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

### Acknowledgements
-We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
+We build on open source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
### Legal
This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
diff --git a/SECURITY.md b/SECURITY.md
index 745c66e61..cbcc975ba 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,12 +1,70 @@
# Security Policy
-NetBird's goal is to provide a secure network. If you find a vulnerability or bug, please report it by opening an issue [here](https://github.com/netbirdio/netbird/issues/new?assignees=&labels=&template=bug-issue-report.md&title=) or by contacting us by email.
-
-There has yet to be an official bug bounty program for the NetBird project.
-
-## Supported Versions
-- We currently support only the latest version
+NetBird's goal is to provide a secure network. The client runs as a privileged service on every machine it is installed on,
+so we take reports about it seriously and we publish what we fix.
## Reporting a Vulnerability
-Please report security issues to `security@netbird.io`
+**Please do not open a public issue for a security vulnerability.** Public issues are visible to everyone, including before
+a fix is available.
+
+Report security issues one of these two ways:
+
+- **GitHub private vulnerability reporting** — [open a private report](https://github.com/netbirdio/netbird/security/advisories/new)
+ on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place.
+- **Email** — `security@netbird.io`.
+
+If the finding affects NetBird Cloud or our hosted infrastructure rather than the open source code, email us rather than
+filing a repository report.
+
+### What to include
+
+A report is easier to act on when it contains:
+
+- The affected component (client, management, signal, relay, dashboard) and the version or commit you tested
+- The platform and configuration, where relevant — operating system, self-hosted or NetBird Cloud, container or host install
+- What an attacker needs before they can exploit it: network position, an account, local access, a specific privilege level
+- Steps to reproduce, and a proof of concept if you have one
+- The impact you believe it has
+
+Partial reports are still welcome. If you are unsure whether something is a security issue, send it to `security@netbird.io`
+and let us make that call.
+
+## What to expect from us
+
+- **We acknowledge your report** and tell you whether we can reproduce it.
+- **We work with you on severity and scope.** If we assess it differently than you do, we will explain why rather than
+ silently downgrade it.
+- **We fix and release**, then publish a [GitHub Security Advisory](https://github.com/netbirdio/netbird/security/advisories)
+ naming the affected version range and the patched version.
+- **We credit reporters who want to be credited.** Tell us the name or handle you would like used, or that you would rather
+ stay anonymous.
+- **We keep you in the loop** until the advisory is published.
+
+We ask that you give us a reasonable opportunity to ship a fix before disclosing the issue publicly, and that you avoid
+accessing, modifying, or exfiltrating data belonging to other people while testing. Testing against your own installation
+or your own account is always fine.
+
+## Supported Versions
+
+We support the latest release. Security fixes ship in the next version rather than as backports to older releases, so
+upgrading to the current release is how you get them.
+
+Release notifications are available by watching [releases](https://github.com/netbirdio/netbird/releases).
+
+## Published advisories
+
+Every vulnerability we fix is published as a GitHub Security Advisory on the
+[advisories page](https://github.com/netbirdio/netbird/security/advisories), including the affected version range, the
+patched version, and the reporter's credit. Advisories for the Go module are also distributed through the Go vulnerability
+database, so `govulncheck` will report them against your dependencies.
+
+## Bug bounty
+
+There is no official bug bounty program for the NetBird project. We credit reporters in advisories, and we are grateful for
+the work, but we cannot currently offer payment for reports.
+
+## Non-security bugs
+
+For bugs that are not security issues, please use the
+[issue tracker](https://github.com/netbirdio/netbird/discussions/new/choose).
diff --git a/agent-network/README.md b/agent-network/README.md
new file mode 100644
index 000000000..5211fe8f9
--- /dev/null
+++ b/agent-network/README.md
@@ -0,0 +1,102 @@
+# NetBird Agent Network
+
+Agent Network is NetBird's access control layer for AI agents and the people who run them.
+It gives every agent a real identity, tied to an identity provider (IdP), and governs what it can reach: LLM APIs and
+AI gateways it can call, and the internal resources it can access. Traffic flows only over the encrypted NetBird tunnel,
+scoped by policy, with no API keys or other credentials to leak. It also gives you control over cost and token usage.
+
+Because every LLM request passes through an
+identity-aware proxy, you can:
+
+- **Set spending and rate limits** per agent, per user, or per team — with hard caps
+ that stop requests once a budget is reached.
+- **Restrict models and providers** so agents can only call approved (and cost-appropriate)
+ endpoints, keeping expensive models off-limits unless explicitly allowed.
+- **Attribute usage** by tracking token consumption and cost per identity, group, or cost center so every
+ request is tied back to the agent and person responsible.
+- **Reuse your existing AI gateway** — point the proxy at a gateway you already run,
+ keeping its routing and config in place while it adds identity on top, so you skip
+ API key distribution.
+
+https://github.com/user-attachments/assets/44d18286-d8ab-49f8-a457-98ccd66f3268
+
+> **Beta.** Agent Network is in beta, but it's stable and already running in
+> production environments. It's fully open source and can be self-hosted on your own
+> infrastructure, with no vendor lock-in and no data leaving your environment.
+
+## How it works
+
+Say you have a simple use case: your Engineering or IT team needs access to Claude Code or Codex, and you want visibility into usage plus the ability to enforce budgets.
+How can you do that without creating a dedicated API key for every team?
+
+With Agent Network you get a private endpoint inside your network, for example: https://mirror.netbird.ai
+Teams configure their agents to point to that endpoint instead of using individual API keys directly.
+
+This endpoint is only reachable when users are connected to your NetBird network and authenticated through your IdP. Otherwise, it is not accessible from the public internet.
+You can then use this private endpoint to configure your AI agents, whether that is Claude Code, Codex, or another tool.
+
+## Quickstart
+
+Full step-by-step setup:
+**https://docs.netbird.io/agent-network/quickstart**
+
+## Client settings that don't follow the endpoint
+
+Most of an agent's traffic follows the base URL you hand it, but a few
+client-side checks call their vendor directly and never reach the proxy. On a
+network that blocks direct egress they fail even though inference works, so
+they are worth setting once when you roll the endpoint out.
+
+For Claude Code:
+
+- **Fast mode** checks availability against `api.anthropic.com` rather than the
+ configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the
+ agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when
+ the proxy injects the real provider key) or when a TLS-inspecting proxy
+ answers the check itself. Set
+ `CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the
+ connection outright. Fast mode is an Anthropic-API feature, so it is
+ unavailable on a Bedrock- or Vertex-backed endpoint whatever you set.
+- **Model discovery** is off by default. Set
+ `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the
+ models your policies authorise; the proxy filters the response to that set.
+ The client gives discovery a three-second budget and treats any redirect as
+ a failure, so the endpoint must serve `/v1/models` directly.
+- **The WebFetch domain safety check** also calls `api.anthropic.com` directly
+ and is unaffected by the variables above.
+
+Allowing direct egress to `api.anthropic.com` covers the network cases but not
+the credential one, where the check reaches Anthropic and is rejected because
+the agent presents a proxy-issued key.
+
+## Architecture
+
+Agent Network is built on two existing NetBird capabilities:
+
+- **Overlay network** — the encrypted WireGuard mesh between peers.
+- **Reverse proxy** — a NetBird peer that terminates LLM requests, establishes the
+ caller's identity, evaluates policies/limits/guardrails, injects the upstream provider
+ key server-side, forwards to the API or gateway, and records usage.
+
+LLM traffic is routed through the proxy's identity-aware pipeline, while internal
+resources (databases, internal APIs, self-hosted models) are reached directly over
+peer-to-peer WireGuard tunnels, governed by the same identities and access policies.
+
+
+
+
+## Where the code lives
+
+There is no separate "agent-network" service — it reuses the reverse-proxy and management
+components:
+
+- [`proxy/`](../proxy) — the NetBird reverse proxy that serves the agent network endpoint
+ and runs the per-request middleware pipeline.
+- [`management/internals/modules/reverseproxy/`](../management/internals/modules/reverseproxy)
+ — the management-side control plane: providers, policies, guardrails, limits, routing,
+ and usage/access logs.
+
+## Documentation
+
+Full documentation, architecture, and quickstart:
+**https://docs.netbird.io/agent-network**
diff --git a/client/android/client.go b/client/android/client.go
index 99ccdf393..7eea83dc0 100644
--- a/client/android/client.go
+++ b/client/android/client.go
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"slices"
+ "strings"
"sync"
"time"
@@ -14,6 +15,7 @@ import (
log "github.com/sirupsen/logrus"
+ nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/debug"
@@ -24,6 +26,8 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/client/net"
+ "github.com/netbirdio/netbird/client/netstate"
+ "github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route"
@@ -31,10 +35,12 @@ import (
types "github.com/netbirdio/netbird/upload-server/types"
)
-// ConnectionListener export internal Listener for mobile
-type ConnectionListener interface {
- peer.Listener
-}
+// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
+// anonymizeLevel values for DebugBundle.
+const (
+ AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
+ AnonymizeLevelStrict = nbAnonymize.LevelStrictString
+)
// TunAdapter export internal TunAdapter for mobile
type TunAdapter interface {
@@ -56,6 +62,12 @@ type DnsReadyListener interface {
dns.ReadyListener
}
+// TunSettings is a snapshot of the settings the TUN device is rebuilt with
+type TunSettings struct {
+ Routes string
+ SearchDomains string
+}
+
func init() {
formatter.SetLogcatFormatter(log.StandardLogger())
}
@@ -70,18 +82,46 @@ type Client struct {
deviceName string
uiVersion string
networkChangeListener listener.NetworkChangeListener
+ // netState outlives engine restarts: it mirrors the OS connectivity, not
+ // the engine lifecycle. Run and RunWithoutLogin inject it into each new
+ // ConnectClient, which distributes it to every reconnection loop.
+ netState *netstate.State
+
+ // sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
+ sweeper *netsweep.Sweeper
stateMu sync.RWMutex
connectClient *internal.ConnectClient
config *profilemanager.Config
cacheDir string
+ // Identifies the running profile for the SSO login hint; see profile_state.go.
+ cfgPath string
+
+ stateChangeMu sync.Mutex
+ stateChangeSubID string
+ eventSub *peer.EventSubscription
+ // Closed to stop the watch goroutines from delivering buffered items to a
+ // listener that has been removed or replaced. See stopStateChangeWatchLocked.
+ stateChangeDone chan struct{}
+
+ // Latched "the server wants an interactive login": survives the engine
+ // restarts that replace the run loop's context state. See Client.Status.
+ // Guarded by loginRequiredMu together with loginCleared, which counts
+ // clears so a stale observation cannot re-latch over one.
+ loginRequiredMu sync.Mutex
+ loginRequired bool
+ loginCleared uint64
+
+ extendMu sync.Mutex
+ extendCancel context.CancelFunc
}
-func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
+func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
c.stateMu.Lock()
defer c.stateMu.Unlock()
c.config = cfg
c.cacheDir = cacheDir
+ c.cfgPath = cfgPath
c.connectClient = cc
}
@@ -91,6 +131,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
return c.config, c.cacheDir, c.connectClient
}
+// authSnapshot returns the config together with the path it was loaded from, in
+// one lock: the path identifies the profile whose account email backs the login
+// hint, so reading it separately could pair one profile's config with another's
+// hint when a profile switch lands in between.
+func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
+ c.stateMu.RLock()
+ defer c.stateMu.RUnlock()
+ return c.config, c.cfgPath, c.connectClient
+}
+
func (c *Client) getConnectClient() *internal.ConnectClient {
c.stateMu.RLock()
defer c.stateMu.RUnlock()
@@ -102,6 +152,7 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
execWorkaround(androidSDKVersion)
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
+ system.SetIFaceDiscover(iFaceDiscover)
return &Client{
deviceName: deviceName,
uiVersion: uiVersion,
@@ -110,6 +161,8 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
recorder: peer.NewRecorder(""),
ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener,
+ netState: netstate.New(),
+ sweeper: netsweep.New(),
}
}
@@ -143,16 +196,22 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
defer c.ctxCancel()
c.ctxCancelLock.Unlock()
- auth := NewAuthWithConfig(ctx, cfg)
+ auth := NewAuthWithConfig(ctx, cfg, cfgFile)
err = auth.login(urlOpener, isAndroidTV)
if err != nil {
return err
}
-
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
- connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
- c.setState(cfg, cacheDir, connectClient)
+ connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
+ internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
+ c.setState(cfg, cacheDir, cfgFile, connectClient)
+ // This path runs the interactive SSO flow, so reaching here means the peer
+ // is authenticated again — release the latch Status() reports from. Clear
+ // only once the fresh connect client is installed: until then Status()
+ // still reads the previous run's context state, which holds the NeedsLogin
+ // that prompted this login, and would re-latch what was just cleared.
+ c.clearLoginRequired()
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -186,8 +245,9 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
- connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
- c.setState(cfg, cacheDir, connectClient)
+ connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
+ internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
+ c.setState(cfg, cacheDir, cfgFile, connectClient)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -216,9 +276,47 @@ func (c *Client) RenewTun(fd int) error {
return e.RenewTun(fd)
}
+func (c *Client) GetTunSettings() (*TunSettings, error) {
+ cc := c.getConnectClient()
+ if cc == nil {
+ return nil, fmt.Errorf("engine not running")
+ }
+
+ e := cc.Engine()
+ if e == nil {
+ return nil, fmt.Errorf("engine not initialized")
+ }
+
+ routes, searchDomains := e.TunSettings()
+ return &TunSettings{
+ Routes: strings.Join(routes, ";"),
+ SearchDomains: strings.Join(searchDomains, ";"),
+ }, nil
+}
+
+// SetNetworkAvailable feeds OS-reported network availability into the client.
+// While unavailable, the internal reconnect loops suspend their attempts and
+// the connection listener reports NoNetwork instead of Connecting; when
+// availability returns, the loops resume immediately with a fresh backoff.
+func (c *Client) SetNetworkAvailable(available bool) {
+ c.netState.Set(available)
+ c.recorder.SetNetworkAvailable(available)
+}
+
+// NotifyNetworkChange marks the management, signal and relay connections
+// stale after the OS switched networks and schedules a sweep that cuts
+// whatever has not redialed on the new network by then. The engine and the
+// TUN device stay untouched.
+func (c *Client) NotifyNetworkChange() {
+ c.sweeper.MarkNetworkChange()
+ log.Infof("network change: connections marked stale")
+}
+
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
-// It works both with and without a running engine.
-func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
+// It works both with and without a running engine. anonymizeLevel is "default"
+// or "strict"; strict also anonymizes internal IP ranges, peer names, and
+// WireGuard public keys, and implies anonymize.
+func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
cfg, cacheDir, cc := c.stateSnapshot()
// If the engine hasn't been started, load config from disk
@@ -237,6 +335,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
InternalConfig: cfg,
StatusRecorder: c.recorder,
TempDir: cacheDir,
+ StatePath: platformFiles.StateFilePath(),
}
if cc != nil {
@@ -247,6 +346,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
deps.SyncResponse = resp
if e := cc.Engine(); e != nil {
+ deps.RefreshStatus = func() {
+ e.RunHealthProbes(context.Background(), true)
+ }
if cm := e.GetClientMetrics(); cm != nil {
deps.ClientMetrics = cm
}
@@ -257,6 +359,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
deps,
debug.BundleConfig{
Anonymize: anonymize,
+ AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
IncludeSystemInfo: true,
},
)
@@ -274,7 +377,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
- key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path)
+ key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false)
if err != nil {
return "", fmt.Errorf("upload debug bundle: %w", err)
}
@@ -296,6 +399,13 @@ func (c *Client) SetInfoLogLevel() {
// PeersList return with the list of the PeerInfos
func (c *Client) PeersList() *PeerInfoArray {
+ // The recorder only caches transfer counters and handshake times; nothing
+ // refreshes them on its own, so without this they read as zero. The desktop
+ // daemon does the same before serving a full peer status.
+ if err := c.recorder.RefreshWireGuardStats(); err != nil {
+ log.Debugf("failed to refresh WireGuard stats: %v", err)
+ }
+
fullStatus := c.recorder.GetFullStatus()
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
@@ -306,6 +416,20 @@ func (c *Client) PeersList() *PeerInfoArray {
FQDN: p.FQDN,
ConnStatus: int(p.ConnStatus),
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
+
+ PubKey: p.PubKey,
+ Latency: formatDuration(p.Latency),
+ LatencyMs: p.Latency.Milliseconds(),
+ BytesRx: p.BytesRx,
+ BytesTx: p.BytesTx,
+ ConnStatusUpdate: formatTime(p.ConnStatusUpdate),
+ Relayed: p.Relayed,
+ RosenpassEnabled: p.RosenpassEnabled,
+ LastWireguardHandshake: formatTime(p.LastWireguardHandshake),
+ LocalIceCandidateType: p.LocalIceCandidateType,
+ RemoteIceCandidateType: p.RemoteIceCandidateType,
+ LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint,
+ RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint,
}
peerInfos[n] = pi
}
@@ -428,7 +552,11 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
// SetConnectionListener set the network connection listener
func (c *Client) SetConnectionListener(listener ConnectionListener) {
- c.recorder.SetConnectionListener(listener)
+ if listener == nil {
+ c.recorder.RemoveConnectionListener()
+ return
+ }
+ c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
}
// RemoveConnectionListener remove connection listener
@@ -436,10 +564,6 @@ func (c *Client) RemoveConnectionListener() {
c.recorder.RemoveConnectionListener()
}
-func (c *Client) toggleRoute(command routeCommand) error {
- return command.toggleRoute()
-}
-
func (c *Client) getRouteManager() (routemanager.Manager, error) {
client := c.getConnectClient()
if client == nil {
@@ -459,22 +583,22 @@ func (c *Client) getRouteManager() (routemanager.Manager, error) {
return manager, nil
}
-func (c *Client) SelectRoute(route string) error {
+func (c *Client) SelectRoute(id string) error {
manager, err := c.getRouteManager()
if err != nil {
return err
}
- return c.toggleRoute(selectRouteCommand{route: route, manager: manager})
+ return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true)
}
-func (c *Client) DeselectRoute(route string) error {
+func (c *Client) DeselectRoute(id string) error {
manager, err := c.getRouteManager()
if err != nil {
return err
}
- return c.toggleRoute(deselectRouteCommand{route: route, manager: manager})
+ return manager.DeselectRoutes([]route.NetID{route.NetID(id)})
}
// getNetworkDomainsFromRoute extracts domains from a route and enriches each domain
@@ -509,3 +633,28 @@ func exportEnvList(list *EnvList) {
}
}
}
+
+// formatDuration renders a duration for display, trimming the fractional part
+// to two digits so latencies read as "12.34ms" rather than "12.345678ms".
+func formatDuration(d time.Duration) string {
+ ds := d.String()
+ dotIndex := strings.Index(ds, ".")
+ if dotIndex == -1 {
+ return ds
+ }
+
+ endIndex := min(dotIndex+3, len(ds))
+
+ // Skip the remaining digits so only the unit suffix is appended back.
+ unitStart := endIndex
+ for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' {
+ unitStart++
+ }
+ return ds[:endIndex] + ds[unitStart:]
+}
+
+// formatTime renders a timestamp in UTC using a fixed layout. The zero time is
+// passed through as-is so the UI can recognise it and show "never" instead.
+func formatTime(t time.Time) string {
+ return t.UTC().Format("2006-01-02 15:04:05")
+}
diff --git a/client/android/connection_listener.go b/client/android/connection_listener.go
new file mode 100644
index 000000000..77c47574b
--- /dev/null
+++ b/client/android/connection_listener.go
@@ -0,0 +1,41 @@
+//go:build android
+
+package android
+
+import (
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+// Client state values delivered via ConnectionListener.OnStateChanged,
+// re-exported as basic constants so gomobile emits them into the generated
+// Java bindings. They mirror peer.ClientState*: append-only, never reorder.
+const (
+ ClientStateDisconnected = int(peer.ClientStateDisconnected)
+ ClientStateConnected = int(peer.ClientStateConnected)
+ ClientStateConnecting = int(peer.ClientStateConnecting)
+ ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
+ ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
+)
+
+// ConnectionListener export internal Listener for mobile. It mirrors
+// peer.Listener with OnStateChanged taking a plain int (one of the
+// ClientState* constants), because gomobile cannot bind named types.
+type ConnectionListener interface {
+ OnStateChanged(state int)
+ OnConnected()
+ OnDisconnected()
+ OnConnecting()
+ OnDisconnecting()
+ OnAddressChanged(string, string)
+ OnPeersListChanged(int)
+}
+
+// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
+// peer.Listener, converting the typed state to the int the binding carries.
+type connectionListenerAdapter struct {
+ ConnectionListener
+}
+
+func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) {
+ a.ConnectionListener.OnStateChanged(int(state))
+}
diff --git a/client/android/env_list.go b/client/android/env_list.go
index a0a4d7040..d0e0a1e78 100644
--- a/client/android/env_list.go
+++ b/client/android/env_list.go
@@ -10,7 +10,7 @@ var (
EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay
// EnvKeyNBLazyConn Exported for Android java client to configure lazy connection
- EnvKeyNBLazyConn = lazyconn.EnvEnableLazyConn
+ EnvKeyNBLazyConn = lazyconn.EnvLazyConn
// EnvKeyNBInactivityThreshold Exported for Android java client to configure connection inactivity threshold
EnvKeyNBInactivityThreshold = lazyconn.EnvInactivityThreshold
diff --git a/client/android/login.go b/client/android/login.go
index a9422cdbf..3742e01a5 100644
--- a/client/android/login.go
+++ b/client/android/login.go
@@ -4,8 +4,11 @@ import (
"context"
"fmt"
+ log "github.com/sirupsen/logrus"
+
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/mobile"
"github.com/netbirdio/netbird/client/system"
)
@@ -36,12 +39,20 @@ type Auth struct {
}
// NewAuth instantiate Auth struct and validate the management URL
+//
+// The configuration at cfgPath is reused when one is already there, and only created when it is
+// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on
+// every call: the peer registers under that key, the key is written out, and any peer registered by
+// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
+// the persisted config, because the identity it registered is not the one it runs with — the
+// management stream rejects it with "no peer auth method provided".
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
inputCfg := profilemanager.ConfigInput{
+ ConfigPath: cfgPath,
ManagementURL: mgmURL,
}
- cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
+ cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
if err != nil {
return nil, err
}
@@ -53,11 +64,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
}, nil
}
-// NewAuthWithConfig instantiate Auth based on existing config
-func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
+// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
+// file the config was loaded from; it identifies the profile whose account email
+// backs the login_hint.
+func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth {
return &Auth{
- ctx: ctx,
- config: config,
+ ctx: ctx,
+ config: config,
+ cfgPath: cfgPath,
}
}
@@ -150,12 +164,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
}
jwtToken := ""
+ email := ""
if needsLogin {
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
jwtToken = tokenInfo.GetTokenToUse()
+ email = tokenInfo.Email
}
err, _ = authClient.Login(a.ctx, "", jwtToken)
@@ -163,27 +179,62 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
return fmt.Errorf("login failed: %v", err)
}
+ // Stored after Login, not before: a rejected token must not leave a hint
+ // pointing at an account that cannot be used.
+ if email != "" && a.cfgPath != "" {
+ if err := mobile.WriteProfileEmail(a.cfgPath, email); err != nil {
+ log.Warnf("failed to store profile account email: %v", err)
+ }
+ }
+
go urlOpener.OnLoginSuccess()
return nil
}
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
- oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
+ oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath))
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
- flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
+ return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil)
+}
+
+// profileLoginHint returns the stored account email for the profile at cfgPath.
+// An empty hint is deliberate, not a fallback: a fresh profile leaves the
+// choice to the IdP. Switching accounts is done by switching or removing
+// profiles, not by logging out — logout keeps the email.
+func profileLoginHint(cfgPath string) string {
+ if cfgPath == "" {
+ return ""
+ }
+ return mobile.ReadProfileEmail(cfgPath)
+}
+
+// runOAuthFlow drives an already acquired OAuth flow to a token: requests the
+// flow info, presents the verification URL through the opener and waits for
+// the browser round-trip. Open is called synchronously — it is what marks the
+// surface as opened on the client side, and a fast token's OnLoginSuccess is
+// a no-op until it has, so the dismissal would be dropped rather than
+// delayed. Openers must therefore not block: they post their UI work and
+// return. onWaiting, when set, runs after the URL is shown, right before the
+// blocking wait.
+func runOAuthFlow(ctx context.Context, flow auth.OAuthFlow, urlOpener URLOpener, onWaiting func()) (*auth.TokenInfo, error) {
+ flowInfo, err := flow.RequestAuthInfo(ctx)
if err != nil {
- return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
+ return nil, fmt.Errorf("request auth info: %w", err)
}
- go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
+ urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
- tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo)
+ if onWaiting != nil {
+ onWaiting()
+ }
+
+ tokenInfo, err := flow.WaitToken(ctx, flowInfo)
if err != nil {
- return nil, fmt.Errorf("waiting for browser login failed: %v", err)
+ return nil, fmt.Errorf("wait for token: %w", err)
}
return &tokenInfo, nil
diff --git a/client/android/login_test.go b/client/android/login_test.go
new file mode 100644
index 000000000..b04790f6b
--- /dev/null
+++ b/client/android/login_test.go
@@ -0,0 +1,51 @@
+package android
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one.
+//
+// Creating a new in-memory config on every call gives the client a new WireGuard private key each
+// time. The peer registers under that key and the key is written out, so a peer registered by an
+// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns
+// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so
+// the identity that registered is not the identity that runs, and the management stream rejects it
+// with "no peer auth method provided, please use a setup key or interactive SSO login".
+func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
+ cfgPath := filepath.Join(t.TempDir(), "config.json")
+
+ first, err := NewAuth(cfgPath, "https://api.example.com:443")
+ if err != nil {
+ t.Fatalf("first NewAuth: %v", err)
+ }
+ if first.config.PrivateKey == "" {
+ t.Fatal("first NewAuth produced no private key")
+ }
+
+ second, err := NewAuth(cfgPath, "https://api.example.com:443")
+ if err != nil {
+ t.Fatalf("second NewAuth: %v", err)
+ }
+
+ if second.config.PrivateKey != first.config.PrivateKey {
+ t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first")
+ }
+}
+
+// A missing configuration is still created, so a first enrolment works unchanged.
+func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
+ cfgPath := filepath.Join(t.TempDir(), "config.json")
+
+ auth, err := NewAuth(cfgPath, "https://api.example.com:443")
+ if err != nil {
+ t.Fatalf("NewAuth: %v", err)
+ }
+ if auth.config == nil || auth.config.PrivateKey == "" {
+ t.Fatal("NewAuth did not create a usable configuration")
+ }
+ if auth.cfgPath != cfgPath {
+ t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath)
+ }
+}
diff --git a/client/android/peer_notifier.go b/client/android/peer_notifier.go
index c2595e574..f525055bb 100644
--- a/client/android/peer_notifier.go
+++ b/client/android/peer_notifier.go
@@ -12,12 +12,30 @@ const (
)
// PeerInfo describe information about the peers. It designed for the UI usage
+//
+// The fields below ConnStatus back the peer detail screen. Durations and times
+// are pre-formatted into strings so the UI does not have to know Go's layouts;
+// Latency is additionally exposed as LatencyMs for colour coding.
type PeerInfo struct {
IP string
IPv6 string
FQDN string
ConnStatus int
Routes PeerRoutes
+
+ PubKey string
+ Latency string
+ LatencyMs int64
+ BytesRx int64
+ BytesTx int64
+ ConnStatusUpdate string
+ Relayed bool
+ RosenpassEnabled bool
+ LastWireguardHandshake string
+ LocalIceCandidateType string
+ RemoteIceCandidateType string
+ LocalIceCandidateEndpoint string
+ RemoteIceCandidateEndpoint string
}
func (p *PeerInfo) GetPeerRoutes() *PeerRoutes {
diff --git a/client/android/profile_prefs.go b/client/android/profile_prefs.go
new file mode 100644
index 000000000..a761ebbcf
--- /dev/null
+++ b/client/android/profile_prefs.go
@@ -0,0 +1,37 @@
+//go:build android
+
+package android
+
+import (
+ "fmt"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+)
+
+type prefsStore interface {
+ Get(namespace string, v any) (bool, error)
+ Put(namespace string, v any) error
+}
+
+type profilePrefs struct {
+ prefs *profilemanager.Prefs
+}
+
+func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) {
+ if configDir == "" || profileID == "" {
+ return nil, fmt.Errorf("profile prefs require a config dir and profile ID")
+ }
+ prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID)
+ if err != nil {
+ return nil, err
+ }
+ return &profilePrefs{prefs: prefs}, nil
+}
+
+func (p *profilePrefs) Get(namespace string, v any) (bool, error) {
+ return p.prefs.Get(namespace, v)
+}
+
+func (p *profilePrefs) Put(namespace string, v any) error {
+ return p.prefs.Put(namespace, v)
+}
diff --git a/client/android/route_command.go b/client/android/route_command.go
deleted file mode 100644
index 5e7357335..000000000
--- a/client/android/route_command.go
+++ /dev/null
@@ -1,70 +0,0 @@
-//go:build android
-
-package android
-
-import (
- "fmt"
-
- log "github.com/sirupsen/logrus"
- "golang.org/x/exp/maps"
-
- "github.com/netbirdio/netbird/client/internal/routemanager"
- "github.com/netbirdio/netbird/route"
-)
-
-func executeRouteToggle(id string, manager routemanager.Manager,
- operationName string,
- routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error {
- netID := route.NetID(id)
- routes := []route.NetID{netID}
-
- routesMap := manager.GetClientRoutesWithNetID()
- routes = route.ExpandV6ExitPairs(routes, routesMap)
-
- log.Debugf("%s with ids: %v", operationName, routes)
-
- if err := routeOperation(routes, maps.Keys(routesMap)); err != nil {
- log.Debugf("error when %s: %s", operationName, err)
- return fmt.Errorf("error %s: %w", operationName, err)
- }
-
- manager.TriggerSelection(manager.GetClientRoutes())
-
- return nil
-}
-
-type routeCommand interface {
- toggleRoute() error
-}
-
-type selectRouteCommand struct {
- route string
- manager routemanager.Manager
-}
-
-func (s selectRouteCommand) toggleRoute() error {
- routeSelector := s.manager.GetRouteSelector()
- if routeSelector == nil {
- return fmt.Errorf("no route selector available")
- }
-
- routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error {
- return routeSelector.SelectRoutes(routes, true, allRoutes)
- }
-
- return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation)
-}
-
-type deselectRouteCommand struct {
- route string
- manager routemanager.Manager
-}
-
-func (d deselectRouteCommand) toggleRoute() error {
- routeSelector := d.manager.GetRouteSelector()
- if routeSelector == nil {
- return fmt.Errorf("no route selector available")
- }
-
- return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes)
-}
diff --git a/client/android/session.go b/client/android/session.go
new file mode 100644
index 000000000..d5da09c93
--- /dev/null
+++ b/client/android/session.go
@@ -0,0 +1,312 @@
+//go:build android
+
+package android
+
+import (
+ "context"
+ "fmt"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/auth"
+ "github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ cProto "github.com/netbirdio/netbird/client/proto"
+)
+
+// StateChangeListener receives client state notifications.
+//
+// OnStateChanged is a payload-free wake-up whenever the state snapshot
+// changed: connection state, the run-loop status label (e.g. NeedsLogin) or
+// the session deadline. It mirrors the daemon's SubscribeStatus stream
+// trigger — on each signal the consumer pulls the fresh values via
+// Status() / SessionExpiresAtUnix().
+//
+// OnSessionExpiring forwards the engine's session-expiry warnings, fired at
+// sessionwatch.WarningLead before the deadline and again at FinalWarningLead
+// (finalWarning true). The second one is suppressed when the user dismissed
+// the first via DismissSessionWarning. The daemon turns the same events into
+// its tray notification.
+type StateChangeListener interface {
+ OnStateChanged()
+ OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool)
+}
+
+// Status returns the connect run-loop's status label — the same value the
+// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the
+// management server rejected the peer and an interactive login is required.
+//
+// The label is latched: the run loop keeps its status in a per-run context
+// state, which a restart replaces with a fresh Idle one, so an engine restart
+// (network change, always-on) would otherwise erase the fact that the peer
+// still needs to log in. Only a successful interactive login or extend clears
+// it — see clearLoginRequired.
+func (c *Client) Status() string {
+ latched, generation := c.loginRequiredState()
+ if latched {
+ return string(internal.StatusNeedsLogin)
+ }
+ cc := c.getConnectClient()
+ if cc == nil {
+ return string(internal.StatusIdle)
+ }
+ status := cc.Status()
+ if status == internal.StatusNeedsLogin {
+ c.latchLoginRequired(generation)
+ }
+ return string(status)
+}
+
+func (c *Client) loginRequiredState() (bool, uint64) {
+ c.loginRequiredMu.Lock()
+ defer c.loginRequiredMu.Unlock()
+ return c.loginRequired, c.loginCleared
+}
+
+// latchLoginRequired records a NeedsLogin observation, unless a clear landed
+// while the caller was reading the run loop's status: cc.Status() is read
+// outside the lock, so a login or extend completing in that window would
+// otherwise be undone by this stale observation, stranding the UI on
+// "login required" over a healthy session.
+func (c *Client) latchLoginRequired(observedGeneration uint64) {
+ c.loginRequiredMu.Lock()
+ defer c.loginRequiredMu.Unlock()
+ if c.loginCleared != observedGeneration {
+ return
+ }
+ c.loginRequired = true
+}
+
+// clearLoginRequired releases the latch after a successful interactive login
+// or session extend, and invalidates any observation already in flight.
+func (c *Client) clearLoginRequired() {
+ c.loginRequiredMu.Lock()
+ defer c.loginRequiredMu.Unlock()
+ c.loginRequired = false
+ c.loginCleared++
+}
+
+// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0
+// when no deadline is known (not SSO-registered, expiry disabled, or the
+// engine has not received one yet). A past value means the session expired.
+// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon.
+func (c *Client) SessionExpiresAtUnix() int64 {
+ deadline := c.recorder.GetSessionExpiresAt()
+ if deadline.IsZero() {
+ return 0
+ }
+ return deadline.Unix()
+}
+
+// SetStateChangeListener registers the state notification listener.
+// Replaces any previously registered listener; remove it with
+// RemoveStateChangeListener.
+func (c *Client) SetStateChangeListener(listener StateChangeListener) {
+ c.stateChangeMu.Lock()
+ defer c.stateChangeMu.Unlock()
+ c.stopStateChangeWatchLocked()
+ if listener == nil {
+ return
+ }
+
+ // Both subscriptions are buffered (one pending tick, ten pending events),
+ // so unsubscribing is not enough to stop callbacks: the loops would drain
+ // what is already queued and deliver it to a listener the caller has
+ // already removed or replaced. Gate every callback on this registration's
+ // own signal, which is closed before unsubscribing.
+ done := make(chan struct{})
+ c.stateChangeDone = done
+
+ id, ch := c.recorder.SubscribeToStateChanges()
+ c.stateChangeSubID = id
+ // The channel is closed by UnsubscribeFromStateChanges, which ends the
+ // goroutine. Ticks are coalesced (buffer of one), so a burst of changes
+ // wakes the listener once.
+ go func() {
+ for range ch {
+ select {
+ case <-done:
+ return
+ default:
+ }
+ listener.OnStateChanged()
+ }
+ }()
+
+ c.eventSub = c.recorder.SubscribeToEvents()
+ go watchSessionWarnings(c.eventSub, listener, done)
+}
+
+// RemoveStateChangeListener unregisters the state notification listener.
+func (c *Client) RemoveStateChangeListener() {
+ c.stateChangeMu.Lock()
+ defer c.stateChangeMu.Unlock()
+ c.stopStateChangeWatchLocked()
+}
+
+// DismissSessionWarning records the user's "Dismiss" on the first expiry
+// warning and suppresses the final one for the current deadline. A refreshed
+// deadline re-arms both. No-op while the engine is not running.
+func (c *Client) DismissSessionWarning() {
+ cc := c.getConnectClient()
+ if cc == nil {
+ return
+ }
+ engine := cc.Engine()
+ if engine == nil {
+ return
+ }
+ engine.DismissSessionWarning()
+}
+
+// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and
+// asks the management server to extend the session deadline. The tunnel is
+// untouched: no resync, no reconnect. Async; the result arrives on the
+// listener. Mirror of the daemon's RequestExtendAuthSession /
+// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the
+// browser" role.
+//
+// Only one flow may be in flight: the PKCE step binds a fixed loopback port,
+// so a second concurrent flow would fail on that bind. Call
+// CancelExtendAuthSession when the user abandons the browser.
+func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) {
+ ctx, err := c.beginExtend()
+ if err != nil {
+ resultListener.OnError(err)
+ return
+ }
+
+ go func() {
+ defer c.endExtend()
+ if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil {
+ resultListener.OnError(err)
+ return
+ }
+ resultListener.OnSuccess()
+ }()
+}
+
+// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is
+// left alone — unlike the login flow, which cancels the whole client context
+// by stopping the engine. Without this the abandoned PKCE wait keeps its
+// loopback port for the full flow timeout and blocks every later attempt.
+// No-op when no flow is running.
+func (c *Client) CancelExtendAuthSession() {
+ c.extendMu.Lock()
+ defer c.extendMu.Unlock()
+ if c.extendCancel != nil {
+ c.extendCancel()
+ }
+}
+
+func (c *Client) stopStateChangeWatchLocked() {
+ // Signal first, unsubscribe second: closing the channels only stops new
+ // items, and the loops would still hand whatever is buffered to a listener
+ // that is no longer registered.
+ if c.stateChangeDone != nil {
+ close(c.stateChangeDone)
+ c.stateChangeDone = nil
+ }
+ if c.stateChangeSubID != "" {
+ c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID)
+ c.stateChangeSubID = ""
+ }
+ if c.eventSub != nil {
+ // Closes the channel, which ends watchSessionWarnings.
+ c.recorder.UnsubscribeFromEvents(c.eventSub)
+ c.eventSub = nil
+ }
+}
+
+// watchSessionWarnings forwards the engine's session-expiry warnings to the
+// listener. The event stream also carries unrelated traffic — network-map
+// updates on every sync, DNS and route errors — so everything but an
+// AUTHENTICATION event carrying the session-warning marker is dropped. Exits
+// when the subscription is closed by UnsubscribeFromEvents, or earlier when
+// done is closed — the stream buffers up to ten events, and a deregistered
+// listener must not receive the ones already queued.
+func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) {
+ for ev := range sub.Events() {
+ select {
+ case <-done:
+ return
+ default:
+ }
+ if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION {
+ continue
+ }
+ meta := ev.GetMetadata()
+ if meta[sessionwatch.MetaSessionWarning] != "true" {
+ // Other AUTHENTICATION events exist (e.g. a deadline rejected as
+ // out of range); they carry no warning marker.
+ continue
+ }
+ deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt])
+ if err != nil {
+ log.Warnf("session warning event with unparsable deadline: %v", err)
+ continue
+ }
+ lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes])
+ if err != nil {
+ // Informational only — the deadline above is what drives the UI.
+ lead = 0
+ }
+ listener.OnSessionExpiring(deadline.Unix(), int64(lead),
+ meta[sessionwatch.MetaSessionFinal] == "true")
+ }
+}
+
+func (c *Client) beginExtend() (context.Context, error) {
+ c.extendMu.Lock()
+ defer c.extendMu.Unlock()
+ if c.extendCancel != nil {
+ return nil, fmt.Errorf("session extend already in progress")
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ c.extendCancel = cancel
+ return ctx, nil
+}
+
+func (c *Client) endExtend() {
+ c.extendMu.Lock()
+ defer c.extendMu.Unlock()
+ if c.extendCancel != nil {
+ c.extendCancel()
+ c.extendCancel = nil
+ }
+}
+
+func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
+ cfg, cfgPath, cc := c.authSnapshot()
+ if cfg == nil || cc == nil {
+ return fmt.Errorf("engine is not running")
+ }
+ engine := cc.Engine()
+ if engine == nil {
+ return fmt.Errorf("engine is not initialized")
+ }
+
+ authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg)
+ if err != nil {
+ return fmt.Errorf("failed to create auth client: %v", err)
+ }
+ defer authClient.Close()
+
+ // Passing the config path makes the flow pick up the login_hint: an extend
+ // renews the session of the account already signed in, so it must not stop to
+ // offer a choice.
+ a := NewAuthWithConfig(ctx, cfg, cfgPath)
+ tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
+ if err != nil {
+ return fmt.Errorf("interactive sso login failed: %v", err)
+ }
+
+ if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil {
+ return err
+ }
+ c.clearLoginRequired()
+
+ go urlOpener.OnLoginSuccess()
+ return nil
+}
diff --git a/client/android/ssh_client.go b/client/android/ssh_client.go
new file mode 100644
index 000000000..2822b6539
--- /dev/null
+++ b/client/android/ssh_client.go
@@ -0,0 +1,649 @@
+//go:build android
+
+package android
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ gossh "golang.org/x/crypto/ssh"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/auth"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ "github.com/netbirdio/netbird/client/ssh/detection"
+)
+
+const (
+ sshDialTimeout = 30 * time.Second
+ sshDetectionTimeout = 5 * time.Second
+)
+
+// PasswordRequiredMarker tells Java to prompt for a password and retry. It is
+// a string because gomobile flattens errors to their message, so a sentinel
+// value would not survive the binding.
+const PasswordRequiredMarker = "netbird-ssh-password-required"
+
+// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation,
+// retry with TrustHostKey set. The presented fingerprint is appended after the
+// marker so the prompt can display it and the retry can guard against a key
+// that changed between the two connects. Only regular (non-NetBird) servers
+// reach this: NetBird peers verify against the registry.
+const HostKeyUnknownMarker = "netbird-ssh-hostkey-unknown"
+
+var (
+ errPasswordRequired = errors.New(PasswordRequiredMarker)
+ errClientClosed = errors.New("ssh client closed")
+)
+
+// errHostKeyUnknown carries the presented fingerprint so Connect can build the
+// marker message the Java side parses.
+type errHostKeyUnknown struct {
+ fingerprint string
+}
+
+func (e *errHostKeyUnknown) Error() string {
+ return HostKeyUnknownMarker + ":" + e.fingerprint
+}
+
+// SSHTerminalListener receives SSH session events. It is implemented in Java.
+//
+// All callbacks are invoked from goroutines and may run concurrently with each
+// other; the implementation must be safe to call from any thread.
+type SSHTerminalListener interface {
+ OnConnected()
+ OnData(data []byte)
+ OnClose(reason string)
+ OnError(message string)
+}
+
+// SSHClient is a NetBird-aware SSH client exposed to Java via gomobile.
+//
+// It dials through the running NetBird tunnel and runs a standard SSH session
+// on top with PTY enabled. Host-key verification uses the NetBird-provided
+// peer SSH host keys, identical to the desktop client.
+type SSHClient struct {
+ nb *Client
+ mu sync.Mutex
+ listener SSHTerminalListener
+ urlOpener URLOpener
+
+ sshClient *gossh.Client
+ session *gossh.Session
+ stdin io.WriteCloser
+ closed bool
+
+ // gen identifies the current connection attempt. Connect and Close bump it,
+ // so an in-flight dial or a reader left over from a previous connection
+ // finds itself stale and stays silent instead of publishing OnConnected or
+ // OnClose for a connection the caller already abandoned.
+ gen uint64
+ dialCancel context.CancelFunc
+
+ // knownHostsConfigDir and knownHostsProfile locate the TOFU store for
+ // regular SSH servers in the profile's preferences. Java supplies them,
+ // since an overlay IP is a different host under a different profile. Empty
+ // until set: without them a regular server cannot be verified and Connect
+ // refuses one.
+ knownHostsConfigDir string
+ knownHostsProfile string
+ // trustHostKey carries the fingerprint the user confirmed on a previous
+ // attempt, so the retry accepts exactly that key and persists it.
+ trustHostKey string
+}
+
+// NewSSHClient creates a new SSH client bound to the running NetBird Client.
+func NewSSHClient(c *Client) *SSHClient {
+ return &SSHClient{nb: c}
+}
+
+// SetListener registers the Java listener. Must be called before Connect to
+// receive any events.
+func (s *SSHClient) SetListener(l SSHTerminalListener) {
+ s.mu.Lock()
+ s.listener = l
+ s.mu.Unlock()
+}
+
+// SetURLOpener registers the Java URL opener used to display the device-code
+// authorization page in a Custom Tabs window when the target peer requires
+// JWT authentication. Must be set before Connect to be effective.
+func (s *SSHClient) SetURLOpener(opener URLOpener) {
+ s.mu.Lock()
+ s.urlOpener = opener
+ s.mu.Unlock()
+}
+
+// SetKnownHostsStore points the TOFU host-key store at a profile's preferences.
+// Must be set before connecting to a regular SSH server; without it such a
+// server cannot be verified and Connect refuses one.
+func (s *SSHClient) SetKnownHostsStore(configDir, profileID string) {
+ s.mu.Lock()
+ s.knownHostsConfigDir = configDir
+ s.knownHostsProfile = profileID
+ s.mu.Unlock()
+}
+
+// TrustHostKey records the fingerprint the user confirmed for a regular server,
+// so the next Connect accepts that exact key and adds it to the known-hosts
+// store. Passing a fingerprint that no longer matches makes the connect fail
+// rather than trust a key that changed since the prompt.
+func (s *SSHClient) TrustHostKey(fingerprint string) {
+ s.mu.Lock()
+ s.trustHostKey = fingerprint
+ s.mu.Unlock()
+}
+
+// Connect dials the SSH server through the NetBird tunnel and performs the
+// SSH handshake. It auto-detects the server type via SSH banner inspection
+// and selects the appropriate authentication path:
+//
+// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
+// flow, opens the verification URL through the registered URLOpener, and
+// uses the resulting token as the SSH password. Host-key verification
+// uses the NetBird peer registry.
+// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
+// private key. Host-key verification uses the NetBird peer registry.
+// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
+// first (so a user-installed NetBird public key works), then falls back
+// to the supplied password if non-empty. Host-key verification is
+// trust-on-first-use against the per-profile known-hosts store.
+//
+// The password parameter is only consulted for regular SSH servers.
+func (s *SSHClient) Connect(host string, port int, user, password string) error {
+ if port < 1 || port > 65535 {
+ return fmt.Errorf("invalid port: %d", port)
+ }
+
+ cfg, cfgPath, cc := s.nb.authSnapshot()
+ if cc == nil {
+ return errors.New("netbird client not running")
+ }
+ if cfg == nil {
+ return errors.New("netbird config not loaded")
+ }
+ engine := cc.Engine()
+ if engine == nil {
+ return errors.New("netbird engine not available")
+ }
+
+ s.mu.Lock()
+ s.gen++
+ gen := s.gen
+ s.mu.Unlock()
+
+ serverType := detectServerType(host, port)
+ log.Debugf("SSH server type: %s", serverType)
+
+ authMethods, hostKeyCallback, err := s.buildAuth(cfg, cfgPath, engine, serverType, password)
+ if err != nil {
+ return err
+ }
+
+ clientConfig := &gossh.ClientConfig{
+ User: user,
+ Auth: authMethods,
+ HostKeyCallback: hostKeyCallback,
+ Timeout: sshDialTimeout,
+ }
+ err = s.dialAndHandshake(gen, host, port, clientConfig)
+
+ // An unknown host key is a prompt, not a failure: return the marker intact
+ // (rootCause would unwrap it) so Java can show the fingerprint and retry.
+ var unknownHost *errHostKeyUnknown
+ if errors.As(err, &unknownHost) {
+ return errors.New(unknownHost.Error())
+ }
+
+ // A regular server may still accept a password, so let the caller ask for
+ // one instead of failing. NetBird servers never use a password, so a
+ // failure there is genuine.
+ if err != nil && serverType != detection.ServerTypeNetBirdJWT &&
+ serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) &&
+ passwordCouldHelp(err, password != "") {
+ return errPasswordRequired
+ }
+ if err != nil {
+ return rootCause(err)
+ }
+ return nil
+}
+
+// StartSession requests a PTY and starts an interactive shell. Output from
+// the session is forwarded to the listener via OnData.
+func (s *SSHClient) StartSession(cols, rows int) error {
+ err := s.startSession(cols, rows)
+ if err != nil {
+ log.Infof("SSH: start session failed: %v", err)
+ return rootCause(err)
+ }
+ return nil
+}
+
+// Write sends data to the SSH session stdin.
+func (s *SSHClient) Write(data []byte) error {
+ s.mu.Lock()
+ stdin := s.stdin
+ s.mu.Unlock()
+ if stdin == nil {
+ return errors.New("ssh session not started")
+ }
+ if _, err := stdin.Write(data); err != nil {
+ return fmt.Errorf("write stdin: %w", err)
+ }
+ return nil
+}
+
+// Resize updates the PTY window size.
+func (s *SSHClient) Resize(cols, rows int) error {
+ s.mu.Lock()
+ session := s.session
+ s.mu.Unlock()
+ if session == nil {
+ return errors.New("ssh session not started")
+ }
+ return session.WindowChange(rows, cols)
+}
+
+// Reset makes a closed client usable for another Connect: Close leaves the
+// one-shot guard set, and clearing it lets the same client back a reconnect.
+func (s *SSHClient) Reset() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.closed = false
+}
+
+// Close terminates the SSH session and underlying connection. Safe to call
+// multiple times.
+func (s *SSHClient) Close() error {
+ s.mu.Lock()
+ s.gen++
+ if s.dialCancel != nil {
+ s.dialCancel()
+ s.dialCancel = nil
+ }
+ sshClient := s.sshClient
+ session := s.session
+ stdin := s.stdin
+ s.sshClient = nil
+ s.session = nil
+ s.stdin = nil
+ notify := !s.closed
+ s.closed = true
+ listener := s.listener
+ s.mu.Unlock()
+
+ if stdin != nil {
+ if err := stdin.Close(); err != nil {
+ log.Debugf("ssh: stdin close: %v", err)
+ }
+ }
+ if session != nil {
+ if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
+ log.Debugf("ssh: session close: %v", err)
+ }
+ }
+ var firstErr error
+ if sshClient != nil {
+ if err := sshClient.Close(); err != nil {
+ firstErr = err
+ }
+ }
+ if notify && listener != nil {
+ listener.OnClose("closed by client")
+ }
+ return firstErr
+}
+
+func (s *SSHClient) startSession(cols, rows int) error {
+ log.Debugf("SSH: starting session %dx%d", cols, rows)
+ s.mu.Lock()
+ sshClient := s.sshClient
+ gen := s.gen
+ s.mu.Unlock()
+
+ if sshClient == nil {
+ return errors.New("ssh client not connected")
+ }
+
+ pty, err := nbssh.StartPTYSession(sshClient, cols, rows)
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ if gen != s.gen {
+ s.mu.Unlock()
+ closeQuiet(pty.Session, "stale session")
+ return errClientClosed
+ }
+ s.session = pty.Session
+ s.stdin = pty.Stdin
+ s.mu.Unlock()
+
+ readerDone := make(chan string, 2)
+ go func() { readerDone <- s.readLoop(pty.Stdout, "stdout") }()
+ go func() { readerDone <- s.readLoop(pty.Stderr, "stderr") }()
+ go func() {
+ reason := <-readerDone
+ if second := <-readerDone; reason == "" {
+ reason = second
+ }
+ s.notifyClose(gen, reason)
+ }()
+ log.Debug("SSH: session started, shell running")
+ return nil
+}
+
+func (s *SSHClient) buildAuth(cfg *profilemanager.Config, cfgPath string, engine *internal.Engine,
+ serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
+
+ switch serverType {
+ case detection.ServerTypeNetBirdJWT:
+ token, err := s.requestJWTToken(cfg, cfgPath)
+ if err != nil {
+ return nil, nil, fmt.Errorf("jwt: %w", err)
+ }
+ auths := []gossh.AuthMethod{gossh.Password(token)}
+ return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
+
+ case detection.ServerTypeNetBirdNoJWT:
+ if cfg.SSHKey == "" {
+ return nil, nil, errors.New("no NetBird SSH key available")
+ }
+ signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
+ if err != nil {
+ return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
+ }
+ auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
+ return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
+
+ case detection.ServerTypeRegular:
+ var auths []gossh.AuthMethod
+ if cfg.SSHKey != "" {
+ if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
+ auths = append(auths, gossh.PublicKeys(signer))
+ } else {
+ log.Debugf("ssh: parse netbird key for regular auth: %v", err)
+ }
+ }
+ if password != "" {
+ pw := password
+ auths = append(auths, gossh.Password(pw))
+ auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
+ answers := make([]string, len(questions))
+ for i := range questions {
+ answers[i] = pw
+ }
+ return answers, nil
+ }))
+ }
+ if len(auths) == 0 {
+ // Nothing to offer at all: ask for a password rather than failing,
+ // so the caller can retry once the user supplies one.
+ return nil, nil, errPasswordRequired
+ }
+ callback, err := s.tofuHostKeyCallback()
+ if err != nil {
+ return nil, nil, err
+ }
+ return auths, callback, nil
+
+ default:
+ return nil, nil, fmt.Errorf("unsupported SSH server type: %v", serverType)
+ }
+}
+
+// tofuHostKeyCallback verifies a regular server's host key against the
+// per-profile known-hosts store. An unknown host returns errHostKeyUnknown so
+// Java can show the fingerprint and, once confirmed, retry with the key
+// trusted; a changed key is rejected outright, as OpenSSH does. When the user
+// has confirmed a fingerprint, the callback accepts exactly that key and
+// appends it to the store.
+func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) {
+ s.mu.Lock()
+ configDir := s.knownHostsConfigDir
+ profileID := s.knownHostsProfile
+ trusted := s.trustHostKey
+ s.mu.Unlock()
+
+ if configDir == "" || profileID == "" {
+ return nil, errors.New("no known-hosts store configured for regular SSH")
+ }
+
+ store, err := openKnownHostsStore(configDir, profileID)
+ if err != nil {
+ return nil, fmt.Errorf("load known-hosts store: %w", err)
+ }
+
+ return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
+ verdict, err := store.verify(hostname, remote, key)
+ if err != nil {
+ return err
+ }
+ if verdict == hostKeyMatched {
+ return nil
+ }
+ if verdict == hostKeyChanged {
+ return fmt.Errorf("SSH host key changed for %s (possible attack)", hostname)
+ }
+
+ fingerprint := gossh.FingerprintSHA256(key)
+ if trusted == "" {
+ return &errHostKeyUnknown{fingerprint: fingerprint}
+ }
+ if trusted != fingerprint {
+ return fmt.Errorf("SSH host key changed since it was confirmed for %s", hostname)
+ }
+ if err := store.append(hostname, remote, key); err != nil {
+ return fmt.Errorf("persist trusted host key: %w", err)
+ }
+ // The confirmation is spent: now that the key is stored, a later
+ // reconnect must verify against the file, not re-accept this fingerprint.
+ s.mu.Lock()
+ s.trustHostKey = ""
+ s.mu.Unlock()
+ return nil
+ }, nil
+}
+
+func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string) (string, error) {
+ s.mu.Lock()
+ urlOpener := s.urlOpener
+ s.mu.Unlock()
+ if urlOpener == nil {
+ return "", errors.New("URL opener not configured for JWT auth")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath))
+ if err != nil {
+ return "", fmt.Errorf("create oauth flow: %w", err)
+ }
+
+ // The status callback covers the browser round-trip, which would
+ // otherwise leave the terminal blank.
+ tokenInfo, err := runOAuthFlow(ctx, flow, urlOpener, func() {
+ s.notifyStatus("Waiting for browser authentication...")
+ })
+ if err != nil {
+ return "", err
+ }
+
+ token := tokenInfo.GetTokenToUse()
+ if token == "" {
+ return "", errors.New("empty token returned by IdP")
+ }
+
+ // Tells the client the browser round-trip is over so it can dismiss the
+ // surface it opened, the same way the login and session-extend flows do.
+ // Without it the Custom Tab stays in front of the terminal even though the
+ // token has already been collected.
+ urlOpener.OnLoginSuccess()
+
+ return token, nil
+}
+
+func (s *SSHClient) dialAndHandshake(gen uint64, host string, port int, clientConfig *gossh.ClientConfig) error {
+ addr := net.JoinHostPort(host, strconv.Itoa(port))
+ ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
+ defer cancel()
+
+ s.mu.Lock()
+ if gen != s.gen {
+ s.mu.Unlock()
+ return errClientClosed
+ }
+ s.dialCancel = cancel
+ s.mu.Unlock()
+
+ var dialer net.Dialer
+ conn, err := dialer.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return fmt.Errorf("dial %s: %w", addr, err)
+ }
+
+ client, err := nbssh.Handshake(ctx, conn, addr, clientConfig)
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ if gen != s.gen {
+ s.mu.Unlock()
+ closeQuiet(client, "stale ssh client")
+ return errClientClosed
+ }
+ s.sshClient = client
+ listener := s.listener
+ s.mu.Unlock()
+
+ if listener != nil {
+ listener.OnConnected()
+ }
+ return nil
+}
+
+func (s *SSHClient) readLoop(r io.Reader, name string) string {
+ buf := make([]byte, 4096)
+ for {
+ n, err := r.Read(buf)
+ if n > 0 {
+ s.mu.Lock()
+ listener := s.listener
+ s.mu.Unlock()
+ if listener != nil {
+ chunk := make([]byte, n)
+ copy(chunk, buf[:n])
+ listener.OnData(chunk)
+ }
+ }
+ if err != nil {
+ // EOF is a normal shell exit, so report it without a reason.
+ if errors.Is(err, io.EOF) {
+ return ""
+ }
+ log.Debugf("ssh %s read: %v", name, err)
+ return rootCause(err).Error()
+ }
+ }
+}
+
+// notifyStatus writes a progress line to the terminal through the normal
+// output path, so long steps are visible while nothing else is arriving.
+func (s *SSHClient) notifyStatus(text string) {
+ s.mu.Lock()
+ listener := s.listener
+ s.mu.Unlock()
+ if listener != nil {
+ listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n"))
+ }
+}
+
+func (s *SSHClient) notifyClose(gen uint64, reason string) {
+ s.mu.Lock()
+ if gen != s.gen || s.closed {
+ s.mu.Unlock()
+ return
+ }
+ s.closed = true
+ listener := s.listener
+ s.mu.Unlock()
+ if listener != nil {
+ listener.OnClose(reason)
+ }
+}
+
+func closeQuiet(c io.Closer, label string) {
+ if c == nil {
+ return
+ }
+ if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
+ log.Debugf("ssh: close %s: %v", label, err)
+ }
+}
+
+func detectServerType(host string, port int) detection.ServerType {
+ ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
+ defer cancel()
+
+ dialer := &net.Dialer{}
+ serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
+ if err != nil {
+ log.Debugf("ssh: server detection failed: %v (assuming regular SSH)", err)
+ return detection.ServerTypeRegular
+ }
+ return serverType
+}
+
+// rootCause returns the innermost error of a %w chain, so the terminal shows
+// "i/o timeout" rather than every layer that added context on the way up.
+func rootCause(err error) error {
+ for {
+ // A joined error has no single root, so keep it as-is.
+ if _, ok := err.(interface{ Unwrap() []error }); ok {
+ return err
+ }
+ next := errors.Unwrap(err)
+ if next == nil {
+ return err
+ }
+ err = next
+ }
+}
+
+// isAuthFailure distinguishes credential rejection from dial, timeout and
+// host-key errors, which retrying with a password would not fix.
+func isAuthFailure(err error) bool {
+ if errors.Is(err, errPasswordRequired) {
+ return true
+ }
+ var partial *gossh.PartialSuccessError
+ if errors.As(err, &partial) {
+ return true
+ }
+ return strings.Contains(err.Error(), "unable to authenticate")
+}
+
+// passwordCouldHelp reports whether prompting for a password again can change
+// the outcome. gossh lists a method under "attempted methods" only when the
+// server offered it, so a supplied password that was never attempted means the
+// server does not accept passwords and the real error should surface instead.
+func passwordCouldHelp(err error, passwordOffered bool) bool {
+ if !passwordOffered {
+ return true
+ }
+ msg := err.Error()
+ return strings.Contains(msg, "password") || strings.Contains(msg, "keyboard-interactive")
+}
diff --git a/client/android/ssh_known_hosts.go b/client/android/ssh_known_hosts.go
new file mode 100644
index 000000000..eea90fd32
--- /dev/null
+++ b/client/android/ssh_known_hosts.go
@@ -0,0 +1,168 @@
+//go:build android
+
+package android
+
+import (
+ "bytes"
+ "net"
+ "strconv"
+ "strings"
+ "sync"
+
+ gossh "golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/knownhosts"
+)
+
+const knownHostsNamespace = "ssh"
+
+const (
+ hostKeyUnknown hostKeyVerdict = iota
+ hostKeyMatched
+ hostKeyChanged
+)
+
+var knownHostsMu sync.Mutex
+
+type hostKeyVerdict uint8
+
+type knownHostsSection struct {
+ KnownHosts []string `json:"knownHosts"`
+}
+
+type knownHostsStore struct {
+ prefs prefsStore
+}
+
+// RemoveKnownHost deletes every known-hosts entry for host:port from the
+// profile's store, so a host trusted for a session that is being deleted does
+// not linger. Java calls this only once no session targets that host, so a
+// shared host stays trusted. A missing entry is not an error: the goal state
+// is "absent".
+func RemoveKnownHost(configDir, profileID, host string, port int) error {
+ store, err := openKnownHostsStore(configDir, profileID)
+ if err != nil {
+ return err
+ }
+ return store.removeHost(host, port)
+}
+
+func openKnownHostsStore(configDir, profileID string) (*knownHostsStore, error) {
+ prefs, err := newProfilePrefs(configDir, profileID)
+ if err != nil {
+ return nil, err
+ }
+ return &knownHostsStore{prefs: prefs}, nil
+}
+
+func (st *knownHostsStore) verify(hostname string, remote net.Addr, key gossh.PublicKey) (hostKeyVerdict, error) {
+ lines, err := st.lines()
+ if err != nil {
+ return hostKeyUnknown, err
+ }
+ targets := knownHostsTargets(hostname, remote)
+
+ verdict := hostKeyUnknown
+ for _, line := range lines {
+ pubKey, ok := knownHostsLineKey(line, targets)
+ if !ok {
+ continue
+ }
+ if pubKey.Type() == key.Type() && bytes.Equal(pubKey.Marshal(), key.Marshal()) {
+ return hostKeyMatched, nil
+ }
+ verdict = hostKeyChanged
+ }
+ return verdict, nil
+}
+
+func (st *knownHostsStore) append(hostname string, remote net.Addr, key gossh.PublicKey) error {
+ line := knownhosts.Line(knownHostsTargets(hostname, remote), key)
+
+ knownHostsMu.Lock()
+ defer knownHostsMu.Unlock()
+
+ lines, err := st.lines()
+ if err != nil {
+ return err
+ }
+ return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: append(lines, line)})
+}
+
+func (st *knownHostsStore) removeHost(host string, port int) error {
+ target := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
+
+ knownHostsMu.Lock()
+ defer knownHostsMu.Unlock()
+
+ lines, err := st.lines()
+ if err != nil {
+ return err
+ }
+ kept := make([]string, 0, len(lines))
+ for _, line := range lines {
+ if knownHostsLineMatches(line, target) {
+ continue
+ }
+ kept = append(kept, line)
+ }
+ if len(kept) == len(lines) {
+ return nil
+ }
+ return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: kept})
+}
+
+func (st *knownHostsStore) lines() ([]string, error) {
+ var section knownHostsSection
+ if _, err := st.prefs.Get(knownHostsNamespace, §ion); err != nil {
+ return nil, err
+ }
+ return section.KnownHosts, nil
+}
+
+func knownHostsTargets(hostname string, remote net.Addr) []string {
+ targets := []string{knownhosts.Normalize(hostname)}
+ if remote != nil {
+ if normalized := knownhosts.Normalize(remote.String()); normalized != targets[0] {
+ targets = append(targets, normalized)
+ }
+ }
+ return targets
+}
+
+func knownHostsLineKey(line string, targets []string) (gossh.PublicKey, bool) {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ return nil, false
+ }
+ _, hosts, pubKey, _, _, err := gossh.ParseKnownHosts([]byte(trimmed))
+ if err != nil {
+ return nil, false
+ }
+ for _, host := range hosts {
+ for _, target := range targets {
+ if host == target {
+ return pubKey, true
+ }
+ }
+ }
+ return nil, false
+}
+
+// knownHostsLineMatches reports whether a known-hosts line's address list
+// contains the normalized target. Comment and blank lines never match.
+func knownHostsLineMatches(line, target string) bool {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ return false
+ }
+ fields := strings.Fields(trimmed)
+ if len(fields) == 0 {
+ return false
+ }
+ for _, addr := range strings.Split(fields[0], ",") {
+ if addr == target {
+ return true
+ }
+ }
+ return false
+}
diff --git a/client/android/ssh_sessions.go b/client/android/ssh_sessions.go
new file mode 100644
index 000000000..44b5464e9
--- /dev/null
+++ b/client/android/ssh_sessions.go
@@ -0,0 +1,104 @@
+//go:build android
+
+package android
+
+const (
+ sshSessionsNamespace = "ssh-sessions"
+ maxStoredSSHSessions = 50
+)
+
+type sshSessionRecord struct {
+ ID string `json:"id"`
+ Host string `json:"host"`
+ Port int `json:"port"`
+ User string `json:"user"`
+}
+
+type sshSessionsSection struct {
+ Sessions []sshSessionRecord `json:"sessions"`
+}
+
+// SSHSessionEntry is one stored SSH session, without any credential.
+type SSHSessionEntry struct {
+ ID string
+ Host string
+ Port int
+ User string
+}
+
+// SSHSessionArray wraps stored SSH sessions for gomobile compatibility.
+type SSHSessionArray struct {
+ items []*SSHSessionEntry
+}
+
+// NewSSHSessionArray creates an empty session array to fill via Add.
+func NewSSHSessionArray() *SSHSessionArray {
+ return &SSHSessionArray{}
+}
+
+// Add appends a session entry, oldest first.
+func (a *SSHSessionArray) Add(id, host string, port int, user string) {
+ a.items = append(a.items, &SSHSessionEntry{ID: id, Host: host, Port: port, User: user})
+}
+
+// Length returns the number of entries.
+func (a *SSHSessionArray) Length() int {
+ return len(a.items)
+}
+
+// Get returns the entry at index i, or nil when out of range.
+func (a *SSHSessionArray) Get(i int) *SSHSessionEntry {
+ if i < 0 || i >= len(a.items) {
+ return nil
+ }
+ return a.items[i]
+}
+
+// SSHSessionStore reads and writes a profile's stored SSH sessions.
+type SSHSessionStore struct {
+ prefs prefsStore
+}
+
+// NewSSHSessionStore opens the session store of the given profile.
+func NewSSHSessionStore(configDir, profileID string) (*SSHSessionStore, error) {
+ prefs, err := newProfilePrefs(configDir, profileID)
+ if err != nil {
+ return nil, err
+ }
+ return &SSHSessionStore{prefs: prefs}, nil
+}
+
+// Load returns the stored sessions, oldest first.
+func (s *SSHSessionStore) Load() (*SSHSessionArray, error) {
+ var section sshSessionsSection
+ if _, err := s.prefs.Get(sshSessionsNamespace, §ion); err != nil {
+ return nil, err
+ }
+
+ out := NewSSHSessionArray()
+ for _, record := range section.Sessions {
+ if record.ID == "" || record.Host == "" {
+ continue
+ }
+ out.Add(record.ID, record.Host, record.Port, record.User)
+ }
+ return out, nil
+}
+
+// Save replaces the stored sessions, keeping only the newest entries when the
+// list exceeds the storage cap.
+func (s *SSHSessionStore) Save(sessions *SSHSessionArray) error {
+ var items []*SSHSessionEntry
+ if sessions != nil {
+ items = sessions.items
+ }
+ if len(items) > maxStoredSSHSessions {
+ items = items[len(items)-maxStoredSSHSessions:]
+ }
+
+ records := make([]sshSessionRecord, 0, len(items))
+ for _, item := range items {
+ records = append(records, sshSessionRecord{ID: item.ID, Host: item.Host, Port: item.Port, User: item.User})
+ }
+ return s.prefs.Put(sshSessionsNamespace, sshSessionsSection{Sessions: records})
+}
diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go
index c140cef89..c5d43ed55 100644
--- a/client/anonymize/anonymize.go
+++ b/client/anonymize/anonymize.go
@@ -2,6 +2,7 @@ package anonymize
import (
"crypto/rand"
+ "encoding/base64"
"fmt"
"math/big"
"net"
@@ -15,13 +16,88 @@ import (
const anonTLD = ".domain"
+// Level selects how much the anonymizer redacts. Levels are ordered: a higher
+// level redacts strictly more. On the wire (protos, flags) levels travel as
+// their string form.
+type Level int
+
+const (
+ // LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC
+ // addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are
+ // preserved so support can reason about the real topology.
+ LevelDefault Level = iota
+ // LevelStrict additionally anonymizes internal IP ranges, peer names, and
+ // WireGuard public keys.
+ LevelStrict
+)
+
+// LevelDefaultString and LevelStrictString are the wire forms of the levels,
+// for boundaries that pass levels as strings (flags, protos, mobile bindings).
+const (
+ LevelDefaultString = "default"
+ LevelStrictString = "strict"
+)
+
+// ParseLevel maps s to a Level. Empty means LevelDefault; anything
+// unrecognized maps to LevelStrict so an unknown request never yields less
+// anonymization than intended.
+func ParseLevel(s string) Level {
+ switch strings.ToLower(s) {
+ case "", LevelDefaultString:
+ return LevelDefault
+ default:
+ return LevelStrict
+ }
+}
+
+// String returns the wire form of the level: "default" or "strict".
+func (l Level) String() string {
+ if l >= LevelStrict {
+ return LevelStrictString
+ }
+ return LevelDefaultString
+}
+
+// protectedDomains are NetBird-operated suffixes that stay recognizable in an
+// anonymized bundle. At LevelStrict the labels in front of them (the peer
+// name) are still replaced, except under netbird.io, which only hosts
+// NetBird infrastructure (api, signal, flow), never peer names.
+var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"}
+
+const infraDomain = "netbird.io"
+
+var (
+ macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`)
+ macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`)
+ wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`)
+)
+
type Anonymizer struct {
ipAnonymizer map[netip.Addr]netip.Addr
domainAnonymizer map[string]string
- currentAnonIPv4 netip.Addr
- currentAnonIPv6 netip.Addr
- startAnonIPv4 netip.Addr
- startAnonIPv6 netip.Addr
+ // domainOrder caches the keys of domainAnonymizer sorted longest-first
+ // for AnonymizeString; it is rebuilt when the map gains entries.
+ domainOrder []string
+ labelAnonymizer map[string]string
+ labelAnonymized map[string]struct{}
+ labelCounter uint32
+ macAnonymizer map[string]string
+ macCounter uint32
+ wgKeyAnonymizer map[string]string
+ wgKeyAnonymized map[string]struct{}
+ currentAnonIPv4 netip.Addr
+ currentAnonIPv6 netip.Addr
+ startAnonIPv4 netip.Addr
+ startAnonIPv6 netip.Addr
+
+ // LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT,
+ // link-local), replacing them from the dedicated internal pools below so
+ // a reader can still tell an internal address from a public one.
+ level Level
+ currentAnonInternalIPv4 netip.Addr
+ currentAnonInternalIPv6 netip.Addr
+ startAnonInternalIPv4 netip.Addr
+ startAnonInternalIPv6 netip.Addr
domainKeyRegex *regexp.Regexp
}
@@ -32,25 +108,50 @@ func DefaultAddresses() (netip.Addr, netip.Addr) {
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::")
}
+// InternalAddresses returns the pool starts used in strict mode for internal
+// ranges. Both are reserved ranges that cannot collide with real addressing:
+// 198.18.0.0 (RFC 2544 benchmarking), 2001:db8:1:: (RFC 3849 documentation).
+func InternalAddresses() (netip.Addr, netip.Addr) {
+ return netip.AddrFrom4([4]byte{198, 18, 0, 0}), netip.MustParseAddr("2001:db8:1::")
+}
+
func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
+ internalIPv4, internalIPv6 := InternalAddresses()
return &Anonymizer{
ipAnonymizer: map[netip.Addr]netip.Addr{},
domainAnonymizer: map[string]string{},
+ labelAnonymizer: map[string]string{},
+ labelAnonymized: map[string]struct{}{},
+ macAnonymizer: map[string]string{},
+ wgKeyAnonymizer: map[string]string{},
+ wgKeyAnonymized: map[string]struct{}{},
currentAnonIPv4: startIPv4,
currentAnonIPv6: startIPv6,
startAnonIPv4: startIPv4,
startAnonIPv6: startIPv6,
+ level: LevelDefault,
+ currentAnonInternalIPv4: internalIPv4,
+ currentAnonInternalIPv6: internalIPv6,
+ startAnonInternalIPv4: internalIPv4,
+ startAnonInternalIPv6: internalIPv6,
+
domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`),
}
}
+// SetLevel selects the anonymization level. The zero value of a new
+// Anonymizer is LevelDefault.
+func (a *Anonymizer) SetLevel(level Level) {
+ a.level = level
+}
+
func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
+ // Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps
+ // like 192.168.1.1.
+ ip = ip.Unmap()
+
if ip.IsLoopback() ||
- ip.IsLinkLocalUnicast() ||
- ip.IsLinkLocalMulticast() ||
- ip.IsInterfaceLocalMulticast() ||
- (ip.Is4() && ip.IsPrivate()) ||
ip.IsUnspecified() ||
ip.IsMulticast() ||
isWellKnown(ip) ||
@@ -59,18 +160,100 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
return ip
}
+ if isInternal(ip) && a.level < LevelStrict {
+ return ip
+ }
+
if _, ok := a.ipAnonymizer[ip]; !ok {
- if ip.Is4() {
- a.ipAnonymizer[ip] = a.currentAnonIPv4
- a.currentAnonIPv4 = a.currentAnonIPv4.Next()
- } else {
- a.ipAnonymizer[ip] = a.currentAnonIPv6
- a.currentAnonIPv6 = a.currentAnonIPv6.Next()
- }
+ a.ipAnonymizer[ip] = a.nextAnonIP(ip)
}
return a.ipAnonymizer[ip]
}
+func (a *Anonymizer) nextAnonIP(ip netip.Addr) netip.Addr {
+ // At the strict level, internal addresses (including IPv6 ULA, matched
+ // by IsPrivate) come from the internal pools so they remain recognizable
+ // as internal without disclosing the real values.
+ if a.level >= LevelStrict && (isInternal(ip) || ip.IsPrivate()) {
+ if ip.Is4() {
+ anon := a.currentAnonInternalIPv4
+ a.currentAnonInternalIPv4 = a.currentAnonInternalIPv4.Next()
+ return anon
+ }
+ anon := a.currentAnonInternalIPv6
+ a.currentAnonInternalIPv6 = a.currentAnonInternalIPv6.Next()
+ return anon
+ }
+
+ if ip.Is4() {
+ anon := a.currentAnonIPv4
+ a.currentAnonIPv4 = a.currentAnonIPv4.Next()
+ return anon
+ }
+ anon := a.currentAnonIPv6
+ a.currentAnonIPv6 = a.currentAnonIPv6.Next()
+ return anon
+}
+
+// AnonymizeMAC replaces a MAC address with a consistent placeholder from the
+// locally administered range starting at 02:00:00:00:00:01, at every
+// anonymization level. Broadcast, multicast, all-zero, and already assigned
+// placeholder addresses are preserved. The colon and dash spellings of the
+// same address share one placeholder; the output keeps the input's separator.
+func (a *Anonymizer) AnonymizeMAC(mac string) string {
+ hw, err := net.ParseMAC(mac)
+ if err != nil || len(hw) != 6 {
+ return mac
+ }
+
+ if isWellKnownMAC(hw) || a.isAnonymizedMAC(hw) {
+ return mac
+ }
+
+ key := hw.String()
+ anon, ok := a.macAnonymizer[key]
+ if !ok {
+ a.macCounter++
+ anon = fmt.Sprintf("02:00:00:%02x:%02x:%02x", byte(a.macCounter>>16), byte(a.macCounter>>8), byte(a.macCounter))
+ a.macAnonymizer[key] = anon
+ }
+
+ if strings.Contains(mac, "-") {
+ anon = strings.ReplaceAll(anon, ":", "-")
+ }
+ return anon
+}
+
+// isAnonymizedMAC reports whether hw is a placeholder this anonymizer already
+// handed out, so a second pass over anonymized output leaves it unchanged.
+func (a *Anonymizer) isAnonymizedMAC(hw net.HardwareAddr) bool {
+ if hw[0] != 0x02 || hw[1] != 0 || hw[2] != 0 {
+ return false
+ }
+ value := uint32(hw[3])<<16 | uint32(hw[4])<<8 | uint32(hw[5])
+ return value <= a.macCounter
+}
+
+// AnonymizeWGKey replaces a WireGuard public key with a consistent random
+// placeholder of the same shape. Keys are only anonymized at LevelStrict;
+// placeholders already handed out pass through unchanged.
+func (a *Anonymizer) AnonymizeWGKey(key string) string {
+ if a.level < LevelStrict || !looksLikeWGKey(key) {
+ return key
+ }
+ if _, ok := a.wgKeyAnonymized[key]; ok {
+ return key
+ }
+
+ anon, ok := a.wgKeyAnonymizer[key]
+ if !ok {
+ anon = generateAnonymousKey()
+ a.wgKeyAnonymizer[key] = anon
+ a.wgKeyAnonymized[anon] = struct{}{}
+ }
+ return anon
+}
+
func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
// Convert IP to netip.Addr
ip, ok := netip.AddrFromSlice(addr.IP)
@@ -89,12 +272,12 @@ func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
// isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs
func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
- if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 {
- return true
- } else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 {
- return true
+ if ip.Is4() {
+ return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) ||
+ inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4)
}
- return false
+ return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) ||
+ inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6)
}
func (a *Anonymizer) AnonymizeIPString(ip string) string {
@@ -118,14 +301,23 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
baseDomain = domain[:len(domain)-1]
}
- if strings.HasSuffix(baseDomain, "netbird.io") ||
- strings.HasSuffix(baseDomain, "netbird.selfhosted") ||
- strings.HasSuffix(baseDomain, "netbird.cloud") ||
- strings.HasSuffix(baseDomain, "netbird.stage") ||
- strings.HasSuffix(baseDomain, anonTLD) {
+ if strings.HasSuffix(baseDomain, anonTLD) {
return domain
}
+ // A reverse zone names an address prefix, so it follows the address rules,
+ // which also keeps its digit labels intact.
+ if zone, ok := a.anonymizeReverseZone(baseDomain); ok {
+ return withTrailingDot(zone, hasDot)
+ }
+
+ if suffix := protectedSuffix(baseDomain); suffix != "" {
+ if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
+ return domain
+ }
+ return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot)
+ }
+
parts := strings.Split(baseDomain, ".")
if len(parts) < 2 {
return domain
@@ -141,12 +333,53 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
}
result := strings.Replace(baseDomain, baseForLookup, anonymized, 1)
- if hasDot {
- result += "."
+ if a.level >= LevelStrict && len(parts) > 2 {
+ prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup)
+ result = a.anonymizeLabels(prefix, "host") + "." + anonymized
+ // The full mapping feeds AnonymizeString so seeded FQDNs are caught
+ // in log lines as a whole, labels included.
+ a.domainAnonymizer[baseDomain] = result
+ }
+ return withTrailingDot(result, hasDot)
+}
+
+// anonymizePeerName replaces the labels in front of a protected suffix with
+// numbered peer placeholders, keeping the suffix, and records the full
+// mapping for string replacement in logs. The numbering keeps a peer
+// recognizable across the whole bundle without disclosing its name.
+func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string {
+ prefix := strings.TrimSuffix(baseDomain, "."+suffix)
+ result := a.anonymizeLabels(prefix, "peer") + "." + suffix
+ if result != baseDomain {
+ a.domainAnonymizer[baseDomain] = result
}
return result
}
+// anonymizeLabels replaces each dot-separated label with a consistent
+// numbered placeholder ("-"). Wildcard labels and
+// placeholders already handed out pass through unchanged.
+func (a *Anonymizer) anonymizeLabels(prefix, placeholder string) string {
+ labels := strings.Split(prefix, ".")
+ for i, label := range labels {
+ if label == "*" {
+ continue
+ }
+ if _, ok := a.labelAnonymized[label]; ok {
+ continue
+ }
+ anon, ok := a.labelAnonymizer[label]
+ if !ok {
+ a.labelCounter++
+ anon = fmt.Sprintf("%s-%d", placeholder, a.labelCounter)
+ a.labelAnonymizer[label] = anon
+ a.labelAnonymized[anon] = struct{}{}
+ }
+ labels[i] = anon
+ }
+ return strings.Join(labels, ".")
+}
+
func (a *Anonymizer) AnonymizeURI(uri string) string {
u, err := url.Parse(uri)
if err != nil {
@@ -178,17 +411,75 @@ func (a *Anonymizer) AnonymizeString(str string) string {
ipv4Regex := regexp.MustCompile(`\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b`)
ipv6Regex := regexp.MustCompile(`\b([0-9a-fA-F:]+:+[0-9a-fA-F]{0,4})(?:%[0-9a-zA-Z]+)?(?:\/[0-9]{1,3})?(?::[0-9]{1,5})?\b`)
+ // Reverse zones go first and are then held out of the passes below: their
+ // labels are digits, which the address patterns would otherwise consume.
+ str, restoreZones := a.replaceReverseZones(str)
+
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
- for domain, anonDomain := range a.domainAnonymizer {
- str = strings.ReplaceAll(str, domain, anonDomain)
+ for _, domain := range a.sortedDomains() {
+ str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain])
}
str = a.AnonymizeSchemeURI(str)
str = a.AnonymizeDNSLogLine(str)
- return str
+ // MAC handling runs after the IP passes so preserved IPv6 addresses are
+ // already out of the way; the separator guard skips matches embedded in a
+ // longer colon- or dash-separated sequence (such as an IPv6 tail).
+ str = a.anonymizeMACsInString(str, macColonRegex, ':')
+ str = a.anonymizeMACsInString(str, macDashRegex, '-')
+
+ if a.level >= LevelStrict {
+ str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
+ }
+
+ return restoreZones(str)
+}
+
+// sortedDomains returns the domain mappings longest-first, so a full-FQDN
+// mapping (strict level) is applied before the base-domain mapping it
+// contains. The order is rebuilt only when domainAnonymizer has grown.
+func (a *Anonymizer) sortedDomains() []string {
+ if len(a.domainOrder) == len(a.domainAnonymizer) {
+ return a.domainOrder
+ }
+
+ a.domainOrder = a.domainOrder[:0]
+ for domain := range a.domainAnonymizer {
+ a.domainOrder = append(a.domainOrder, domain)
+ }
+ slices.SortFunc(a.domainOrder, func(x, y string) int {
+ if d := len(y) - len(x); d != 0 {
+ return d
+ }
+ return strings.Compare(x, y)
+ })
+ return a.domainOrder
+}
+
+// anonymizeMACsInString replaces MAC addresses matched by re, skipping
+// matches that directly adjoin another sep so a six-group run inside a longer
+// separated sequence is left alone.
+func (a *Anonymizer) anonymizeMACsInString(str string, re *regexp.Regexp, sep byte) string {
+ matches := re.FindAllStringIndex(str, -1)
+ if len(matches) == 0 {
+ return str
+ }
+
+ var b strings.Builder
+ last := 0
+ for _, m := range matches {
+ if (m[0] > 0 && str[m[0]-1] == sep) || (m[1] < len(str) && str[m[1]] == sep) {
+ continue
+ }
+ b.WriteString(str[last:m[0]])
+ b.WriteString(a.AnonymizeMAC(str[m[0]:m[1]]))
+ last = m[1]
+ }
+ b.WriteString(str[last:])
+ return b.String()
}
// AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes.
@@ -239,10 +530,79 @@ func isWellKnown(addr netip.Addr) bool {
"128.0.0.0", "8000::", // 2nd split subnet for default routes
}
- if slices.Contains(wellKnown, addr.String()) {
+ return slices.Contains(wellKnown, addr.String())
+}
+
+// isInternal reports whether ip identifies a host only within the local
+// network: IPv4 private (RFC 1918), CGNAT (RFC 6598), and link-local (v4 and
+// v6). These are preserved at the default level so support can reason about
+// the real topology, and replaced from the internal pools at the strict
+// level. IPv6 ULA is deliberately not internal: its random global ID uniquely
+// fingerprints the network, so it is anonymized at every level.
+func isInternal(ip netip.Addr) bool {
+ return (ip.Is4() && ip.IsPrivate()) ||
+ ip.IsLinkLocalUnicast() ||
+ isCGNAT(ip)
+}
+
+func inPoolRange(ip, start, current netip.Addr) bool {
+ return ip.Compare(start) >= 0 && ip.Compare(current) <= 0
+}
+
+// isWellKnownMAC reports whether hw carries no stable host identity: all-zero
+// or a group address (broadcast and multicast).
+func isWellKnownMAC(hw net.HardwareAddr) bool {
+ if hw[0]&1 == 1 {
return true
}
+ for _, b := range hw {
+ if b != 0 {
+ return false
+ }
+ }
+ return true
+}
+// looksLikeWGKey reports whether s has the shape of a WireGuard key:
+// 44 base64 characters decoding to 32 bytes.
+func looksLikeWGKey(s string) bool {
+ if len(s) != 44 || s[43] != '=' {
+ return false
+ }
+ decoded, err := base64.StdEncoding.DecodeString(s)
+ return err == nil && len(decoded) == 32
+}
+
+func generateAnonymousKey() string {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return strings.Repeat("A", 43) + "="
+ }
+ return base64.StdEncoding.EncodeToString(buf)
+}
+
+// protectedSuffix returns the protected NetBird suffix baseDomain ends with,
+// or empty. The match is label-anchored so an unrelated domain that merely
+// ends in the same characters is not preserved.
+func protectedSuffix(baseDomain string) string {
+ for _, d := range protectedDomains {
+ if baseDomain == d || strings.HasSuffix(baseDomain, "."+d) {
+ return d
+ }
+ }
+ return ""
+}
+
+func withTrailingDot(domain string, hasDot bool) string {
+ if hasDot {
+ return domain + "."
+ }
+ return domain
+}
+
+// isCGNAT reports whether addr is in 100.64.0.0/10 (RFC 6598), the range
+// NetBird assigns overlay peer addresses from.
+func isCGNAT(addr netip.Addr) bool {
cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0})
cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10)
diff --git a/client/anonymize/anonymize_test.go b/client/anonymize/anonymize_test.go
index 852315fa1..7c3c7bcf8 100644
--- a/client/anonymize/anonymize_test.go
+++ b/client/anonymize/anonymize_test.go
@@ -1,8 +1,11 @@
package anonymize_test
import (
+ "bytes"
+ "encoding/base64"
"net/netip"
"regexp"
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -44,6 +47,301 @@ func TestAnonymizeIP(t *testing.T) {
}
}
+func TestParseLevel(t *testing.T) {
+ tests := []struct {
+ input string
+ expect anonymize.Level
+ }{
+ {"", anonymize.LevelDefault},
+ {"default", anonymize.LevelDefault},
+ {"DEFAULT", anonymize.LevelDefault},
+ {"strict", anonymize.LevelStrict},
+ {"STRICT", anonymize.LevelStrict},
+ // Unknown values must never yield less anonymization than requested.
+ {"garbage", anonymize.LevelStrict},
+ }
+
+ for _, tc := range tests {
+ t.Run("input="+tc.input, func(t *testing.T) {
+ assert.Equal(t, tc.expect, anonymize.ParseLevel(tc.input), "parsed level should match")
+ })
+ }
+}
+
+func TestAnonymizeIP_DefaultLevelInternalRanges(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+
+ tests := []struct {
+ name string
+ ip string
+ expect string
+ }{
+ {"RFC1918 10/8", "10.1.2.3", "10.1.2.3"},
+ {"RFC1918 172.16/12", "172.16.5.5", "172.16.5.5"},
+ {"RFC1918 192.168/16", "192.168.1.1", "192.168.1.1"},
+ {"CGNAT", "100.64.0.5", "100.64.0.5"},
+ {"IPv4 link-local", "169.254.1.1", "169.254.1.1"},
+ {"IPv6 link-local", "fe80::1", "fe80::1"},
+ // ULA is anonymized even at the default level: its random global ID
+ // uniquely fingerprints the network, unlike shared RFC 1918 space.
+ {"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:ffff::"},
+ // 4-in-6 addresses classify like their unmapped IPv4 form.
+ {"4-in-6 RFC1918", "::ffff:192.168.1.1", "192.168.1.1"},
+ {"4-in-6 CGNAT", "::ffff:100.64.0.5", "100.64.0.5"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
+ assert.Equal(t, tc.expect, result.String(), "default level should preserve internal ranges except ULA")
+ })
+ }
+}
+
+func TestAnonymizeIP_StrictLevel(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ anonymizer.SetLevel(anonymize.LevelStrict)
+
+ // Order matters: internal pool addresses are assigned sequentially.
+ tests := []struct {
+ name string
+ ip string
+ expect string
+ }{
+ {"RFC1918 192.168/16", "192.168.1.1", "198.18.0.0"},
+ {"Second RFC1918", "192.168.1.2", "198.18.0.1"},
+ {"Repeated RFC1918", "192.168.1.1", "198.18.0.0"},
+ {"RFC1918 10/8", "10.1.2.3", "198.18.0.2"},
+ {"RFC1918 172.16/12", "172.16.5.5", "198.18.0.3"},
+ {"CGNAT", "100.64.0.5", "198.18.0.4"},
+ {"IPv4 link-local", "169.254.1.1", "198.18.0.5"},
+ {"Public IPv4 uses public pool", "1.2.3.4", "198.51.100.0"},
+ {"IPv6 link-local", "fe80::1", "2001:db8:1::"},
+ {"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:1::1"},
+ {"Public IPv6 uses public pool", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
+ {"Loopback IPv4", "127.0.0.1", "127.0.0.1"},
+ {"Loopback IPv6", "::1", "::1"},
+ {"Unspecified", "0.0.0.0", "0.0.0.0"},
+ {"Multicast", "224.0.0.251", "224.0.0.251"},
+ {"Well known resolver", "8.8.8.8", "8.8.8.8"},
+ {"Well known split marker", "128.0.0.0", "128.0.0.0"},
+ {"In internal pool range", "198.18.0.3", "198.18.0.3"},
+ {"In public pool range", "198.51.100.0", "198.51.100.0"},
+ {"4-in-6 repeated RFC1918", "::ffff:192.168.1.1", "198.18.0.0"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
+ assert.Equal(t, tc.expect, result.String(), "strict level should replace internal ranges from the internal pools")
+ })
+ }
+}
+
+func TestAnonymizeString_StrictInternalIPs(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ anonymizer.SetLevel(anonymize.LevelStrict)
+
+ input := "route 10.20.30.0/24 via 192.168.1.1 dev eth0 src 100.64.0.7"
+ firstPass := anonymizer.AnonymizeString(input)
+ secondPass := anonymizer.AnonymizeString(firstPass)
+
+ assert.NotContains(t, firstPass, "10.20.30.0", "private network address should be anonymized")
+ assert.NotContains(t, firstPass, "192.168.1.1", "private gateway should be anonymized")
+ assert.NotContains(t, firstPass, "100.64.0.7", "CGNAT address should be anonymized")
+ assert.Contains(t, firstPass, "/24", "prefix length should be preserved")
+ assert.Equal(t, firstPass, secondPass, "second pass should not further anonymize the string")
+}
+
+func TestAnonymizeMAC(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+
+ first := anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f")
+ assert.Equal(t, "02:00:00:00:00:01", first, "first MAC should get the first placeholder")
+ assert.Equal(t, first, anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f"), "repeated MAC should map to the same placeholder")
+ assert.Equal(t, first, anonymizer.AnonymizeMAC("AA:BB:CC:DD:EE:0F"), "case should not affect the mapping")
+ assert.Equal(t, "02-00-00-00-00-01", anonymizer.AnonymizeMAC("AA-BB-CC-DD-EE-0F"), "dash form should keep its separator but share the mapping")
+
+ second := anonymizer.AnonymizeMAC("10:22:33:44:55:66")
+ assert.Equal(t, "02:00:00:00:00:02", second, "second distinct MAC should get the next placeholder")
+
+ tests := []struct {
+ name string
+ mac string
+ }{
+ {"Broadcast", "ff:ff:ff:ff:ff:ff"},
+ {"IPv4 multicast", "01:00:5e:00:00:fb"},
+ {"IPv6 multicast", "33:33:00:00:00:01"},
+ {"All zero", "00:00:00:00:00:00"},
+ {"Assigned placeholder", "02:00:00:00:00:01"},
+ {"Invalid", "not-a-mac"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.mac, anonymizer.AnonymizeMAC(tc.mac), "should be preserved")
+ })
+ }
+}
+
+func TestAnonymizeString_MACAddresses(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+
+ tests := []struct {
+ name string
+ input string
+ expect string
+ }{
+ {
+ name: "nftables ether rule",
+ input: "ether saddr aa:bb:cc:dd:ee:ff drop",
+ expect: "ether saddr 02:00:00:00:00:01 drop",
+ },
+ {
+ name: "Windows dash form",
+ input: "Physical Address : AA-BB-CC-DD-EE-FF",
+ expect: "Physical Address : 02-00-00-00-00-01",
+ },
+ {
+ name: "IPv6 address tail is not treated as MAC",
+ input: "addr fe80:0:11:22:33:44:55:66 scope link",
+ expect: "addr fe80:0:11:22:33:44:55:66 scope link",
+ },
+ {
+ name: "broadcast MAC preserved",
+ input: "dst ff:ff:ff:ff:ff:ff type ARP",
+ expect: "dst ff:ff:ff:ff:ff:ff type ARP",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := anonymizer.AnonymizeString(tc.input)
+ assert.Equal(t, tc.expect, result, "MAC addresses should be anonymized at every level")
+ assert.Equal(t, result, anonymizer.AnonymizeString(result), "second pass should not change the result")
+ })
+ }
+}
+
+func TestAnonymizeWGKey(t *testing.T) {
+ key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
+
+ t.Run("default level preserves keys", func(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ assert.Equal(t, key, anonymizer.AnonymizeWGKey(key), "default level should not touch WireGuard keys")
+ })
+
+ t.Run("strict level replaces keys", func(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ anonymizer.SetLevel(anonymize.LevelStrict)
+
+ anon := anonymizer.AnonymizeWGKey(key)
+ assert.NotEqual(t, key, anon, "strict level should replace the key")
+ assert.Regexp(t, `^[A-Za-z0-9+/]{43}=$`, anon, "placeholder should keep the WireGuard key shape")
+ assert.Equal(t, anon, anonymizer.AnonymizeWGKey(key), "repeated key should map to the same placeholder")
+ assert.Equal(t, anon, anonymizer.AnonymizeWGKey(anon), "an assigned placeholder should pass through unchanged")
+
+ assert.Equal(t, "not-a-key", anonymizer.AnonymizeWGKey("not-a-key"), "non-key values should be preserved")
+ })
+}
+
+func TestAnonymizeString_WGKeys(t *testing.T) {
+ key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
+ input := "peer " + key + " handshake completed"
+
+ t.Run("default level preserves keys", func(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ assert.Equal(t, input, anonymizer.AnonymizeString(input), "default level should not touch WireGuard keys in strings")
+ })
+
+ t.Run("strict level replaces keys", func(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ anonymizer.SetLevel(anonymize.LevelStrict)
+
+ firstPass := anonymizer.AnonymizeString(input)
+ assert.NotContains(t, firstPass, key, "the key should not survive strict anonymization")
+ assert.Equal(t, anonymizer.AnonymizeWGKey(key), extractKey(t, firstPass), "string replacement should be consistent with AnonymizeWGKey")
+ assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
+ })
+}
+
+func extractKey(t *testing.T, logLine string) string {
+ t.Helper()
+ fields := strings.Fields(logLine)
+ require.Len(t, fields, 4, "log line should keep its structure")
+ return fields[1]
+}
+
+func TestAnonymizeDomain_StrictLevel(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ anonymizer.SetLevel(anonymize.LevelStrict)
+
+ t.Run("netbird peer name", func(t *testing.T) {
+ result := anonymizer.AnonymizeDomain("my-laptop.netbird.cloud")
+ assert.Regexp(t, `^peer-\d+\.netbird\.cloud$`, result, "peer name should be anonymized, suffix kept")
+ assert.NotContains(t, result, "my-laptop", "the peer name should not survive")
+ assert.Equal(t, result, anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), "repeated domain should map consistently")
+ assert.Equal(t, result, anonymizer.AnonymizeDomain(result), "an anonymized domain should pass through unchanged")
+ })
+
+ t.Run("bare netbird domain", func(t *testing.T) {
+ assert.Equal(t, "netbird.cloud", anonymizer.AnonymizeDomain("netbird.cloud"), "the bare protected suffix should be preserved")
+ })
+
+ t.Run("netbird infrastructure preserved", func(t *testing.T) {
+ assert.Equal(t, "api.netbird.io", anonymizer.AnonymizeDomain("api.netbird.io"),
+ "netbird.io hosts infrastructure, not peer names, and should stay readable")
+ })
+
+ t.Run("leading labels of other domains", func(t *testing.T) {
+ result := anonymizer.AnonymizeDomain("host1.corp.example.com")
+ assert.Regexp(t, `^host-\d+\.host-\d+\.anon-[a-zA-Z0-9]+\.domain$`, result, "every label should be anonymized")
+ for _, label := range []string{"host1", "corp", "example"} {
+ assert.NotContains(t, result, label, "no original label should survive")
+ }
+ assert.Equal(t, result, anonymizer.AnonymizeDomain("host1.corp.example.com"), "repeated domain should map consistently")
+ })
+
+ t.Run("same label maps consistently across domains", func(t *testing.T) {
+ first := anonymizer.AnonymizeDomain("shared.one.com")
+ second := anonymizer.AnonymizeDomain("shared.two.com")
+ assert.Equal(t, strings.Split(first, ".")[0], strings.Split(second, ".")[0], "the shared host label should get one placeholder")
+ })
+
+ t.Run("wildcard label preserved", func(t *testing.T) {
+ result := anonymizer.AnonymizeDomain("*.example.com")
+ assert.Regexp(t, `^\*\.anon-[a-zA-Z0-9]+\.domain$`, result, "the wildcard label should stay a wildcard")
+ })
+}
+
+func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+
+ assert.Equal(t, "my-laptop.netbird.cloud", anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"),
+ "default level should preserve netbird FQDNs including the peer name")
+ assert.Regexp(t, `^sub\.anon-[a-zA-Z0-9]+\.domain$`, anonymizer.AnonymizeDomain("sub.example.com"),
+ "default level should keep subdomain labels")
+}
+
+func TestAnonymizeString_StrictPeerNames(t *testing.T) {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ anonymizer.SetLevel(anonymize.LevelStrict)
+
+ // Seed like the bundle generator does from the status: base first, then
+ // the full FQDN, so replacement must prefer the longer mapping.
+ anonBase := anonymizer.AnonymizeDomain("example.com")
+ anonPeer := anonymizer.AnonymizeDomain("peer1.netbird.cloud")
+ anonHost := anonymizer.AnonymizeDomain("host1.example.com")
+
+ logLine := "connected to peer1.netbird.cloud via host1.example.com endpoint"
+ firstPass := anonymizer.AnonymizeString(logLine)
+ assert.NotContains(t, firstPass, "peer1", "the peer name should not survive in logs")
+ assert.NotContains(t, firstPass, "host1", "the host label should not survive in logs")
+ assert.Contains(t, firstPass, anonPeer, "the seeded peer mapping should be applied")
+ assert.Contains(t, firstPass, anonHost, "the seeded host mapping should be applied, not just the base mapping")
+ assert.NotContains(t, firstPass, "host1."+anonBase, "the base mapping must not preempt the longer FQDN mapping")
+ assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
+}
+
func TestAnonymizeDNSLogLine(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{})
tests := []struct {
diff --git a/client/anonymize/reverse_zone.go b/client/anonymize/reverse_zone.go
new file mode 100644
index 000000000..b521b71b7
--- /dev/null
+++ b/client/anonymize/reverse_zone.go
@@ -0,0 +1,174 @@
+package anonymize
+
+import (
+ "encoding/hex"
+ "net/netip"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+const (
+ reverseZoneSuffixV4 = ".in-addr.arpa"
+ reverseZoneSuffixV6 = ".ip6.arpa"
+
+ v6Nibbles = 32
+ v4Octets = 4
+)
+
+// reverseZoneRegexes match a reverse zone or a full reverse name in free text.
+// They are applied before the address passes of AnonymizeString, whose IPv4
+// pattern would otherwise consume the digit labels of a zone and replace parts
+// of it with unrelated addresses.
+var reverseZoneRegexes = []*regexp.Regexp{
+ regexp.MustCompile(`(?:[0-9]{1,3}\.){1,4}in-addr\.arpa\b`),
+ regexp.MustCompile(`(?:[0-9a-fA-F]\.){1,32}ip6\.arpa\b`),
+}
+
+// anonymizeReverseZone maps a reverse zone to the zone of the anonymized form
+// of the prefix it encodes, so it follows the address rules rather than the
+// domain ones: the zone of an address that is preserved is preserved too, and
+// the zone of one that is replaced names the replacement. This keeps a reverse
+// zone recognizable as such, and consistent with the addresses it belongs to
+// elsewhere in the same output. It reports false for anything that is not a
+// reverse zone.
+func (a *Anonymizer) anonymizeReverseZone(domain string) (string, bool) {
+ prefix, labelCount, suffix, ok := parseReverseZone(domain)
+ if !ok {
+ return "", false
+ }
+
+ anonymized := a.AnonymizeIP(prefix)
+ if anonymized == prefix {
+ return domain, true
+ }
+
+ return reverseZoneName(anonymized, labelCount) + suffix, true
+}
+
+// replaceReverseZones anonymizes every reverse zone in str and swaps each one
+// for a placeholder, returning a function that puts the anonymized zones back.
+// The placeholders carry no dots, digits or colons, so no later pass matches
+// them.
+func (a *Anonymizer) replaceReverseZones(str string) (string, func(string) string) {
+ var zones []string
+
+ for _, re := range reverseZoneRegexes {
+ str = re.ReplaceAllStringFunc(str, func(match string) string {
+ zone, ok := a.anonymizeReverseZone(match)
+ if !ok {
+ return match
+ }
+
+ zones = append(zones, zone)
+ return reverseZonePlaceholder(len(zones) - 1)
+ })
+ }
+
+ if len(zones) == 0 {
+ return str, func(s string) string { return s }
+ }
+
+ return str, func(s string) string {
+ for i, zone := range zones {
+ s = strings.ReplaceAll(s, reverseZonePlaceholder(i), zone)
+ }
+ return s
+ }
+}
+
+func reverseZonePlaceholder(index int) string {
+ return "\x00reversezone" + strconv.Itoa(index) + "\x00"
+}
+
+// parseReverseZone turns a reverse zone into the address of the prefix its
+// labels spell backwards, padding the absent low-order part with zeroes, and
+// returns the label count and zone suffix so the name can be rebuilt.
+func parseReverseZone(domain string) (netip.Addr, int, string, bool) {
+ lower := strings.ToLower(domain)
+
+ switch {
+ case strings.HasSuffix(lower, reverseZoneSuffixV4):
+ labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV4), ".")
+ addr, ok := reverseZoneAddrV4(labels)
+ return addr, len(labels), reverseZoneSuffixV4, ok
+ case strings.HasSuffix(lower, reverseZoneSuffixV6):
+ labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV6), ".")
+ addr, ok := reverseZoneAddrV6(labels)
+ return addr, len(labels), reverseZoneSuffixV6, ok
+ default:
+ return netip.Addr{}, 0, "", false
+ }
+}
+
+func reverseZoneAddrV4(labels []string) (netip.Addr, bool) {
+ if len(labels) == 0 || len(labels) > v4Octets {
+ return netip.Addr{}, false
+ }
+
+ var octets [v4Octets]byte
+ for i, label := range labels {
+ octet, err := strconv.ParseUint(label, 10, 8)
+ if err != nil {
+ return netip.Addr{}, false
+ }
+ octets[len(labels)-1-i] = byte(octet)
+ }
+
+ return netip.AddrFrom4(octets), true
+}
+
+func reverseZoneAddrV6(labels []string) (netip.Addr, bool) {
+ if len(labels) == 0 || len(labels) > v6Nibbles {
+ return netip.Addr{}, false
+ }
+
+ nibbles := make([]byte, 0, v6Nibbles)
+ for i := len(labels) - 1; i >= 0; i-- {
+ if len(labels[i]) != 1 || !isHexDigit(labels[i][0]) {
+ return netip.Addr{}, false
+ }
+ nibbles = append(nibbles, labels[i][0])
+ }
+ for len(nibbles) < v6Nibbles {
+ nibbles = append(nibbles, '0')
+ }
+
+ var groups []string
+ for i := 0; i < len(nibbles); i += 4 {
+ groups = append(groups, string(nibbles[i:i+4]))
+ }
+
+ addr, err := netip.ParseAddr(strings.Join(groups, ":"))
+ if err != nil {
+ return netip.Addr{}, false
+ }
+
+ return addr, true
+}
+
+// reverseZoneName spells the first labelCount labels of addr backwards, the
+// inverse of parseReverseZone, without the zone suffix.
+func reverseZoneName(addr netip.Addr, labelCount int) string {
+ labels := make([]string, 0, labelCount)
+
+ if addr.Is4() {
+ octets := addr.As4()
+ for i := labelCount - 1; i >= 0; i-- {
+ labels = append(labels, strconv.Itoa(int(octets[i])))
+ }
+ return strings.Join(labels, ".")
+ }
+
+ address := addr.As16()
+ nibbles := hex.EncodeToString(address[:])
+ for i := labelCount - 1; i >= 0; i-- {
+ labels = append(labels, string(nibbles[i]))
+ }
+
+ return strings.Join(labels, ".")
+}
+
+func isHexDigit(c byte) bool {
+ return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
+}
diff --git a/client/anonymize/reverse_zone_test.go b/client/anonymize/reverse_zone_test.go
new file mode 100644
index 000000000..8c3b8954a
--- /dev/null
+++ b/client/anonymize/reverse_zone_test.go
@@ -0,0 +1,171 @@
+package anonymize
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newLeveledAnonymizer(level Level) *Anonymizer {
+ a := NewAnonymizer(DefaultAddresses())
+ a.SetLevel(level)
+ return a
+}
+
+// TestAnonymizeDomainReverseZone covers reverse zones going through the address
+// rules instead of the domain ones, so a zone stays a zone and an address that
+// is preserved keeps the zone that names it.
+func TestAnonymizeDomainReverseZone(t *testing.T) {
+ // 100.64.0.0/10 is the overlay range, which is CGNAT: preserved at the
+ // default level and replaced from the internal pool at the strict one
+ const overlayZone = "64.100.in-addr.arpa"
+
+ t.Run("overlay zone preserved at the default level", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelDefault)
+ assert.Equal(t, overlayZone, a.AnonymizeDomain(overlayZone), "should keep the zone of a preserved address")
+ })
+
+ t.Run("private zone preserved at the default level", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelDefault)
+ assert.Equal(t, "168.192.in-addr.arpa", a.AnonymizeDomain("168.192.in-addr.arpa"), "should keep the zone of a private address")
+ })
+
+ t.Run("overlay zone replaced at the strict level", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelStrict)
+
+ got := a.AnonymizeDomain(overlayZone)
+ require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got)
+ assert.NotEqual(t, overlayZone, got, "should replace the encoded prefix")
+ assert.Len(t, strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV4), "."), 2,
+ "should keep the label count, got %q", got)
+ })
+
+ t.Run("public zone replaced at the default level", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelDefault)
+
+ got := a.AnonymizeDomain("113.0.203.in-addr.arpa")
+ require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got)
+ assert.NotEqual(t, "113.0.203.in-addr.arpa", got, "should replace a public prefix")
+ })
+
+ t.Run("zone of an address keeps that address mapping", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelDefault)
+
+ anonymizedAddr := a.AnonymizeIPString("203.0.113.7")
+ got := a.AnonymizeDomain("7.113.0.203.in-addr.arpa")
+
+ octets := strings.Split(anonymizedAddr, ".")
+ want := octets[3] + "." + octets[2] + "." + octets[1] + "." + octets[0] + reverseZoneSuffixV4
+ assert.Equal(t, want, got, "should name the same replacement as the address itself")
+ })
+
+ t.Run("ipv6 nibble labels stay single digits", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelDefault)
+
+ zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6
+ got := a.AnonymizeDomain(zone)
+
+ require.True(t, strings.HasSuffix(got, reverseZoneSuffixV6), "should stay a reverse zone, got %q", got)
+ labels := strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV6), ".")
+ assert.Len(t, labels, 28, "should keep every nibble label, got %q", got)
+ for _, label := range labels {
+ assert.Len(t, label, 1, "nibble label %q should stay a single digit", label)
+ }
+ })
+
+ t.Run("trailing dot is kept", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelDefault)
+ assert.Equal(t, "64.100.in-addr.arpa.", a.AnonymizeDomain("64.100.in-addr.arpa."), "should keep the trailing dot")
+ })
+
+ t.Run("a domain that only looks like a zone is anonymized as a domain", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelDefault)
+
+ got := a.AnonymizeDomain("not-a-zone.in-addr.arpa")
+ assert.NotContains(t, got, "in-addr.arpa", "should fall back to domain anonymization")
+ })
+}
+
+// TestAnonymizeStringReverseZone verifies that a zone inside free text, such as
+// a DNS log line, is not chewed up by the address passes. The IPv4 pattern
+// matches any run of dotted digits, which a reverse zone is made of.
+func TestAnonymizeStringReverseZone(t *testing.T) {
+ t.Run("ipv6 zone survives the address passes", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelDefault)
+
+ zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6
+ got := a.AnonymizeString("question: domain=" + zone + " type=PTR")
+
+ assert.Contains(t, got, "type=PTR", "should keep the rest of the line")
+ assert.NotContains(t, got, "198.51.100", "should not rewrite nibble labels as an address")
+
+ labels := strings.Split(strings.TrimSuffix(strings.TrimPrefix(got, "question: domain="), reverseZoneSuffixV6+" type=PTR"), ".")
+ assert.Len(t, labels, 28, "should keep every nibble label, got %q", got)
+ })
+
+ t.Run("preserved ipv4 zone is untouched", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelDefault)
+
+ line := "reverse zone 64.100.in-addr.arpa registered"
+ assert.Equal(t, line, a.AnonymizeString(line), "should keep the zone of a preserved address")
+ })
+
+ t.Run("public ipv4 zone is replaced consistently", func(t *testing.T) {
+ a := newLeveledAnonymizer(LevelDefault)
+
+ got := a.AnonymizeString("zone 113.0.203.in-addr.arpa and address 203.0.113.7")
+ assert.NotContains(t, got, "113.0.203.in-addr.arpa", "should replace the zone")
+ assert.NotContains(t, got, "203.0.113.7", "should replace the address")
+ assert.Contains(t, got, reverseZoneSuffixV4, "should keep the zone suffix")
+ })
+}
+
+func TestParseReverseZone(t *testing.T) {
+ tests := []struct {
+ name string
+ zone string
+ addr string
+ labels int
+ }{
+ {name: "v4 two labels", zone: "0.100" + reverseZoneSuffixV4, addr: "100.0.0.0", labels: 2},
+ {name: "v4 three labels", zone: "1.168.192" + reverseZoneSuffixV4, addr: "192.168.1.0", labels: 3},
+ {name: "v4 full address", zone: "7.113.0.203" + reverseZoneSuffixV4, addr: "203.0.113.7", labels: 4},
+ {
+ name: "v6 prefix",
+ zone: "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6,
+ addr: "2::",
+ labels: 28,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ addr, labels, suffix, ok := parseReverseZone(tc.zone)
+ require.True(t, ok, "should decode the reverse zone")
+ assert.Equal(t, tc.addr, addr.String(), "should decode to the encoded prefix")
+ assert.Equal(t, tc.labels, labels, "should count the labels")
+ assert.Equal(t, tc.zone, reverseZoneName(addr, labels)+suffix, "should re-encode to the original zone")
+ })
+ }
+}
+
+func TestParseReverseZoneRejectsNonZones(t *testing.T) {
+ tests := []string{
+ "example.com",
+ "in-addr.arpa",
+ "x.100" + reverseZoneSuffixV4,
+ "256" + reverseZoneSuffixV4,
+ "1.2.3.4.5" + reverseZoneSuffixV4,
+ "ab" + reverseZoneSuffixV6,
+ "g" + reverseZoneSuffixV6,
+ }
+
+ for _, zone := range tests {
+ t.Run(zone, func(t *testing.T) {
+ _, _, _, ok := parseReverseZone(zone)
+ assert.False(t, ok, "should reject %q", zone)
+ })
+ }
+}
diff --git a/client/cmd/daemon_error.go b/client/cmd/daemon_error.go
new file mode 100644
index 000000000..0d5b1307e
--- /dev/null
+++ b/client/cmd/daemon_error.go
@@ -0,0 +1,66 @@
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "google.golang.org/genproto/googleapis/rpc/errdetails"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+)
+
+// daemonCallError prepares a daemon error for display. A refusal the daemon
+// raised because the operation needs root/administrator is already guidance
+// written for the user, so it is surfaced on its own instead of buried under the
+// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped
+// with context as usual.
+func daemonCallError(context string, err error) error {
+ if guidance, ok := privilegeGuidance(err); ok {
+ return errors.New(guidance)
+ }
+ return fmt.Errorf("%s: %w", context, err)
+}
+
+// privilegeGuidance renders the daemon's privilege refusal as a summary and the
+// command that performs the operation with the privileges it needs. It reports
+// false for any other error.
+func privilegeGuidance(err error) (string, bool) {
+ info, ok := privilegeErrorInfo(err)
+ if !ok {
+ return "", false
+ }
+
+ summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
+ command := info.GetMetadata()[ipcauth.ErrorMetaCommand]
+ if summary == "" {
+ // Detail without a summary: fall back to the status message, which
+ // carries the same text.
+ summary = strings.TrimSpace(gstatus.Convert(err).Message())
+ }
+ if command == "" {
+ return summary, true
+ }
+
+ return fmt.Sprintf("%s\n\n %s\n", summary, command), true
+}
+
+// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
+// carries one.
+func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
+ if err == nil {
+ return nil, false
+ }
+
+ for _, detail := range gstatus.Convert(err).Details() {
+ info, ok := detail.(*errdetails.ErrorInfo)
+ if !ok {
+ continue
+ }
+ if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
+ return info, true
+ }
+ }
+ return nil, false
+}
diff --git a/client/cmd/debug.go b/client/cmd/debug.go
index bc7b0e98c..893b1e248 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -27,10 +27,11 @@ import (
const errCloseConnection = "Failed to close connection: %v"
var (
- logFileCount uint32
- systemInfoFlag bool
- uploadBundleFlag bool
- uploadBundleURLFlag string
+ logFileCount uint32
+ systemInfoFlag bool
+ uploadBundleFlag bool
+ uploadBundleURLFlag string
+ uploadBundleInsecureFlag bool
)
var debugCmd = &cobra.Command{
@@ -130,7 +131,7 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
client := proto.NewDaemonServiceClient(conn)
resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{
- ProfileName: activeProf.Name,
+ ProfileName: string(activeProf.ID),
Username: currUser.Username,
})
if err != nil {
@@ -155,6 +156,11 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
// request. Returns an error if the RPC fails or if the daemon reports
// an upload failure reason.
func debugBundle(cmd *cobra.Command, _ []string) error {
+ anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
+ if err != nil {
+ return err
+ }
+
conn, err := getClient(cmd)
if err != nil {
return err
@@ -167,17 +173,19 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
client := proto.NewDaemonServiceClient(conn)
request := &proto.DebugBundleRequest{
- Anonymize: anonymizeFlag,
- SystemInfo: systemInfoFlag,
- LogFileCount: logFileCount,
- CliVersion: version.NetbirdVersion(),
+ Anonymize: anonymizeEnabled,
+ AnonymizeLevel: anonymizeLevel.String(),
+ SystemInfo: systemInfoFlag,
+ LogFileCount: logFileCount,
+ CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
+ request.UploadInsecure = uploadBundleInsecureFlag
}
resp, err := client.DebugBundle(cmd.Context(), request)
if err != nil {
- return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message())
+ return daemonCallError("bundle debug", err)
}
cmd.Printf("Local file:\n%s\n", resp.GetPath())
@@ -227,6 +235,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid duration format: %v", err)
}
+ anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
+ if err != nil {
+ return err
+ }
+
conn, err := getClient(cmd)
if err != nil {
return err
@@ -366,17 +379,19 @@ func runForDuration(cmd *cobra.Command, args []string) error {
cmd.Println("Creating debug bundle...")
request := &proto.DebugBundleRequest{
- Anonymize: anonymizeFlag,
- SystemInfo: systemInfoFlag,
- LogFileCount: logFileCount,
- CliVersion: version.NetbirdVersion(),
+ Anonymize: anonymizeEnabled,
+ AnonymizeLevel: anonymizeLevel.String(),
+ SystemInfo: systemInfoFlag,
+ LogFileCount: logFileCount,
+ CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
+ request.UploadInsecure = uploadBundleInsecureFlag
}
resp, err := client.DebugBundle(cmd.Context(), request)
if err != nil {
- return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message())
+ return daemonCallError("bundle debug", err)
}
if needsRestoreUp {
@@ -524,10 +539,12 @@ func init() {
debugBundleCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
debugBundleCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
debugBundleCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
+ debugBundleCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Uint32VarP(&logFileCount, "log-file-count", "C", 1, "Number of rotated log files to include in debug bundle")
forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
+ forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle")
}
diff --git a/client/cmd/login.go b/client/cmd/login.go
index a7ee960b1..6aa019896 100644
--- a/client/cmd/login.go
+++ b/client/cmd/login.go
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"os/user"
- "runtime"
"strings"
log "github.com/sirupsen/logrus"
@@ -17,16 +16,26 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
+ nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/util"
)
+// extendSessionFlag drives the `netbird login --extend` flow: refresh the
+// SSO session expiry on the management server without tearing down the
+// tunnel. Mutually exclusive with setup-key login (a setup-key cannot
+// refresh an SSO-tracked peer — see auth.errSetupKeyOnSSOExpiredPeer).
+var extendSessionFlag bool
+
func init() {
loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc)
loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location")
+ loginCmd.PersistentFlags().BoolVar(&extendSessionFlag, "extend", false,
+ "refresh the SSO session expiry without tearing down the tunnel (requires an active connection)")
}
var loginCmd = &cobra.Command{
@@ -61,6 +70,16 @@ var loginCmd = &cobra.Command{
return err
}
+ if extendSessionFlag {
+ if providedSetupKey != "" {
+ return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers")
+ }
+ if err := doExtendSession(ctx, cmd); err != nil {
+ return fmt.Errorf("extend session failed: %v", err)
+ }
+ return nil
+ }
+
// workaround to run without service
if util.FindFirstLogPath(logFiles) == "" {
if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil {
@@ -101,7 +120,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
loginRequest := proto.LoginRequest{
SetupKey: providedSetupKey,
ManagementUrl: managementURL,
- IsUnixDesktopClient: isUnixRunningDesktop(),
+ IsUnixDesktopClient: util.HasGraphicalSession(),
Hostname: hostName,
DnsLabels: dnsLabelsReq,
ProfileName: &handle,
@@ -152,6 +171,66 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
return nil
}
+// doExtendSession drives the daemon's RequestExtendAuthSession /
+// WaitExtendAuthSession pair. The user is sent through a regular SSO flow
+// (browser + verification URL) and the resulting JWT is forwarded to the
+// management server's ExtendAuthSession RPC. The tunnel stays up
+// throughout — no Down/Up, no network-map resync.
+func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
+ conn, err := DialClientGRPCServer(ctx, daemonAddr)
+ if err != nil {
+ //nolint
+ return fmt.Errorf("failed to connect to daemon error: %v\n"+
+ "If the daemon is not running please run: "+
+ "\nnetbird service install \nnetbird service start\n", err)
+ }
+ defer conn.Close()
+
+ client := proto.NewDaemonServiceClient(conn)
+
+ // the CLI runs in the user's session, the daemon does not: tell it what we can see
+ req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: util.HasGraphicalSession()}
+ // Pre-fill the IdP login hint from the active profile so the user
+ // doesn't have to retype their email. Best-effort: we still proceed
+ // without a hint if the lookup fails.
+ pm := profilemanager.NewProfileManager()
+ if active, perr := pm.GetActiveProfile(); perr == nil {
+ if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" {
+ req.Hint = &profState.Email
+ }
+ }
+
+ startResp, err := client.RequestExtendAuthSession(ctx, req)
+ if err != nil {
+ return fmt.Errorf("start extend session: %v", err)
+ }
+
+ uri := startResp.GetVerificationURIComplete()
+ if uri == "" {
+ uri = startResp.GetVerificationURI()
+ }
+ openURL(cmd, uri, startResp.GetUserCode(), noBrowser, showQR)
+
+ waitResp, err := client.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{
+ DeviceCode: startResp.GetDeviceCode(),
+ UserCode: startResp.GetUserCode(),
+ })
+ if err != nil {
+ return fmt.Errorf("wait for extend session: %v", err)
+ }
+
+ if ts := waitResp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
+ deadline := ts.AsTime().Local()
+ cmd.Printf("Session extended. New expiry: %s\n", deadline.Format("2006-01-02 15:04:05 MST"))
+ } else {
+ // Management reported the peer is not eligible (e.g. login
+ // expiration disabled on the account). Surface that fact
+ // instead of pretending the call succeeded.
+ cmd.Println("Session extension call completed, but the management server did not return a new deadline (peer may not be SSO-tracked or login expiration is disabled).")
+ }
+ return nil
+}
+
func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) (*profilemanager.Profile, error) {
// switch profile if provided
@@ -254,6 +333,14 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
return fmt.Errorf("read config file %s: %v", configFilePath, err)
}
+ // Mirror runInForegroundMode: recover residual state (DNS, firewall,
+ // ssh config, legacy routing) from a previous unclean shutdown and
+ // enable advanced routing before dialing management.
+ if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configFilePath).GetStatePath()); err != nil {
+ log.Warnf("failed to restore residual state: %v", err)
+ }
+ nbnet.Init()
+
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID)
if err != nil {
return fmt.Errorf("foreground login failed: %v", err)
@@ -321,7 +408,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro
hint = profileState.Email
}
- oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isUnixRunningDesktop(), false, hint)
+ oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint)
if err != nil {
return nil, err
}
@@ -371,14 +458,6 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro
}
}
-// isUnixRunningDesktop checks if a Linux OS is running desktop environment
-func isUnixRunningDesktop() bool {
- if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
- return false
- }
- return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
-}
-
func setEnvAndFlags(cmd *cobra.Command) error {
SetFlagsFromEnvVars(rootCmd)
diff --git a/client/cmd/logout.go b/client/cmd/logout.go
index 1a5281acb..dcd7b5075 100644
--- a/client/cmd/logout.go
+++ b/client/cmd/logout.go
@@ -46,7 +46,7 @@ var logoutCmd = &cobra.Command{
}
if _, err := daemonClient.Logout(ctx, req); err != nil {
- return fmt.Errorf("deregister: %v", err)
+ return daemonCallError("deregister", err)
}
cmd.Println("Deregistered successfully")
diff --git a/client/cmd/root.go b/client/cmd/root.go
index f3fde2f1c..ccad78942 100644
--- a/client/cmd/root.go
+++ b/client/cmd/root.go
@@ -20,8 +20,8 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
+ "github.com/netbirdio/netbird/client/anonymize"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
@@ -70,13 +70,16 @@ var (
autoConnectDisabled bool
extraIFaceBlackList []string
anonymizeFlag bool
+ anonymizeLevelFlag string
dnsRouteInterval time.Duration
- lazyConnEnabled bool
- mtu uint16
- profilesDisabled bool
- updateSettingsDisabled bool
- captureEnabled bool
- networksDisabled bool
+ // lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection
+ // flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead).
+ lazyConnEnabled bool
+ mtu uint16
+ profilesDisabled bool
+ updateSettingsDisabled bool
+ captureEnabled bool
+ networksDisabled bool
rootCmd = &cobra.Command{
Use: "netbird",
@@ -89,6 +92,7 @@ var (
// Don't resolve for service commands — they create the socket, not connect to it.
if !isServiceCmd(cmd) {
daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr)
+ daemonAddr = daddr.ResolveDaemonAddr(daemonAddr)
}
return nil
},
@@ -141,10 +145,10 @@ func init() {
defaultDaemonAddr := "unix:///var/run/netbird.sock"
if runtime.GOOS == "windows" {
- defaultDaemonAddr = "tcp://127.0.0.1:41731"
+ defaultDaemonAddr = daddr.WindowsPipeAddr
}
- rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
+ rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
@@ -154,7 +158,8 @@ func init() {
rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file")
rootCmd.PersistentFlags().StringVar(&preSharedKey, preSharedKeyFlag, "", "Sets WireGuard PreSharedKey property. If set, then only peers that have the same key can communicate.")
rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device")
- rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output")
+ rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize public IP addresses, MAC addresses, and non-netbird.io domains in logs and status output; private, CGNAT, and link-local IP ranges are kept (see --anonymize-level strict)")
+ rootCmd.PersistentFlags().StringVar(&anonymizeLevelFlag, "anonymize-level", "", "anonymization level: \"default\" or \"strict\"; strict also anonymizes private, CGNAT, and link-local IP ranges, peer names, and WireGuard public keys. Setting this flag implies --anonymize")
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location")
rootCmd.AddCommand(upCmd)
@@ -210,7 +215,8 @@ func init() {
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
- upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "[Experimental] Enable the lazy connection feature. If enabled, the client will establish connections on-demand. Note: this setting may be overridden by management configuration.")
+ upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.")
+ _ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable")
}
@@ -266,12 +272,10 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
- return grpc.DialContext(
- ctx,
- strings.TrimPrefix(addr, "tcp://"),
- grpc.WithTransportCredentials(insecure.NewCredentials()),
- grpc.WithBlock(),
- )
+ target, opts := daddr.DialTarget(addr)
+ opts = append(opts, grpc.WithBlock())
+
+ return grpc.DialContext(ctx, target, opts...)
}
// WithBackOff execute function in backoff cycle.
@@ -292,6 +296,19 @@ var CLIBackOffSettings = &backoff.ExponentialBackOff{
Clock: backoff.SystemClock,
}
+// effectiveAnonymize resolves the --anonymize and --anonymize-level flags:
+// setting a level implies anonymization, and an invalid level is rejected.
+func effectiveAnonymize() (bool, anonymize.Level, error) {
+ if anonymizeLevelFlag == "" {
+ return anonymizeFlag, anonymize.LevelDefault, nil
+ }
+ level := anonymize.ParseLevel(anonymizeLevelFlag)
+ if !strings.EqualFold(anonymizeLevelFlag, level.String()) {
+ return false, anonymize.LevelDefault, fmt.Errorf("invalid anonymize level %q: use %q or %q", anonymizeLevelFlag, anonymize.LevelDefault.String(), anonymize.LevelStrict.String())
+ }
+ return true, level, nil
+}
+
func getSetupKey() (string, error) {
if setupKeyPath != "" && setupKey == "" {
return getSetupKeyFromFile(setupKeyPath)
diff --git a/client/cmd/service.go b/client/cmd/service.go
index 56d8a8726..7410d60ea 100644
--- a/client/cmd/service.go
+++ b/client/cmd/service.go
@@ -5,6 +5,7 @@ package cmd
import (
"context"
"fmt"
+ "net/http"
"runtime"
"strings"
"sync"
@@ -22,15 +23,26 @@ var serviceCmd = &cobra.Command{
Short: "Manage the NetBird daemon service",
}
+const defaultJSONSocket = "unix:///var/run/netbird-http.sock"
+
var (
- serviceName string
- serviceEnvVars []string
+ serviceName string
+ serviceEnvVars []string
+ jsonSocket string
+ enableJSONSocket bool
)
type program struct {
- ctx context.Context
- cancel context.CancelFunc
- serv *grpc.Server
+ ctx context.Context
+ cancel context.CancelFunc
+ serv *grpc.Server
+ jsonServ *http.Server
+ // jsonClient is the gateway's own connection to the daemon. It is held so
+ // shutting the gateway down also closes it: nothing else references it once
+ // the handlers are registered, so its transport goroutines would otherwise
+ // outlive the server.
+ jsonClient *grpc.ClientConn
+ jsonServMu sync.Mutex
serverInstance *server.Server
serverInstanceMu sync.Mutex
}
@@ -46,6 +58,8 @@ func init() {
serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings")
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
+ serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket")
+ serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
serviceEnvDesc := `Sets extra environment variables for the service. ` +
diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go
index 8de147946..b187a7b87 100644
--- a/client/cmd/service_controller.go
+++ b/client/cmd/service_controller.go
@@ -5,9 +5,7 @@ package cmd
import (
"context"
"fmt"
- "net"
- "os"
- "strings"
+ "runtime"
"time"
"github.com/kardianos/service"
@@ -16,69 +14,157 @@ import (
"github.com/spf13/cobra"
"google.golang.org/grpc"
+ "github.com/netbirdio/netbird/client/internal/daemonaddr"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/util"
)
+func validateJSONSocketFlags() error {
+ if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket {
+ return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway")
+ }
+ return nil
+}
+
+// daemonServerOptions installs the transport credentials that expose each
+// caller's kernel-authenticated identity to the handlers, which is what lets
+// the daemon require root/administrator for privileged operations.
+//
+// The handshake exchanges no bytes, so older CLI and UI binaries still
+// interoperate. Callers on a TCP socket carry no identity at all: the daemon
+// keeps serving them, and the privileged operations deny them, so a warning is
+// logged to make the loss of functionality visible.
+func daemonServerOptions(network string) []grpc.ServerOption {
+ if network == "tcp" {
+ log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
+ "so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+
+ "deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr)
+ return nil
+ }
+
+ creds := ipcauth.NewTransportCredentials() //nolint:staticcheck
+ if creds == nil { //nolint:staticcheck // nil only on platforms without a peer-identity primitive
+ log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS)
+ return nil
+ }
+
+ return []grpc.ServerOption{grpc.Creds(creds)}
+}
+
func (p *program) Start(svc service.Service) error {
// Start should not block. Do the actual work async.
log.Info("starting NetBird service") //nolint
+ if err := validateJSONSocketFlags(); err != nil {
+ return err
+ }
+
// Collect static system and platform information
system.UpdateStaticInfoAsync()
- // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
- p.serv = grpc.NewServer()
-
- split := strings.Split(daemonAddr, "://")
- switch split[0] {
- case "unix":
- // cleanup failed close
- stat, err := os.Stat(split[1])
- if err == nil && !stat.IsDir() {
- if err := os.Remove(split[1]); err != nil {
- log.Debugf("remove socket file: %v", err)
- }
- }
- case "tcp":
- default:
- return fmt.Errorf("unsupported daemon address protocol: %v", split[0])
+ // A daemon installed before named-pipe support has the loopback TCP address
+ // persisted. Move it to the named pipe so an upgraded daemon can identify
+ // its callers instead of silently serving an unauthenticated socket.
+ if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
+ log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated)
+ daemonAddr = migrated
}
- listen, err := net.Listen(split[0], split[1])
+ network, _, err := parseListenAddress(daemonAddr)
if err != nil {
- return fmt.Errorf("listen daemon interface: %w", err)
+ return fmt.Errorf("parse daemon address: %w", err)
}
+
+ // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
+ p.serv = grpc.NewServer(daemonServerOptions(network)...)
+
+ daemonListener, jsonListener, err := listenDaemonSockets()
+ if err != nil {
+ return err
+ }
+
go func() {
- defer listen.Close()
-
- if split[0] == "unix" {
- if err := os.Chmod(split[1], 0666); err != nil {
- log.Errorf("failed setting daemon permissions: %v", split[1])
- return
- }
- }
-
- serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
- if err := serverInstance.Start(); err != nil {
- log.Fatalf("failed to start daemon: %v", err)
- }
- proto.RegisterDaemonServiceServer(p.serv, serverInstance)
-
- p.serverInstanceMu.Lock()
- p.serverInstance = serverInstance
- p.serverInstanceMu.Unlock()
-
- log.Printf("started daemon server: %v", split[1])
- if err := p.serv.Serve(listen); err != nil {
- log.Errorf("failed to serve daemon requests: %v", err)
+ // Fatal here rather than inside serve, so serve's deferred listener
+ // closes run before the process exits.
+ if err := p.serve(daemonListener, jsonListener); err != nil {
+ log.Fatalf("failed to %v", err)
}
}()
return nil
}
+// listenDaemonSockets opens the daemon control socket and, when it is enabled, the
+// JSON gateway socket. The control socket is closed again if the second one fails,
+// so a failed start leaves nothing listening. The returned JSON listener is nil
+// when the socket is disabled.
+func listenDaemonSockets() (*socketListener, *socketListener, error) {
+ daemonListener, err := listenOnAddress(daemonAddr)
+ if err != nil {
+ return nil, nil, fmt.Errorf("listen daemon interface: %w", err)
+ }
+
+ if !enableJSONSocket {
+ removeStaleUnixSocketForAddress(jsonSocket)
+ return daemonListener, nil, nil
+ }
+
+ jsonListener, err := listenOnAddress(jsonSocket)
+ if err != nil {
+ if cerr := daemonListener.Close(); cerr != nil {
+ log.Debugf("close daemon listener: %v", cerr)
+ }
+ return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err)
+ }
+
+ return daemonListener, jsonListener, nil
+}
+
+// serve brings up the daemon server on an already-open control socket and blocks
+// until it stops. jsonListener is nil when the JSON socket is disabled. A returned
+// error means the daemon cannot run at all and the caller is expected to exit; the
+// failures it recovers from on its own are logged here.
+func (p *program) serve(daemonListener, jsonListener *socketListener) error {
+ defer daemonListener.Close()
+ if jsonListener != nil {
+ defer jsonListener.Close()
+ }
+
+ // chmodUnixSocket is a no-op for a nil listener and for a non-unix one.
+ if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
+ log.Error(err)
+ return nil
+ }
+ if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
+ log.Error(err)
+ return nil
+ }
+
+ serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
+ if err := serverInstance.Start(); err != nil {
+ return fmt.Errorf("start daemon: %w", err)
+ }
+ proto.RegisterDaemonServiceServer(p.serv, serverInstance)
+
+ p.serverInstanceMu.Lock()
+ p.serverInstance = serverInstance
+ p.serverInstanceMu.Unlock()
+
+ if jsonListener == nil {
+ log.Debug("daemon JSON socket disabled")
+ } else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
+ return fmt.Errorf("start daemon JSON server: %w", err)
+ }
+
+ log.Printf("started daemon server: %v", daemonListener.address)
+ if err := p.serv.Serve(daemonListener.Listener); err != nil {
+ log.Errorf("failed to serve daemon requests: %v", err)
+ }
+ return nil
+}
+
func (p *program) Stop(srv service.Service) error {
p.serverInstanceMu.Lock()
if p.serverInstance != nil {
@@ -92,6 +178,25 @@ func (p *program) Stop(srv service.Service) error {
p.cancel()
+ p.jsonServMu.Lock()
+ jsonServ, jsonClient := p.jsonServ, p.jsonClient
+ p.jsonServMu.Unlock()
+ if jsonClient != nil {
+ if err := jsonClient.Close(); err != nil {
+ log.Debugf("close daemon JSON gateway client: %v", err)
+ }
+ }
+ if jsonServ != nil {
+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
+ if err := jsonServ.Shutdown(shutdownCtx); err != nil {
+ log.Errorf("failed to stop daemon JSON server gracefully: %v", err)
+ if err := jsonServ.Close(); err != nil {
+ log.Errorf("failed to close daemon JSON server: %v", err)
+ }
+ }
+ shutdownCancel()
+ }
+
if p.serv != nil {
p.serv.Stop()
}
@@ -148,6 +253,9 @@ var runCmd = &cobra.Command{
if err != nil {
return err
}
+ if err := validateJSONSocketFlags(); err != nil {
+ return err
+ }
return s.Run()
},
@@ -162,6 +270,9 @@ var startCmd = &cobra.Command{
if err != nil {
return err
}
+ if err := validateJSONSocketFlags(); err != nil {
+ return err
+ }
if err := s.Start(); err != nil {
return fmt.Errorf("start service: %w", err)
@@ -198,6 +309,9 @@ var restartCmd = &cobra.Command{
if err != nil {
return err
}
+ if err := validateJSONSocketFlags(); err != nil {
+ return err
+ }
if err := s.Restart(); err != nil {
return fmt.Errorf("restart service: %w", err)
diff --git a/client/cmd/service_installer.go b/client/cmd/service_installer.go
index 2d45fa063..ae2dfb9fa 100644
--- a/client/cmd/service_installer.go
+++ b/client/cmd/service_installer.go
@@ -67,6 +67,10 @@ func buildServiceArguments() []string {
args = append(args, "--disable-networks")
}
+ if enableJSONSocket {
+ args = append(args, "--enable-json-socket", "--json-socket", jsonSocket)
+ }
+
return args
}
@@ -106,6 +110,10 @@ func configurePlatformSpecificSettings(svcConfig *service.Config) error {
// Create fully configured service config for install/reconfigure
func createServiceConfigForInstall() (*service.Config, error) {
+ if err := validateJSONSocketFlags(); err != nil {
+ return nil, err
+ }
+
svcConfig, err := newSVCConfig()
if err != nil {
return nil, fmt.Errorf("create service config: %w", err)
diff --git a/client/cmd/service_json_gateway.go b/client/cmd/service_json_gateway.go
new file mode 100644
index 000000000..b6864f338
--- /dev/null
+++ b/client/cmd/service_json_gateway.go
@@ -0,0 +1,150 @@
+//go:build !ios && !android
+
+package cmd
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc"
+
+ "github.com/netbirdio/netbird/client/internal/daemonaddr"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// jsonPeerIdentity is the context key under which the connecting HTTP client's
+// identity is stashed for the lifetime of its connection.
+type jsonPeerIdentity struct{}
+
+// jsonPeerIdentityValue pairs the identity with whether it could be read at
+// all, so an unreadable identity is forwarded as "unknown" rather than omitted.
+type jsonPeerIdentityValue struct {
+ id ipcauth.Identity
+ known bool
+}
+
+// jsonConnContext reads the identity of the client connecting to the JSON
+// socket and stashes it on the connection's context. The gateway re-dials the
+// daemon in-process, so the daemon would otherwise see every JSON request as
+// coming from the daemon itself.
+func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
+ value := jsonPeerIdentityValue{}
+ id, err := ipcauth.ConnIdentity(c)
+ if err != nil {
+ log.Warnf("json gateway: cannot read HTTP client identity, privileged operations will be denied for this connection: %v", err)
+ } else {
+ value.id = id
+ value.known = true
+ }
+ return context.WithValue(ctx, jsonPeerIdentity{}, value)
+}
+
+// forwardIdentity stamps the HTTP client's identity onto every call the gateway
+// makes to the daemon.
+//
+// It is an interceptor on the gateway's client connection rather than a
+// runtime.WithMetadata annotator because grpc-gateway skips annotators when no
+// request header maps to metadata, which an HTTP/1.0 request with no Host header
+// over a unix socket achieves. The daemon would then receive no marker, see its own
+// identity as the transport peer, and authorize the request as the daemon itself.
+// An interceptor runs for every RPC whatever the request looked like.
+func forwardIdentity(ctx context.Context) context.Context {
+ value, ok := ctx.Value(jsonPeerIdentity{}).(jsonPeerIdentityValue)
+ if !ok {
+ // No ConnContext ran for this request, so forward an unknown identity:
+ // the daemon must not mistake its own identity for the client's.
+ return ipcauth.WithForwardedIdentity(ctx, ipcauth.Identity{}, false)
+ }
+ return ipcauth.WithForwardedIdentity(ctx, value.id, value.known)
+}
+
+func forwardIdentityUnary(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
+ return invoker(forwardIdentity(ctx), method, req, reply, cc, opts...)
+}
+
+func forwardIdentityStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
+ return streamer(forwardIdentity(ctx), desc, cc, method, opts...)
+}
+
+// reservedHeaderWarning limits the dropped-header warning to the first occurrence.
+var reservedHeaderWarning sync.Once
+
+// jsonIncomingHeaderMatcher keeps an HTTP client from supplying the metadata the
+// gateway uses to forward its identity. grpc-gateway turns "Grpc-Metadata-"
+// headers into gRPC metadata and joins them ahead of what its annotators add, so
+// without this filter a JSON client could send its own x-netbird-fwd-uid and the
+// daemon would authorize that instead of the client's real identity.
+func jsonIncomingHeaderMatcher(key string) (string, bool) {
+ mapped, ok := runtime.DefaultHeaderMatcher(key)
+ if !ok {
+ return "", false
+ }
+ if ipcauth.IsReservedForwardKey(mapped) {
+ // Warn once: any client can send these on every request, so warning each
+ // time hands it a way to fill the log. The rest are debug-level.
+ reservedHeaderWarning.Do(func() {
+ log.Warnf("json gateway: dropping reserved header %q from a request: only the gateway may set the caller's identity", key)
+ })
+ log.Debugf("json gateway: dropping reserved header %q", key)
+ return "", false
+ }
+ return mapped, true
+}
+
+func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
+ if jsonListener.network == "tcp" {
+ log.Warnf("daemon JSON socket is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
+ "so privileged operations will be denied for JSON clients", jsonListener.address)
+ }
+
+ mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
+
+ // grpc.NewClient does not connect until the first request, so registering
+ // the handler here cannot block daemon startup.
+ target, opts := daemonaddr.DialTarget(daemonEndpoint)
+ opts = append(opts,
+ grpc.WithChainUnaryInterceptor(forwardIdentityUnary),
+ grpc.WithChainStreamInterceptor(forwardIdentityStream),
+ )
+ conn, err := grpc.NewClient(target, opts...)
+ if err != nil {
+ return fmt.Errorf("create daemon client for JSON gateway: %w", err)
+ }
+ if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil {
+ if cerr := conn.Close(); cerr != nil {
+ log.Debugf("close daemon client after failed JSON gateway registration: %v", cerr)
+ }
+ return err
+ }
+
+ jsonServer := &http.Server{
+ Handler: mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ BaseContext: func(net.Listener) context.Context {
+ return p.ctx
+ },
+ ConnContext: jsonConnContext,
+ }
+
+ p.jsonServMu.Lock()
+ p.jsonServ = jsonServer
+ p.jsonClient = conn
+ p.jsonServMu.Unlock()
+
+ go func() {
+ log.Printf("started daemon JSON server: %v", jsonListener.address)
+ if err := jsonServer.Serve(jsonListener.Listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Errorf("failed to serve daemon JSON requests: %v", err)
+ }
+ }()
+
+ return nil
+}
diff --git a/client/cmd/service_json_gateway_test.go b/client/cmd/service_json_gateway_test.go
new file mode 100644
index 000000000..dfeef1c46
--- /dev/null
+++ b/client/cmd/service_json_gateway_test.go
@@ -0,0 +1,261 @@
+//go:build !windows && !ios && !android
+
+package cmd
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/peer"
+
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+)
+
+// The JSON gateway runs inside the daemon and re-dials it locally, so every JSON
+// request reaches a handler with the daemon's own identity as the transport peer.
+// The gateway therefore forwards its HTTP client's identity as metadata, and the
+// daemon authorizes that instead of itself. These tests drive the real wiring
+// (jsonConnContext, forwardIdentity, jsonIncomingHeaderMatcher) and check the
+// identity a handler would end up authorizing.
+
+// daemonSideCtx is what a handler sees for a gateway-relayed call. The transport
+// peer must be this process's own identity: the gateway is the daemon, so the two
+// cannot differ, and hardcoding root here instead would describe a state that
+// never occurs.
+func daemonSideCtx(t *testing.T, md metadata.MD) context.Context {
+ t.Helper()
+ self, err := ipcauth.CurrentProcessIdentity()
+ if err != nil {
+ t.Skipf("cannot read this process's identity: %v", err)
+ }
+ ctx := peer.NewContext(context.Background(), &peer.Peer{
+ AuthInfo: ipcauth.AuthInfo{
+ CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
+ Identity: self,
+ },
+ })
+ return metadata.NewIncomingContext(ctx, md)
+}
+
+// gatewayMetadata reproduces what the daemon receives for a JSON request: the
+// mux annotates the context from the request's headers, then the interceptor on the
+// gateway's client connection stamps the caller's identity. The order matters,
+// since the interceptor must win over anything a header put there.
+func gatewayMetadata(t *testing.T, req *http.Request, ctx context.Context) metadata.MD {
+ t.Helper()
+ mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
+ annotated, err := runtime.AnnotateContext(ctx, mux, req,
+ "/daemon.DaemonService/SetConfig",
+ runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
+ if err != nil {
+ t.Fatalf("annotate: %v", err)
+ }
+
+ md, ok := metadata.FromOutgoingContext(forwardIdentity(annotated))
+ if !ok {
+ t.Fatal("the interceptor produced no metadata")
+ }
+ return md
+}
+
+// clientCtx is the connection context jsonConnContext would have produced for an
+// HTTP client whose identity the gateway could read.
+func clientCtx(id ipcauth.Identity, known bool) context.Context {
+ return context.WithValue(context.Background(), jsonPeerIdentity{},
+ jsonPeerIdentityValue{id: id, known: known})
+}
+
+// An HTTP client must not be able to name its own identity. grpc-gateway turns
+// Grpc-Metadata- headers into gRPC metadata, so without the header filter and
+// the interceptor overwriting the reserved keys, this request would authorize as
+// uid 0.
+func TestJSONGateway_ForgedIdentityHeaderIsDropped(t *testing.T) {
+ req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Uid", "0")
+ req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Gid", "0")
+ req.Header.Set("Grpc-Metadata-X-Netbird-Fwd", "1")
+ req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Sid", "S-1-5-18")
+
+ caller := ipcauth.Identity{UID: 31000, GID: 31000}
+ md := gatewayMetadata(t, req, clientCtx(caller, true))
+
+ id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
+ if !ok {
+ t.Fatal("the forwarded identity should be usable")
+ }
+ if id.IsPrivileged() {
+ t.Errorf("forged header was believed: authorized as %v", id)
+ }
+ if id.UID != caller.UID {
+ t.Errorf("authorized as uid %d, want the real client %d", id.UID, caller.UID)
+ }
+}
+
+// A request with no headers at all (HTTP/1.0 needs no Host, and a unix socket
+// yields no host:port) makes grpc-gateway produce no metadata whatsoever and skip
+// its annotators: "if len(pairs) == 0 { return ctx, nil, nil }" in
+// runtime/context.go. That is why the identity is stamped by an interceptor
+// instead. This is the case that previously reached the gate as the daemon itself.
+func TestJSONGateway_HeaderlessRequestIsStillMarkedForwarded(t *testing.T) {
+ req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header = http.Header{}
+ req.Host = ""
+
+ caller := ipcauth.Identity{UID: 31000, GID: 31000}
+ ctx := clientCtx(caller, true)
+
+ // Pin the skip path itself: if grpc-gateway ever produced a pair here, this
+ // test would still pass below while no longer covering what it was written for.
+ mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
+ annotated, err := runtime.AnnotateContext(ctx, mux, req,
+ "/daemon.DaemonService/SetConfig",
+ runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
+ if err != nil {
+ t.Fatalf("annotate: %v", err)
+ }
+ if md, ok := metadata.FromOutgoingContext(annotated); ok {
+ t.Fatalf("grpc-gateway produced metadata %v for a headerless request; "+
+ "this test no longer covers the annotator-skip path", md)
+ }
+
+ md := gatewayMetadata(t, req, ctx)
+
+ id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
+ if !ok {
+ t.Fatal("the forwarded identity should be usable")
+ }
+ if id.UID != caller.UID || id.IsPrivileged() {
+ t.Errorf("authorized as %v, want the real client uid %d", id, caller.UID)
+ }
+}
+
+// When the gateway cannot read its client's identity (a TCP JSON socket, say) it
+// forwards the marker alone. The daemon must then report "unidentified" so the
+// privileged operations refuse, rather than falling back to the gateway's own
+// identity.
+func TestJSONGateway_UnreadableClientIdentityIsUnidentified(t *testing.T) {
+ req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ md := gatewayMetadata(t, req, clientCtx(ipcauth.Identity{}, false))
+
+ if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
+ t.Errorf("a request with no client identity was authorized as %v", id)
+ }
+}
+
+// A request that never passed through jsonConnContext (no stashed identity) must
+// also come out unidentified rather than as the daemon.
+func TestJSONGateway_MissingConnContextIsUnidentified(t *testing.T) {
+ req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ md := gatewayMetadata(t, req, context.Background())
+
+ if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
+ t.Errorf("a request with no connection context was authorized as %v", id)
+ }
+}
+
+// End to end over a real unix socket: the gateway reads the connecting client's
+// identity from the socket itself, so a client cannot present anything else.
+func TestJSONGateway_IdentityComesFromTheSocket(t *testing.T) {
+ mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
+
+ type observed struct {
+ md metadata.MD
+ }
+ seen := make(chan observed, 1)
+
+ srv := &http.Server{
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx, err := runtime.AnnotateContext(r.Context(), mux, r,
+ "/daemon.DaemonService/SetConfig",
+ runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
+ if err != nil {
+ t.Errorf("annotate: %v", err)
+ return
+ }
+ md, _ := metadata.FromOutgoingContext(forwardIdentity(ctx))
+ seen <- observed{md: md}
+ w.WriteHeader(http.StatusOK)
+ }),
+ ReadHeaderTimeout: 5 * time.Second,
+ ConnContext: jsonConnContext,
+ }
+
+ sock := filepath.Join(t.TempDir(), "http.sock")
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := srv.Close(); err != nil {
+ t.Logf("close server: %v", err)
+ }
+ })
+ go func() {
+ if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
+ t.Logf("serve: %v", err)
+ }
+ }()
+
+ conn, err := net.Dial("unix", sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := conn.Close(); err != nil {
+ t.Logf("close conn: %v", err)
+ }
+ })
+
+ // Forge the identity headers on the wire as well.
+ request := "POST /daemon.DaemonService/SetConfig HTTP/1.1\r\n" +
+ "Host: localhost\r\n" +
+ "Grpc-Metadata-X-Netbird-Fwd: 1\r\n" +
+ "Grpc-Metadata-X-Netbird-Fwd-Uid: 0\r\n" +
+ "Content-Length: 0\r\n\r\n"
+ if _, err := conn.Write([]byte(request)); err != nil {
+ t.Fatal(err)
+ }
+
+ select {
+ case got := <-seen:
+ self, err := ipcauth.CurrentProcessIdentity()
+ if err != nil {
+ t.Skipf("cannot read this process's identity: %v", err)
+ }
+ // The socket peer is this test process, so that is the identity the
+ // gateway must forward, not the uid 0 the request asked for.
+ if uids := got.md.Get("x-netbird-fwd-uid"); len(uids) != 1 {
+ t.Fatalf("x-netbird-fwd-uid = %v, want exactly the gateway's own value", uids)
+ }
+ id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, got.md))
+ if !ok {
+ t.Fatal("the forwarded identity should be usable")
+ }
+ if id.UID != self.UID {
+ t.Errorf("authorized as uid %d, want the socket peer %d", id.UID, self.UID)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("the gateway never handled the request")
+ }
+}
diff --git a/client/cmd/service_json_socket_test.go b/client/cmd/service_json_socket_test.go
new file mode 100644
index 000000000..4b39794d7
--- /dev/null
+++ b/client/cmd/service_json_socket_test.go
@@ -0,0 +1,176 @@
+//go:build !ios && !android
+
+package cmd
+
+import (
+ "net"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func preserveJSONSocketTestState(t *testing.T) {
+ t.Helper()
+
+ origJSONSocket := jsonSocket
+ origEnableJSONSocket := enableJSONSocket
+ origChanged := map[string]bool{}
+ serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
+ origChanged[flag.Name] = flag.Changed
+ })
+
+ t.Cleanup(func() {
+ jsonSocket = origJSONSocket
+ enableJSONSocket = origEnableJSONSocket
+ serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
+ flag.Changed = origChanged[flag.Name]
+ })
+ })
+}
+
+func TestJSONSocketFlagsArePositiveEnableOnly(t *testing.T) {
+ assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("enable-json-socket"))
+ assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("json-socket"))
+ assert.Nil(t, serviceCmd.PersistentFlags().Lookup("disable-json-socket"))
+ assert.Equal(t, "false", serviceCmd.PersistentFlags().Lookup("enable-json-socket").DefValue)
+}
+
+func TestBuildServiceArgumentsDefaultDisablesJSONSocket(t *testing.T) {
+ preserveJSONSocketTestState(t)
+
+ enableJSONSocket = false
+ jsonSocket = "tcp://127.0.0.1:8080"
+
+ args := buildServiceArguments()
+
+ assert.NotContains(t, args, "--enable-json-socket")
+ assert.NotContains(t, args, "--json-socket")
+}
+
+func TestBuildServiceArgumentsIncludesJSONSocketWhenEnabled(t *testing.T) {
+ preserveJSONSocketTestState(t)
+
+ enableJSONSocket = true
+ jsonSocket = "tcp://127.0.0.1:8080"
+
+ args := buildServiceArguments()
+
+ enableIndex := indexOfArg(args, "--enable-json-socket")
+ jsonIndex := indexOfArg(args, "--json-socket")
+ require.NotEqual(t, -1, enableIndex)
+ require.NotEqual(t, -1, jsonIndex)
+ require.Less(t, enableIndex, jsonIndex)
+ require.Less(t, jsonIndex+1, len(args))
+ assert.Equal(t, "tcp://127.0.0.1:8080", args[jsonIndex+1])
+}
+
+func TestJSONSocketWithoutEnableValidation(t *testing.T) {
+ preserveJSONSocketTestState(t)
+
+ enableJSONSocket = false
+ require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080"))
+
+ err := validateJSONSocketFlags()
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--enable-json-socket")
+}
+
+func TestJSONSocketWithEnableValidation(t *testing.T) {
+ preserveJSONSocketTestState(t)
+
+ require.NoError(t, serviceCmd.PersistentFlags().Set("enable-json-socket", "true"))
+ require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080"))
+
+ assert.NoError(t, validateJSONSocketFlags())
+}
+
+func TestJSONSocketServiceParamsPersistEnableAndAddress(t *testing.T) {
+ preserveJSONSocketTestState(t)
+ serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
+ flag.Changed = false
+ })
+
+ enableJSONSocket = true
+ jsonSocket = "tcp://127.0.0.1:8080"
+
+ params := currentServiceParams()
+ require.True(t, params.EnableJSONSocket)
+ require.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket)
+
+ enableJSONSocket = false
+ jsonSocket = defaultJSONSocket
+ applyServiceParams(testServiceEnvCommand(), params)
+
+ assert.True(t, enableJSONSocket)
+ assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket)
+}
+
+func TestRemoveStaleUnixSocketDoesNotRemoveRegularFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "netbird-http.sock")
+ require.NoError(t, os.WriteFile(path, []byte("not a socket"), 0600))
+
+ removeStaleUnixSocket(path)
+
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("not a socket"), data)
+}
+
+func TestRemoveStaleUnixSocketRemovesSocket(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("unix sockets are not available on Windows")
+ }
+
+ path := filepath.Join(t.TempDir(), "netbird-http.sock")
+ addr := &net.UnixAddr{Name: path, Net: "unix"}
+ listener, err := net.ListenUnix("unix", addr)
+ require.NoError(t, err)
+ listener.SetUnlinkOnClose(false)
+ require.NoError(t, listener.Close())
+
+ _, err = os.Lstat(path)
+ require.NoError(t, err, "test setup must leave a stale Unix socket path")
+
+ removeStaleUnixSocket(path)
+
+ _, err = os.Lstat(path)
+ assert.True(t, os.IsNotExist(err), "expected stale Unix socket to be removed, got %v", err)
+}
+
+func TestRemoveStaleUnixSocketDoesNotRemoveLiveSocket(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("unix sockets are not available on Windows")
+ }
+
+ path := filepath.Join(t.TempDir(), "netbird-http.sock")
+ listener, err := net.Listen("unix", path)
+ require.NoError(t, err)
+ defer listener.Close()
+
+ removeStaleUnixSocket(path)
+
+ _, err = os.Lstat(path)
+ assert.NoError(t, err, "expected live Unix socket to be preserved")
+}
+
+func testServiceEnvCommand() *cobra.Command {
+ cmd := &cobra.Command{}
+ cmd.Flags().StringSlice("service-env", nil, "")
+ return cmd
+}
+
+func indexOfArg(args []string, arg string) int {
+ for i, candidate := range args {
+ if candidate == arg {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/client/cmd/service_params.go b/client/cmd/service_params.go
index 192e0ac60..750b22ae6 100644
--- a/client/cmd/service_params.go
+++ b/client/cmd/service_params.go
@@ -13,6 +13,7 @@ import (
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/configs"
+ "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/util"
)
@@ -23,6 +24,7 @@ const serviceParamsFile = "service.json"
type serviceParams struct {
LogLevel string `json:"log_level"`
DaemonAddr string `json:"daemon_addr"`
+ JSONSocket string `json:"json_socket"`
ManagementURL string `json:"management_url,omitempty"`
ConfigPath string `json:"config_path,omitempty"`
LogFiles []string `json:"log_files,omitempty"`
@@ -30,6 +32,7 @@ type serviceParams struct {
DisableUpdateSettings bool `json:"disable_update_settings,omitempty"`
EnableCapture bool `json:"enable_capture,omitempty"`
DisableNetworks bool `json:"disable_networks,omitempty"`
+ EnableJSONSocket bool `json:"enable_json_socket,omitempty"`
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
}
@@ -75,6 +78,7 @@ func currentServiceParams() *serviceParams {
params := &serviceParams{
LogLevel: logLevel,
DaemonAddr: daemonAddr,
+ JSONSocket: jsonSocket,
ManagementURL: managementURL,
ConfigPath: configPath,
LogFiles: logFiles,
@@ -82,6 +86,7 @@ func currentServiceParams() *serviceParams {
DisableUpdateSettings: updateSettingsDisabled,
EnableCapture: captureEnabled,
DisableNetworks: networksDisabled,
+ EnableJSONSocket: enableJSONSocket,
}
if len(serviceEnvVars) > 0 {
@@ -113,15 +118,29 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
return
}
- // For fields with non-empty defaults (log-level, daemon-addr), keep the
- // != "" guard so that an older service.json missing the field doesn't
- // clobber the default with an empty string.
+ // For fields with non-empty defaults, keep the != "" guard so that an older
+ // service.json missing the field doesn't clobber the default with an empty string.
if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" {
logLevel = params.LogLevel
}
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
daemonAddr = params.DaemonAddr
+ // An install that predates named-pipe support has the loopback TCP
+ // address saved. Callers carry no identity over TCP, so move it to the
+ // pipe instead of restoring a socket the daemon cannot authorize on.
+ if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
+ cmd.Printf("Moving the saved daemon address from %s to %s so the daemon can identify its callers\n", daemonAddr, migrated)
+ daemonAddr = migrated
+ }
+ }
+
+ if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" {
+ jsonSocket = params.JSONSocket
+ }
+
+ if !serviceCmd.PersistentFlags().Changed("enable-json-socket") {
+ enableJSONSocket = params.EnableJSONSocket
}
// For optional fields where empty means "use default", always apply so
diff --git a/client/cmd/service_params_test.go b/client/cmd/service_params_test.go
index f338c12f4..94f98a0ce 100644
--- a/client/cmd/service_params_test.go
+++ b/client/cmd/service_params_test.go
@@ -41,6 +41,8 @@ func TestSaveAndLoadServiceParams(t *testing.T) {
params := &serviceParams{
LogLevel: "debug",
DaemonAddr: "unix:///var/run/netbird.sock",
+ JSONSocket: "tcp://127.0.0.1:8080",
+ EnableJSONSocket: true,
ManagementURL: "https://my.server.com",
ConfigPath: "/etc/netbird/config.json",
LogFiles: []string{"/var/log/netbird/client.log", "console"},
@@ -63,6 +65,8 @@ func TestSaveAndLoadServiceParams(t *testing.T) {
assert.Equal(t, params.LogLevel, loaded.LogLevel)
assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr)
+ assert.Equal(t, params.JSONSocket, loaded.JSONSocket)
+ assert.Equal(t, params.EnableJSONSocket, loaded.EnableJSONSocket)
assert.Equal(t, params.ManagementURL, loaded.ManagementURL)
assert.Equal(t, params.ConfigPath, loaded.ConfigPath)
assert.Equal(t, params.LogFiles, loaded.LogFiles)
@@ -101,6 +105,8 @@ func TestLoadServiceParams_InvalidJSON(t *testing.T) {
func TestCurrentServiceParams(t *testing.T) {
origLogLevel := logLevel
origDaemonAddr := daemonAddr
+ origJSONSocket := jsonSocket
+ origEnableJSONSocket := enableJSONSocket
origManagementURL := managementURL
origConfigPath := configPath
origLogFiles := logFiles
@@ -110,6 +116,8 @@ func TestCurrentServiceParams(t *testing.T) {
t.Cleanup(func() {
logLevel = origLogLevel
daemonAddr = origDaemonAddr
+ jsonSocket = origJSONSocket
+ enableJSONSocket = origEnableJSONSocket
managementURL = origManagementURL
configPath = origConfigPath
logFiles = origLogFiles
@@ -120,6 +128,8 @@ func TestCurrentServiceParams(t *testing.T) {
logLevel = "trace"
daemonAddr = "tcp://127.0.0.1:9999"
+ jsonSocket = "tcp://127.0.0.1:8080"
+ enableJSONSocket = true
managementURL = "https://mgmt.example.com"
configPath = "/tmp/test-config.json"
logFiles = []string{"/tmp/test.log"}
@@ -131,6 +141,8 @@ func TestCurrentServiceParams(t *testing.T) {
assert.Equal(t, "trace", params.LogLevel)
assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr)
+ assert.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket)
+ assert.True(t, params.EnableJSONSocket)
assert.Equal(t, "https://mgmt.example.com", params.ManagementURL)
assert.Equal(t, "/tmp/test-config.json", params.ConfigPath)
assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles)
@@ -142,6 +154,8 @@ func TestCurrentServiceParams(t *testing.T) {
func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
origLogLevel := logLevel
origDaemonAddr := daemonAddr
+ origJSONSocket := jsonSocket
+ origEnableJSONSocket := enableJSONSocket
origManagementURL := managementURL
origConfigPath := configPath
origLogFiles := logFiles
@@ -151,6 +165,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
t.Cleanup(func() {
logLevel = origLogLevel
daemonAddr = origDaemonAddr
+ jsonSocket = origJSONSocket
+ enableJSONSocket = origEnableJSONSocket
managementURL = origManagementURL
configPath = origConfigPath
logFiles = origLogFiles
@@ -162,6 +178,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
// Reset all flags to defaults.
logLevel = "info"
daemonAddr = "unix:///var/run/netbird.sock"
+ jsonSocket = defaultJSONSocket
+ enableJSONSocket = false
managementURL = ""
configPath = "/etc/netbird/config.json"
logFiles = []string{"/var/log/netbird/client.log"}
@@ -184,6 +202,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
saved := &serviceParams{
LogLevel: "debug",
DaemonAddr: "tcp://127.0.0.1:5555",
+ JSONSocket: "tcp://127.0.0.1:8080",
+ EnableJSONSocket: true,
ManagementURL: "https://saved.example.com",
ConfigPath: "/saved/config.json",
LogFiles: []string{"/saved/client.log"},
@@ -201,6 +221,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
// All other fields were not Changed, so they should use saved values.
assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr)
+ assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket)
+ assert.True(t, enableJSONSocket)
assert.Equal(t, "https://saved.example.com", managementURL)
assert.Equal(t, "/saved/config.json", configPath)
assert.Equal(t, []string{"/saved/client.log"}, logFiles)
@@ -212,14 +234,17 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) {
origProfilesDisabled := profilesDisabled
origUpdateSettingsDisabled := updateSettingsDisabled
+ origEnableJSONSocket := enableJSONSocket
t.Cleanup(func() {
profilesDisabled = origProfilesDisabled
updateSettingsDisabled = origUpdateSettingsDisabled
+ enableJSONSocket = origEnableJSONSocket
})
// Simulate current state where booleans are true (e.g. set by previous install).
profilesDisabled = true
updateSettingsDisabled = true
+ enableJSONSocket = true
// Reset Changed state so flags appear unset.
serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
@@ -238,6 +263,7 @@ func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) {
assert.False(t, profilesDisabled, "saved false should override current true")
assert.False(t, updateSettingsDisabled, "saved false should override current true")
+ assert.False(t, enableJSONSocket, "saved false should override current true")
}
func TestApplyServiceParams_ClearManagementURL(t *testing.T) {
@@ -530,6 +556,7 @@ func fieldToGlobalVar(field string) string {
m := map[string]string{
"LogLevel": "logLevel",
"DaemonAddr": "daemonAddr",
+ "JSONSocket": "jsonSocket",
"ManagementURL": "managementURL",
"ConfigPath": "configPath",
"LogFiles": "logFiles",
@@ -537,6 +564,7 @@ func fieldToGlobalVar(field string) string {
"DisableUpdateSettings": "updateSettingsDisabled",
"EnableCapture": "captureEnabled",
"DisableNetworks": "networksDisabled",
+ "EnableJSONSocket": "enableJSONSocket",
"ServiceEnvVars": "serviceEnvVars",
}
if v, ok := m[field]; ok {
diff --git a/client/cmd/service_pipe_other.go b/client/cmd/service_pipe_other.go
new file mode 100644
index 000000000..c7cc72469
--- /dev/null
+++ b/client/cmd/service_pipe_other.go
@@ -0,0 +1,14 @@
+//go:build !windows
+
+package cmd
+
+import (
+ "fmt"
+ "net"
+)
+
+// listenNamedPipe is Windows-only: no other platform serves the daemon on a
+// named pipe.
+func listenNamedPipe(string) (net.Listener, string, error) {
+ return nil, "", fmt.Errorf("named pipes are only supported on Windows")
+}
diff --git a/client/cmd/service_pipe_windows.go b/client/cmd/service_pipe_windows.go
new file mode 100644
index 000000000..b6e860f51
--- /dev/null
+++ b/client/cmd/service_pipe_windows.go
@@ -0,0 +1,41 @@
+//go:build windows
+
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "net"
+
+ "github.com/Microsoft/go-winio"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/daemonaddr"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+)
+
+// listenNamedPipe creates the daemon control pipe and reports the path it ended
+// up on. The security descriptor lets any local caller connect, as a Unix socket
+// at 0666 does, and the privileged operations are authorized separately from the
+// caller's token.
+//
+// The protected name comes first so that an unprivileged process cannot take the
+// name before the service does. Creating it requires being an administrator or
+// LocalSystem, so a daemon an ordinary user runs themselves, as in netstack mode,
+// falls back to the plain name; clients try both and check who serves them.
+func listenNamedPipe(name string) (net.Listener, string, error) {
+ var errs []error
+ for _, path := range daemonaddr.PipePaths(name) {
+ listener, err := winio.ListenPipe(path, &winio.PipeConfig{
+ SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
+ })
+ if err != nil {
+ log.Debugf("not serving the daemon on %s: %v", path, err)
+ errs = append(errs, fmt.Errorf("%s: %w", path, err))
+ continue
+ }
+ return listener, path, nil
+ }
+
+ return nil, "", errors.Join(errs...)
+}
diff --git a/client/cmd/service_privileged_test.go b/client/cmd/service_privileged_test.go
new file mode 100644
index 000000000..075d7f378
--- /dev/null
+++ b/client/cmd/service_privileged_test.go
@@ -0,0 +1,196 @@
+//go:build privileged
+
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/kardianos/service"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ serviceStartTimeout = 10 * time.Second
+ serviceStopTimeout = 5 * time.Second
+ statusPollInterval = 500 * time.Millisecond
+)
+
+// waitForServiceStatus waits for service to reach expected status with timeout
+func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) {
+ cfg, err := newSVCConfig()
+ if err != nil {
+ return false, err
+ }
+
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ if err != nil {
+ return false, err
+ }
+
+ ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout)
+ defer timeoutCancel()
+
+ ticker := time.NewTicker(statusPollInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus)
+ case <-ticker.C:
+ status, err := s.Status()
+ if err != nil {
+ // Continue polling on transient errors
+ continue
+ }
+ if status == expectedStatus {
+ return true, nil
+ }
+ }
+ }
+}
+
+// TestServiceLifecycle tests the complete service lifecycle
+func TestServiceLifecycle(t *testing.T) {
+ // TODO: Add support for Windows and macOS
+ if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
+ t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS)
+ }
+
+ if os.Getenv("CONTAINER") == "true" {
+ t.Skip("Skipping service lifecycle test in container environment")
+ }
+
+ originalServiceName := serviceName
+ serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix())
+ defer func() {
+ serviceName = originalServiceName
+ }()
+
+ tempDir := t.TempDir()
+ configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir)
+ logLevel = "info"
+ daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir)
+
+ // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run.
+ t.Cleanup(func() {
+ cfg, err := newSVCConfig()
+ if err != nil {
+ t.Errorf("cleanup: create service config: %v", err)
+ return
+ }
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ if err != nil {
+ t.Errorf("cleanup: create service: %v", err)
+ return
+ }
+
+ // If the subtests already cleaned up, there's nothing to do.
+ if _, err := s.Status(); err != nil {
+ return
+ }
+
+ if err := s.Stop(); err != nil {
+ t.Errorf("cleanup: stop service: %v", err)
+ }
+ if err := s.Uninstall(); err != nil {
+ t.Errorf("cleanup: uninstall service: %v", err)
+ }
+ })
+
+ ctx := context.Background()
+
+ t.Run("Install", func(t *testing.T) {
+ installCmd.SetContext(ctx)
+ err := installCmd.RunE(installCmd, []string{})
+ require.NoError(t, err)
+
+ cfg, err := newSVCConfig()
+ require.NoError(t, err)
+
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ require.NoError(t, err)
+
+ status, err := s.Status()
+ assert.NoError(t, err)
+ assert.NotEqual(t, service.StatusUnknown, status)
+ })
+
+ t.Run("Start", func(t *testing.T) {
+ startCmd.SetContext(ctx)
+ err := startCmd.RunE(startCmd, []string{})
+ require.NoError(t, err)
+
+ running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
+ require.NoError(t, err)
+ assert.True(t, running)
+ })
+
+ t.Run("Restart", func(t *testing.T) {
+ restartCmd.SetContext(ctx)
+ err := restartCmd.RunE(restartCmd, []string{})
+ require.NoError(t, err)
+
+ running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
+ require.NoError(t, err)
+ assert.True(t, running)
+ })
+
+ t.Run("Reconfigure", func(t *testing.T) {
+ originalLogLevel := logLevel
+ logLevel = "debug"
+ defer func() {
+ logLevel = originalLogLevel
+ }()
+
+ reconfigureCmd.SetContext(ctx)
+ err := reconfigureCmd.RunE(reconfigureCmd, []string{})
+ require.NoError(t, err)
+
+ running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
+ require.NoError(t, err)
+ assert.True(t, running)
+ })
+
+ t.Run("Stop", func(t *testing.T) {
+ stopCmd.SetContext(ctx)
+ err := stopCmd.RunE(stopCmd, []string{})
+ require.NoError(t, err)
+
+ stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout)
+ require.NoError(t, err)
+ assert.True(t, stopped)
+ })
+
+ t.Run("Uninstall", func(t *testing.T) {
+ uninstallCmd.SetContext(ctx)
+ err := uninstallCmd.RunE(uninstallCmd, []string{})
+ require.NoError(t, err)
+
+ cfg, err := newSVCConfig()
+ require.NoError(t, err)
+
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ require.NoError(t, err)
+
+ _, err = s.Status()
+ assert.Error(t, err)
+ })
+}
diff --git a/client/cmd/service_socket.go b/client/cmd/service_socket.go
new file mode 100644
index 000000000..bf3122f7c
--- /dev/null
+++ b/client/cmd/service_socket.go
@@ -0,0 +1,119 @@
+//go:build !ios && !android
+
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "strings"
+ "syscall"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+type socketListener struct {
+ net.Listener
+ network string
+ address string
+}
+
+func listenOnAddress(addr string) (*socketListener, error) {
+ network, address, err := parseListenAddress(addr)
+ if err != nil {
+ return nil, err
+ }
+
+ if network == "npipe" {
+ listener, path, err := listenNamedPipe(address) //nolint:staticcheck
+ if err != nil { //nolint:staticcheck // always errors on non-Windows builds
+ return nil, err
+ }
+ return &socketListener{Listener: listener, network: network, address: path}, nil
+ }
+
+ if network == "unix" {
+ removeStaleUnixSocket(address)
+ }
+
+ listener, err := net.Listen(network, address)
+ if err != nil {
+ return nil, err
+ }
+
+ return &socketListener{Listener: listener, network: network, address: address}, nil
+}
+
+func parseListenAddress(addr string) (string, string, error) {
+ network, address, ok := strings.Cut(addr, "://")
+ if !ok || network == "" || address == "" {
+ return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr)
+ }
+
+ switch network {
+ case "unix", "tcp", "npipe":
+ return network, address, nil
+ default:
+ return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)
+ }
+}
+
+func removeStaleUnixSocket(path string) {
+ stat, err := os.Lstat(path)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ log.Debugf("stat socket file: %v", err)
+ }
+ return
+ }
+
+ if stat.Mode()&os.ModeSocket == 0 {
+ return
+ }
+
+ if !isStaleUnixSocket(path) {
+ return
+ }
+
+ if err := os.Remove(path); err != nil {
+ log.Debugf("remove socket file: %v", err)
+ }
+}
+
+func isStaleUnixSocket(path string) bool {
+ conn, err := net.DialTimeout("unix", path, 100*time.Millisecond)
+ if err == nil {
+ if closeErr := conn.Close(); closeErr != nil {
+ log.Debugf("close unix socket probe: %v", closeErr)
+ }
+ return false
+ }
+
+ if os.IsNotExist(err) || os.IsPermission(err) || os.IsTimeout(err) {
+ log.Debugf("not removing unix socket %s after probe error: %v", path, err)
+ return false
+ }
+
+ return errors.Is(err, syscall.ECONNREFUSED)
+}
+
+func removeStaleUnixSocketForAddress(addr string) {
+ network, address, err := parseListenAddress(addr)
+ if err != nil || network != "unix" {
+ return
+ }
+ removeStaleUnixSocket(address)
+}
+
+func (l *socketListener) chmodUnixSocket(description string) error {
+ if l == nil || l.network != "unix" {
+ return nil
+ }
+
+ if err := os.Chmod(l.address, 0666); err != nil {
+ return fmt.Errorf("failed setting %s permissions for %s: %w", description, l.address, err)
+ }
+ return nil
+}
diff --git a/client/cmd/service_test.go b/client/cmd/service_test.go
index ce6f71550..22eba206d 100644
--- a/client/cmd/service_test.go
+++ b/client/cmd/service_test.go
@@ -1,16 +1,12 @@
package cmd
import (
- "context"
- "fmt"
"os"
"os/signal"
"runtime"
"syscall"
"testing"
- "time"
- "github.com/kardianos/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -31,186 +27,6 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
-const (
- serviceStartTimeout = 10 * time.Second
- serviceStopTimeout = 5 * time.Second
- statusPollInterval = 500 * time.Millisecond
-)
-
-// waitForServiceStatus waits for service to reach expected status with timeout
-func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) {
- cfg, err := newSVCConfig()
- if err != nil {
- return false, err
- }
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- if err != nil {
- return false, err
- }
-
- ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout)
- defer timeoutCancel()
-
- ticker := time.NewTicker(statusPollInterval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus)
- case <-ticker.C:
- status, err := s.Status()
- if err != nil {
- // Continue polling on transient errors
- continue
- }
- if status == expectedStatus {
- return true, nil
- }
- }
- }
-}
-
-// TestServiceLifecycle tests the complete service lifecycle
-func TestServiceLifecycle(t *testing.T) {
- // TODO: Add support for Windows and macOS
- if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
- t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS)
- }
-
- if os.Getenv("CONTAINER") == "true" {
- t.Skip("Skipping service lifecycle test in container environment")
- }
-
- originalServiceName := serviceName
- serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix())
- defer func() {
- serviceName = originalServiceName
- }()
-
- tempDir := t.TempDir()
- configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir)
- logLevel = "info"
- daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir)
-
- // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run.
- t.Cleanup(func() {
- cfg, err := newSVCConfig()
- if err != nil {
- t.Errorf("cleanup: create service config: %v", err)
- return
- }
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- if err != nil {
- t.Errorf("cleanup: create service: %v", err)
- return
- }
-
- // If the subtests already cleaned up, there's nothing to do.
- if _, err := s.Status(); err != nil {
- return
- }
-
- if err := s.Stop(); err != nil {
- t.Errorf("cleanup: stop service: %v", err)
- }
- if err := s.Uninstall(); err != nil {
- t.Errorf("cleanup: uninstall service: %v", err)
- }
- })
-
- ctx := context.Background()
-
- t.Run("Install", func(t *testing.T) {
- installCmd.SetContext(ctx)
- err := installCmd.RunE(installCmd, []string{})
- require.NoError(t, err)
-
- cfg, err := newSVCConfig()
- require.NoError(t, err)
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- require.NoError(t, err)
-
- status, err := s.Status()
- assert.NoError(t, err)
- assert.NotEqual(t, service.StatusUnknown, status)
- })
-
- t.Run("Start", func(t *testing.T) {
- startCmd.SetContext(ctx)
- err := startCmd.RunE(startCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Restart", func(t *testing.T) {
- restartCmd.SetContext(ctx)
- err := restartCmd.RunE(restartCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Reconfigure", func(t *testing.T) {
- originalLogLevel := logLevel
- logLevel = "debug"
- defer func() {
- logLevel = originalLogLevel
- }()
-
- reconfigureCmd.SetContext(ctx)
- err := reconfigureCmd.RunE(reconfigureCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Stop", func(t *testing.T) {
- stopCmd.SetContext(ctx)
- err := stopCmd.RunE(stopCmd, []string{})
- require.NoError(t, err)
-
- stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout)
- require.NoError(t, err)
- assert.True(t, stopped)
- })
-
- t.Run("Uninstall", func(t *testing.T) {
- uninstallCmd.SetContext(ctx)
- err := uninstallCmd.RunE(uninstallCmd, []string{})
- require.NoError(t, err)
-
- cfg, err := newSVCConfig()
- require.NoError(t, err)
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- require.NoError(t, err)
-
- _, err = s.Status()
- assert.Error(t, err)
- })
-}
-
// TestServiceEnvVars tests environment variable parsing
func TestServiceEnvVars(t *testing.T) {
tests := []struct {
diff --git a/client/cmd/status.go b/client/cmd/status.go
index 5a7559cf1..f2e5bcc66 100644
--- a/client/cmd/status.go
+++ b/client/cmd/status.go
@@ -6,6 +6,7 @@ import (
"net"
"net/netip"
"strings"
+ "time"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
@@ -115,8 +116,19 @@ func statusFunc(cmd *cobra.Command, args []string) error {
// manager only knows the active profile ID, not its display name.
profName := getActiveProfileName(ctx)
+ var sessionExpiresAt time.Time
+ if ts := resp.GetSessionExpiresAt(); ts.IsValid() {
+ sessionExpiresAt = ts.AsTime().UTC()
+ }
+
+ anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
+ if err != nil {
+ return err
+ }
+
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
- Anonymize: anonymizeFlag,
+ Anonymize: anonymizeEnabled,
+ AnonymizeLevel: anonymizeLevel,
DaemonVersion: resp.GetDaemonVersion(),
DaemonStatus: nbstatus.ParseDaemonStatus(status),
StatusFilter: statusFilter,
@@ -125,6 +137,7 @@ func statusFunc(cmd *cobra.Command, args []string) error {
IPsFilter: ipsFilterMap,
ConnectionTypeFilter: connectionTypeFilter,
ProfileName: profName,
+ SessionExpiresAt: sessionExpiresAt,
})
var statusOutputString string
switch {
diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go
index 205327ef5..f40056f83 100644
--- a/client/cmd/testutil_test.go
+++ b/client/cmd/testutil_test.go
@@ -6,7 +6,7 @@ import (
"testing"
"time"
- "github.com/golang/mock/gomock"
+ "go.uber.org/mock/gomock"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"google.golang.org/grpc"
diff --git a/client/cmd/up.go b/client/cmd/up.go
index 0506bc65b..9f4fa8c33 100644
--- a/client/cmd/up.go
+++ b/client/cmd/up.go
@@ -21,7 +21,9 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
+ nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/util"
@@ -229,6 +231,24 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
+ // Restore residual state left by a previous run that did not shut down
+ // cleanly, mirroring what the daemon does before connecting: it recovers
+ // DNS config (a stale resolv.conf takeover can make the management
+ // hostname unresolvable), firewall rules, ssh config and legacy routing.
+ // Route cleanup itself happens at engine start; nbnet.Init() below lets
+ // the management dial bypass a leftover fwmark rule until then.
+ // Foreground mode is particularly exposed in containers: a crashed
+ // container restarts inside the same (pod) network namespace, so stale
+ // state survives while the process does not.
+ if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configPath).GetStatePath()); err != nil {
+ log.Warnf("failed to restore residual state: %v", err)
+ }
+
+ // Enable advanced routing (as the daemon does on startup) so the
+ // management dial bypasses a leftover fwmark rule instead of being
+ // shunted into a stale routing table.
+ nbnet.Init()
+
err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID)
if err != nil {
return fmt.Errorf("foreground login failed: %v", err)
@@ -305,7 +325,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
} else {
- return fmt.Errorf("call service setConfig method: %v", err)
+ return daemonCallError("call service setConfig method", err)
}
}
@@ -359,7 +379,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
}
if loginErr != nil {
- return fmt.Errorf("login failed: %v", loginErr)
+ return daemonCallError("login failed", loginErr)
}
if loginResp.NeedsSSOLogin {
@@ -372,7 +392,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
ProfileName: &profileID,
Username: &username,
}); err != nil {
- return fmt.Errorf("call service up method: %v", err)
+ return daemonCallError("call service up method", err)
}
return nil
@@ -479,10 +499,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
req.DisableIpv6 = &disableIPv6
}
- if cmd.Flag(enableLazyConnectionFlag).Changed {
- req.LazyConnectionEnabled = &lazyConnEnabled
- }
-
return &req
}
@@ -600,9 +616,6 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
ic.DisableIPv6 = &disableIPv6
}
- if cmd.Flag(enableLazyConnectionFlag).Changed {
- ic.LazyConnectionEnabled = &lazyConnEnabled
- }
return &ic, nil
}
@@ -613,7 +626,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
NatExternalIPs: natExternalIPs,
CleanNATExternalIPs: natExternalIPs != nil && len(natExternalIPs) == 0,
CustomDNSAddress: customDNSAddressConverted,
- IsUnixDesktopClient: isUnixRunningDesktop(),
+ IsUnixDesktopClient: util.HasGraphicalSession(),
Hostname: hostName,
ExtraIFaceBlacklist: extraIFaceBlackList,
DnsLabels: dnsLabels,
@@ -718,9 +731,6 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
loginRequest.DisableIpv6 = &disableIPv6
}
- if cmd.Flag(enableLazyConnectionFlag).Changed {
- loginRequest.LazyConnectionEnabled = &lazyConnEnabled
- }
return &loginRequest, nil
}
diff --git a/client/configs/configs.go b/client/configs/configs.go
index 8f9c3ba28..a1ecf0feb 100644
--- a/client/configs/configs.go
+++ b/client/configs/configs.go
@@ -6,6 +6,11 @@ import (
"runtime"
)
+// UILogFile is the file name the desktop UI writes its log to. It is defined
+// here so the UI (writer), the daemon's RegisterUILog validation, and the debug
+// bundle collector all share one definition.
+const UILogFile = "gui-client.log"
+
var StateDir string
func init() {
diff --git a/client/embed/embed.go b/client/embed/embed.go
index 0e8991be2..079e03c63 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -21,7 +21,7 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
- sshcommon "github.com/netbirdio/netbird/client/ssh"
+ nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
@@ -91,6 +91,13 @@ type Options struct {
// when the embedded client must never act as a stepping stone into
// the host's local network (e.g. the proxy's overlay peer).
BlockLANAccess bool
+ // LazyConnectionEnabled is a tri-state local override for lazy connections,
+ // mirroring the NB_LAZY_CONN env var. Nil defers to the management feature
+ // flag; a set value overrides it in both directions. A short-lived client
+ // that reaches only a few known peers can set this to false, so its peers
+ // connect eagerly and the first request does not wait for the connection to
+ // be established.
+ LazyConnectionEnabled *bool
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
WireguardPort *int
// MTU is the MTU for the tunnel interface.
@@ -220,6 +227,15 @@ func New(opts Options) (*Client, error) {
config.PrivateKey = opts.PrivateKey
}
+ if opts.LazyConnectionEnabled != nil {
+ // Runtime-only override, read back through lazyconn.ParseState; a set value
+ // wins over the management feature flag in both directions.
+ config.LazyConnection = "off"
+ if *opts.LazyConnectionEnabled {
+ config.LazyConnection = "on"
+ }
+ }
+
if opts.Performance.PreallocatedBuffersPerPool != nil {
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
}
@@ -279,9 +295,11 @@ func (c *Client) Start(startCtx context.Context) error {
select {
case <-startCtx.Done():
- // Cancel the client context before stopping: Engine.Start blocks on the
- // signal stream while holding the engine mutex and only unblocks on
- // cancellation. Stopping first would deadlock on that mutex.
+ // ConnectClient.Stop now cancels its own run context and waits for the
+ // run loop to tear the engine down, so this cancel() is no longer
+ // required to break the deadlock and could be removed. It is kept as a
+ // defensive belt-and-suspenders: cancelling the parent context first
+ // guarantees the run loop is unblocked even if Stop's contract regresses.
cancel()
if stopErr := client.Stop(); stopErr != nil {
return fmt.Errorf("stop error after context done. Stop error: %w. Context done: %w", stopErr, startCtx.Err())
@@ -468,7 +486,7 @@ func (c *Client) Status() (peer.FullStatus, error) {
if connect != nil {
engine := connect.Engine()
if engine != nil {
- _ = engine.RunHealthProbes(false)
+ _ = engine.RunHealthProbes(context.Background(), false)
}
}
@@ -519,12 +537,7 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
return err
}
- storedKey, found := engine.GetPeerSSHKey(peerAddress)
- if !found {
- return sshcommon.ErrPeerNotFound
- }
-
- return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
+ return nbssh.PeerKeyLookup(engine.GetPeerSSHKey).VerifySSHHostKey(peerAddress, key)
}
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go
index a2f438975..27beb8934 100644
--- a/client/embed/embed_test.go
+++ b/client/embed/embed_test.go
@@ -6,7 +6,7 @@ import (
"testing"
"time"
- "github.com/golang/mock/gomock"
+ "go.uber.org/mock/gomock"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
diff --git a/client/firewall/firewalld/firewalld.go b/client/firewall/firewalld/firewalld.go
index 188ea61dd..38a4efdbc 100644
--- a/client/firewall/firewalld/firewalld.go
+++ b/client/firewall/firewalld/firewalld.go
@@ -2,8 +2,8 @@
// its wg interface into firewalld's "trusted" zone. This is required because
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
// versions, which returns EPERM to any other process that tries to insert
-// rules into them. The workaround mirrors what Tailscale does: let firewalld
-// itself add the accept rules to its own chains by trusting the interface.
+// rules into them. Trusting the interface makes firewalld itself add the
+// accept rules to its own chains instead.
package firewalld
// TrustedZone is the firewalld zone name used for interfaces whose traffic
diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go
index 4b4cebf9c..89d1ebf7c 100644
--- a/client/firewall/iptables/acl_linux.go
+++ b/client/firewall/iptables/acl_linux.go
@@ -42,6 +42,7 @@ type aclManager struct {
optionalEntries map[string][]entry
ipsetStore *ipsetStore
v6 bool
+ ipsetSupported bool
stateManager *statemanager.Manager
}
@@ -60,6 +61,8 @@ func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*acl
func (m *aclManager) init(stateManager *statemanager.Manager) error {
m.stateManager = stateManager
+ m.ipsetSupported = m.probeIPSetSupport()
+
m.seedInitialEntries()
m.seedInitialOptionalEntries()
@@ -91,6 +94,12 @@ func (m *aclManager) AddPeerFiltering(
if m.v6 && ipsetName != "" {
ipsetName += "-v6"
}
+ // When the kernel lacks the required ipset hash module, fall back to
+ // per-IP iptables rules (pre-0.68 behavior) so ACLs keep working instead
+ // of silently leaving the chain empty.
+ if ipsetName != "" && !m.ipsetSupported {
+ ipsetName = ""
+ }
proto := protoForFamily(protocol, m.v6)
specs := filterRuleSpecs(ip, proto, sPort, dPort, action, ipsetName)
@@ -498,6 +507,40 @@ func transformIPsetName(ipsetName string, sPort, dPort *firewall.Port, action fi
}
}
+// probeIPSetSupport checks whether the kernel can create the ipset type used for
+// ACL rules. On kernels lacking the required ipset hash module, ipset creation
+// fails (e.g. "invalid argument"), which would otherwise leave the ACL chain
+// empty and silently drop all policy-permitted inbound traffic. When unsupported,
+// the manager falls back to per-IP iptables rules.
+func (m *aclManager) probeIPSetSupport() bool {
+ // Use a unique name so concurrent processes don't collide and we only ever
+ // destroy the set we created ourselves. ipset names are limited to 31 chars,
+ // so use a short random suffix.
+ probeName := "nb-probe-" + uuid.New().String()[:8]
+
+ opts := ipset.CreateOptions{
+ Replace: true,
+ }
+ if m.v6 {
+ opts.Family = ipset.FamilyIPV6
+ }
+
+ if err := ipset.Create(probeName, ipset.TypeHashNet, opts); err != nil {
+ log.Warnf("ipset is not available (failed to create probe set: %v); "+
+ "falling back to per-IP iptables ACL rules. Ensure the kernel provides "+
+ "the ipset hash:net module (ip_set_hash_net) for better performance with large rule sets", err)
+ return false
+ }
+
+ defer func() {
+ if err := ipset.Destroy(probeName); err != nil {
+ log.Debugf("destroy ipset probe set %q: %v", probeName, err)
+ }
+ }()
+
+ return true
+}
+
func (m *aclManager) createIPSet(name string) error {
opts := ipset.CreateOptions{
Replace: true,
diff --git a/client/firewall/iptables/dnat_refcount_linux_test.go b/client/firewall/iptables/dnat_refcount_linux_test.go
new file mode 100644
index 000000000..681bc0b99
--- /dev/null
+++ b/client/firewall/iptables/dnat_refcount_linux_test.go
@@ -0,0 +1,240 @@
+//go:build privileged
+
+package iptables
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ fw "github.com/netbirdio/netbird/client/firewall/manager"
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+)
+
+func iptRefcountIfaceV4() *iFaceMock {
+ return &iFaceMock{
+ NameFunc: func() string { return "wt-refcount" },
+ AddressFunc: func() wgaddr.Address {
+ return wgaddr.Address{
+ IP: netip.MustParseAddr("10.20.0.1"),
+ Network: netip.MustParsePrefix("10.20.0.0/24"),
+ }
+ },
+ }
+}
+
+func iptRefcountIfaceDual() *iFaceMock {
+ return &iFaceMock{
+ NameFunc: func() string { return "wt-refcount" },
+ AddressFunc: func() wgaddr.Address {
+ return wgaddr.Address{
+ IP: netip.MustParseAddr("10.20.0.1"),
+ Network: netip.MustParsePrefix("10.20.0.0/24"),
+ IPv6: netip.MustParseAddr("fd00::1"),
+ IPv6Net: netip.MustParsePrefix("fd00::/64"),
+ }
+ },
+ }
+}
+
+func newIptRefcountManager(t *testing.T, dual bool) *Manager {
+ t.Helper()
+ var ifMock *iFaceMock
+ if dual {
+ ifMock = iptRefcountIfaceDual()
+ } else {
+ ifMock = iptRefcountIfaceV4()
+ }
+ m, err := Create(ifMock, iface.DefaultMTU)
+ require.NoError(t, err, "create manager")
+ require.NoError(t, m.Init(nil), "init manager")
+ t.Cleanup(func() {
+ require.NoError(t, m.Close(nil), "close manager")
+ })
+ return m
+}
+
+func iptDnatV4(port uint16) fw.ForwardRule {
+ return fw.ForwardRule{
+ Protocol: fw.ProtocolTCP,
+ DestinationPort: fw.Port{Values: []uint16{port}},
+ TranslatedAddress: netip.MustParseAddr("10.20.0.2"),
+ TranslatedPort: fw.Port{Values: []uint16{80}},
+ }
+}
+
+func iptDnatV6(port uint16) fw.ForwardRule {
+ return fw.ForwardRule{
+ Protocol: fw.ProtocolTCP,
+ DestinationPort: fw.Port{Values: []uint16{port}},
+ TranslatedAddress: netip.MustParseAddr("fd00::2"),
+ TranslatedPort: fw.Port{Values: []uint16{80}},
+ }
+}
+
+// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
+// (called on every network-map update) holds at most one reference per family
+// and a single DisableRouting drops both back to zero.
+func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) {
+ m := newIptRefcountManager(t, true)
+ state := m.router.ipFwdState
+
+ require.NoError(t, m.EnableRouting(), "first enable")
+ require.NoError(t, m.EnableRouting(), "second enable")
+ require.NoError(t, m.EnableRouting(), "third enable")
+ v4, v6 := state.Counts()
+ assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
+ assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
+
+ require.NoError(t, m.DisableRouting(), "disable")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4, "single disable releases the v4 reference")
+ assert.Equal(t, 0, v6, "single disable releases the v6 reference")
+}
+
+// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired
+// DisableRouting does not release references held by active DNAT rules.
+func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) {
+ m := newIptRefcountManager(t, true)
+ state := m.router.ipFwdState
+
+ r1, err := m.AddDNATRule(iptDnatV6(9095))
+ require.NoError(t, err, "add v6 dnat")
+
+ require.NoError(t, m.DisableRouting(), "unpaired disable")
+ _, v6 := state.Counts()
+ assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
+
+ require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
+ _, v6 = state.Counts()
+ assert.Equal(t, 0, v6, "delete releases the DNAT reference")
+}
+
+// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4.
+func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) {
+ m := newIptRefcountManager(t, false)
+ state := m.router.ipFwdState
+
+ r1, err := m.AddDNATRule(iptDnatV4(7081))
+ require.NoError(t, err, "add v4 dnat 1")
+ v4, v6 := state.Counts()
+ assert.Equal(t, 1, v4, "v4 refcount after first add")
+ assert.Equal(t, 0, v6, "v6 refcount unchanged")
+
+ r2, err := m.AddDNATRule(iptDnatV4(7082))
+ require.NoError(t, err, "add v4 dnat 2")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 2, v4, "v4 refcount after second add")
+ assert.Equal(t, 0, v6, "v6 refcount unchanged")
+
+ require.NoError(t, m.DeleteDNATRule(r1))
+ v4, v6 = state.Counts()
+ assert.Equal(t, 1, v4, "v4 refcount after first delete")
+ assert.Equal(t, 0, v6, "v6 refcount unchanged")
+
+ require.NoError(t, m.DeleteDNATRule(r2))
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4, "v4 refcount after second delete")
+ assert.Equal(t, 0, v6, "v6 refcount unchanged")
+}
+
+// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and
+// decrements back to zero.
+func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) {
+ m := newIptRefcountManager(t, true)
+ require.NotNil(t, m.router6, "v6 router")
+ require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state")
+ state := m.router.ipFwdState
+
+ r1, err := m.AddDNATRule(iptDnatV6(9081))
+ require.NoError(t, err, "add v6 dnat 1")
+ v4, v6 := state.Counts()
+ assert.Equal(t, 0, v4)
+ assert.Equal(t, 1, v6, "v6 refcount after first add")
+
+ r2, err := m.AddDNATRule(iptDnatV6(9082))
+ require.NoError(t, err, "add v6 dnat 2")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4, "v4 refcount unchanged")
+ assert.Equal(t, 2, v6, "v6 refcount after second add")
+
+ require.NoError(t, m.DeleteDNATRule(r1))
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4, "v4 refcount unchanged")
+ assert.Equal(t, 1, v6, "v6 refcount after first delete")
+
+ require.NoError(t, m.DeleteDNATRule(r2))
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4)
+ assert.Equal(t, 0, v6, "v6 refcount after second delete")
+}
+
+// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns
+// without bumping the refcount.
+func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) {
+ m := newIptRefcountManager(t, true)
+ state := m.router.ipFwdState
+
+ rule := iptDnatV4(7083)
+ r1, err := m.AddDNATRule(rule)
+ require.NoError(t, err)
+ v4, _ := state.Counts()
+ assert.Equal(t, 1, v4)
+
+ _, err = m.AddDNATRule(rule)
+ require.NoError(t, err, "duplicate add")
+ v4, _ = state.Counts()
+ assert.Equal(t, 1, v4, "duplicate add must not increment")
+
+ require.NoError(t, m.DeleteDNATRule(r1))
+ v4, _ = state.Counts()
+ assert.Equal(t, 0, v4, "single delete must drop to zero")
+}
+
+// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule
+// neither errors nor releases the refcount.
+func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
+ m := newIptRefcountManager(t, true)
+ state := m.router.ipFwdState
+
+ phantom := iptDnatV4(7099)
+ require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4")
+ v4, v6 := state.Counts()
+ assert.Equal(t, 0, v4)
+ assert.Equal(t, 0, v6)
+
+ phantom6 := iptDnatV6(9099)
+ require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4)
+ assert.Equal(t, 0, v6)
+
+ r1, err := m.AddDNATRule(iptDnatV4(7100))
+ require.NoError(t, err)
+ v4, _ = state.Counts()
+ assert.Equal(t, 1, v4, "real add still increments after phantom delete")
+ require.NoError(t, m.DeleteDNATRule(r1))
+}
+
+// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same
+// rule is a no-op.
+func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
+ m := newIptRefcountManager(t, true)
+ state := m.router.ipFwdState
+
+ r1, err := m.AddDNATRule(iptDnatV6(9083))
+ require.NoError(t, err)
+ _, v6 := state.Counts()
+ assert.Equal(t, 1, v6)
+
+ require.NoError(t, m.DeleteDNATRule(r1), "first delete")
+ _, v6 = state.Counts()
+ assert.Equal(t, 0, v6)
+
+ require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
+ _, v6 = state.Counts()
+ assert.Equal(t, 0, v6, "double delete must not underflow")
+}
diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go
index 696537dd8..aa052d933 100644
--- a/client/firewall/iptables/manager_linux.go
+++ b/client/firewall/iptables/manager_linux.go
@@ -89,7 +89,7 @@ func (m *Manager) createIPv6Components(wgIface iFaceMapper, mtu uint16) error {
}
// Share the same IP forwarding state with the v4 router, since
- // EnableIPForwarding controls both v4 and v6 sysctls.
+ // Forwarding refcounter is per-family but shared between v4 and v6 routers.
m.router6.ipFwdState = m.router.ipFwdState
m.aclMgr6, err = newAclManager(ip6Client, wgIface)
@@ -402,17 +402,12 @@ func (m *Manager) SetLogLevel(log.Level) {
}
func (m *Manager) EnableRouting() error {
- if err := m.router.ipFwdState.RequestForwarding(); err != nil {
- return fmt.Errorf("enable IP forwarding: %w", err)
- }
- return nil
+ // v6 only when the overlay actually has v6.
+ return m.router.ipFwdState.RequestRouting(m.router6 != nil)
}
func (m *Manager) DisableRouting() error {
- if err := m.router.ipFwdState.ReleaseForwarding(); err != nil {
- return fmt.Errorf("disable IP forwarding: %w", err)
- }
- return nil
+ return m.router.ipFwdState.ReleaseRouting()
}
// AddDNATRule adds a DNAT rule
diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go
index cc4bda0e0..2c3c1a08e 100644
--- a/client/firewall/iptables/manager_linux_test.go
+++ b/client/firewall/iptables/manager_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package iptables
import (
@@ -289,3 +291,40 @@ func TestIptablesCreatePerformance(t *testing.T) {
})
}
}
+
+// TestIptablesACLIPSetFallback verifies that when the kernel lacks ipset support,
+// the ACL manager falls back to per-IP iptables rules (-s ) instead of
+// silently leaving the chain empty. See discussion #6125.
+func TestIptablesACLIPSetFallback(t *testing.T) {
+ ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
+ require.NoError(t, err)
+
+ // Use Create()/Init() so the router-owned chains (chainRTFWDIN/OUT) are
+ // created before the ACL manager's createDefaultChains() references them.
+ manager, err := Create(ifaceMock, iface.DefaultMTU)
+ require.NoError(t, err)
+ require.NoError(t, manager.Init(nil))
+
+ aclMgr := manager.aclMgr
+ // Simulate a kernel without the ipset hash module.
+ aclMgr.ipsetSupported = false
+
+ defer func() {
+ require.NoError(t, manager.Close(nil))
+ }()
+
+ ip := netip.MustParseAddr("10.20.0.42")
+ port := &fw.Port{Values: []uint16{22}}
+
+ rules, err := aclMgr.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "nb0000001")
+ require.NoError(t, err, "AddPeerFiltering should succeed via fallback")
+ require.NotEmpty(t, rules)
+
+ rule := rules[0].(*Rule)
+ require.Empty(t, rule.ipsetName, "fallback rule must not reference an ipset")
+ require.Contains(t, strings.Join(rule.specs, " "), "-s 10.20.0.42", "fallback rule must match by source IP")
+ require.NotContains(t, strings.Join(rule.specs, " "), "--match-set", "fallback rule must not use ipset matching")
+
+ // The rule must actually be present in the ACL chain (not silently dropped).
+ checkRuleSpecs(t, ipv4Client, rule.chain, true, rule.specs...)
+}
diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go
index 42d305f5c..01b18570c 100644
--- a/client/firewall/iptables/router_linux.go
+++ b/client/firewall/iptables/router_linux.go
@@ -102,7 +102,7 @@ func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint1
wgIface: wgIface,
mtu: mtu,
v6: iptablesClient.Proto() == iptables.ProtocolIPv6,
- ipFwdState: ipfwdstate.NewIPForwardingState(),
+ ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()),
}
r.ipsetCounter = refcounter.New(
@@ -770,10 +770,6 @@ func (r *router) updateState() {
}
func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
- if err := r.ipFwdState.RequestForwarding(); err != nil {
- return nil, err
- }
-
ruleKey := rule.ID()
if _, exists := r.rules[ruleKey+dnatSuffix]; exists {
return rule, nil
@@ -840,18 +836,34 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
for key, ruleInfo := range rules {
if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil {
- if rollbackErr := r.rollbackRules(rules); rollbackErr != nil {
- log.Errorf("rollback failed: %v", rollbackErr)
- }
+ r.cleanupFailedDNATAdd(rules)
return nil, fmt.Errorf("add rule %s: %w", key, err)
}
r.rules[key] = ruleInfo.rule
}
+ if err := r.ipFwdState.RequestForwarding(r.v6); err != nil {
+ r.cleanupFailedDNATAdd(rules)
+ return nil, fmt.Errorf("enable forwarding: %w", err)
+ }
+
r.updateState()
return rule, nil
}
+// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied
+// AddDNATRule before rolling back the kernel rules, so no entries remain that
+// never got a forwarding refcount. rollbackRules re-adds entries it failed to
+// remove from the kernel.
+func (r *router) cleanupFailedDNATAdd(rules map[string]ruleInfo) {
+ for key := range rules {
+ delete(r.rules, key)
+ }
+ if err := r.rollbackRules(rules); err != nil {
+ log.Errorf("rollback failed: %v", err)
+ }
+}
+
func (r *router) rollbackRules(rules map[string]ruleInfo) error {
var merr *multierror.Error
for key, ruleInfo := range rules {
@@ -868,32 +880,47 @@ func (r *router) rollbackRules(rules map[string]ruleInfo) error {
}
func (r *router) DeleteDNATRule(rule firewall.Rule) error {
- if err := r.ipFwdState.ReleaseForwarding(); err != nil {
- log.Errorf("%v", err)
- }
-
ruleKey := rule.ID()
+ _, hadDNAT := r.rules[ruleKey+dnatSuffix]
+ _, hadSNAT := r.rules[ruleKey+snatSuffix]
+ _, hadFWD := r.rules[ruleKey+fwdSuffix]
+ if !hadDNAT && !hadSNAT && !hadFWD {
+ return nil
+ }
+
var merr *multierror.Error
if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists {
if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil {
merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err))
+ } else {
+ delete(r.rules, ruleKey+dnatSuffix)
}
- delete(r.rules, ruleKey+dnatSuffix)
}
if snatRule, exists := r.rules[ruleKey+snatSuffix]; exists {
if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil {
merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err))
+ } else {
+ delete(r.rules, ruleKey+snatSuffix)
}
- delete(r.rules, ruleKey+snatSuffix)
}
if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists {
if err := r.iptablesClient.Delete(tableFilter, chainRTFWDOUT, fwdRule...); err != nil {
merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err))
+ } else {
+ delete(r.rules, ruleKey+fwdSuffix)
+ }
+ }
+
+ // Release the refcount only once all rules are gone from the kernel. On
+ // partial failure the failed entries stay in r.rules so a retry can remove
+ // them and release then.
+ if merr == nil {
+ if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil {
+ log.Errorf("%v", err)
}
- delete(r.rules, ruleKey+fwdSuffix)
}
r.updateState()
diff --git a/client/firewall/iptables/router_linux_test.go b/client/firewall/iptables/router_linux_test.go
index 6707573be..9ca6b9f7e 100644
--- a/client/firewall/iptables/router_linux_test.go
+++ b/client/firewall/iptables/router_linux_test.go
@@ -1,4 +1,4 @@
-//go:build !android
+//go:build !android && privileged
package iptables
diff --git a/client/firewall/nftables/dnat_refcount_linux_test.go b/client/firewall/nftables/dnat_refcount_linux_test.go
new file mode 100644
index 000000000..86079676f
--- /dev/null
+++ b/client/firewall/nftables/dnat_refcount_linux_test.go
@@ -0,0 +1,249 @@
+//go:build privileged
+
+package nftables
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ fw "github.com/netbirdio/netbird/client/firewall/manager"
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+)
+
+func nftRefcountIfaceV4() *iFaceMock {
+ return &iFaceMock{
+ NameFunc: func() string { return "wt-refcount" },
+ AddressFunc: func() wgaddr.Address {
+ return wgaddr.Address{
+ IP: netip.MustParseAddr("100.96.0.1"),
+ Network: netip.MustParsePrefix("100.96.0.0/16"),
+ }
+ },
+ }
+}
+
+func nftRefcountIfaceDual() *iFaceMock {
+ return &iFaceMock{
+ NameFunc: func() string { return "wt-refcount" },
+ AddressFunc: func() wgaddr.Address {
+ return wgaddr.Address{
+ IP: netip.MustParseAddr("100.96.0.1"),
+ Network: netip.MustParsePrefix("100.96.0.0/16"),
+ IPv6: netip.MustParseAddr("fd00::1"),
+ IPv6Net: netip.MustParsePrefix("fd00::/64"),
+ }
+ },
+ }
+}
+
+func newNftRefcountManager(t *testing.T, dual bool) *Manager {
+ t.Helper()
+ if check() != NFTABLES {
+ t.Skip("nftables not supported on this system")
+ }
+ var ifMock *iFaceMock
+ if dual {
+ ifMock = nftRefcountIfaceDual()
+ } else {
+ ifMock = nftRefcountIfaceV4()
+ }
+ m, err := Create(ifMock, iface.DefaultMTU)
+ require.NoError(t, err, "create manager")
+ require.NoError(t, m.Init(nil), "init manager")
+ t.Cleanup(func() {
+ require.NoError(t, m.Close(nil), "close manager")
+ })
+ return m
+}
+
+func dnatV4(port uint16) fw.ForwardRule {
+ return fw.ForwardRule{
+ Protocol: fw.ProtocolTCP,
+ DestinationPort: fw.Port{Values: []uint16{port}},
+ TranslatedAddress: netip.MustParseAddr("100.96.0.2"),
+ TranslatedPort: fw.Port{Values: []uint16{80}},
+ }
+}
+
+func dnatV6(port uint16) fw.ForwardRule {
+ return fw.ForwardRule{
+ Protocol: fw.ProtocolTCP,
+ DestinationPort: fw.Port{Values: []uint16{port}},
+ TranslatedAddress: netip.MustParseAddr("fd00::2"),
+ TranslatedPort: fw.Port{Values: []uint16{80}},
+ }
+}
+
+// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the
+// v4 refcount at zero.
+func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) {
+ m := newNftRefcountManager(t, false)
+ state := m.router.ipFwdState
+
+ r1, err := m.AddDNATRule(dnatV4(8081))
+ require.NoError(t, err, "add v4 dnat 1")
+ v4, v6 := state.Counts()
+ assert.Equal(t, 1, v4, "v4 refcount after first add")
+ assert.Equal(t, 0, v6, "v6 refcount unchanged")
+
+ r2, err := m.AddDNATRule(dnatV4(8082))
+ require.NoError(t, err, "add v4 dnat 2")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 2, v4, "v4 refcount after second add")
+ assert.Equal(t, 0, v6, "v6 refcount unchanged")
+
+ require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 1, v4, "v4 refcount after first delete")
+ assert.Equal(t, 0, v6, "v6 refcount unchanged")
+
+ require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4, "v4 refcount after second delete")
+ assert.Equal(t, 0, v6, "v6 refcount unchanged")
+}
+
+// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only
+// and decrements back to zero on Delete.
+func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) {
+ m := newNftRefcountManager(t, true)
+ require.NotNil(t, m.router6, "v6 router")
+ require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state")
+ state := m.router.ipFwdState
+
+ r1, err := m.AddDNATRule(dnatV6(9091))
+ require.NoError(t, err, "add v6 dnat 1")
+ v4, v6 := state.Counts()
+ assert.Equal(t, 0, v4, "v4 refcount unchanged")
+ assert.Equal(t, 1, v6, "v6 refcount after first add")
+
+ r2, err := m.AddDNATRule(dnatV6(9092))
+ require.NoError(t, err, "add v6 dnat 2")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4)
+ assert.Equal(t, 2, v6, "v6 refcount after second add")
+
+ require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4, "v4 refcount unchanged")
+ assert.Equal(t, 1, v6, "v6 refcount after first delete")
+
+ require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4)
+ assert.Equal(t, 0, v6, "v6 refcount after second delete")
+}
+
+// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same
+// ForwardRule) does not double-increment the refcount.
+func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) {
+ m := newNftRefcountManager(t, true)
+ state := m.router.ipFwdState
+
+ rule := dnatV4(8083)
+ r1, err := m.AddDNATRule(rule)
+ require.NoError(t, err, "add v4 dnat")
+ v4, _ := state.Counts()
+ assert.Equal(t, 1, v4)
+
+ // duplicate add: same rule ID, must be a no-op for the refcount.
+ _, err = m.AddDNATRule(rule)
+ require.NoError(t, err, "duplicate add")
+ v4, _ = state.Counts()
+ assert.Equal(t, 1, v4, "duplicate add must not increment")
+
+ require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat")
+ v4, _ = state.Counts()
+ assert.Equal(t, 0, v4, "single delete must drop to zero")
+}
+
+// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was
+// never added does not underflow the refcount.
+func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
+ m := newNftRefcountManager(t, true)
+ state := m.router.ipFwdState
+
+ // Construct a Rule reference for something never added. The router stores
+ // rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing
+ // entry must be a no-op rather than calling Release.
+ phantom := dnatV4(8099)
+ require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat")
+ v4, v6 := state.Counts()
+ assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete")
+ assert.Equal(t, 0, v6, "v6 refcount unaffected")
+
+ phantom6 := dnatV6(9099)
+ require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4)
+ assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete")
+
+ // And after a phantom delete, a real add still results in count=1.
+ r1, err := m.AddDNATRule(dnatV4(8100))
+ require.NoError(t, err, "add v4 dnat after phantom delete")
+ v4, _ = state.Counts()
+ assert.Equal(t, 1, v4, "real add still increments after phantom delete")
+ require.NoError(t, m.DeleteDNATRule(r1))
+}
+
+// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
+// (called on every network-map update) holds at most one reference per family
+// and a single DisableRouting drops both back to zero.
+func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) {
+ m := newNftRefcountManager(t, true)
+ state := m.router.ipFwdState
+
+ require.NoError(t, m.EnableRouting(), "first enable")
+ require.NoError(t, m.EnableRouting(), "second enable")
+ require.NoError(t, m.EnableRouting(), "third enable")
+ v4, v6 := state.Counts()
+ assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
+ assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
+
+ require.NoError(t, m.DisableRouting(), "disable")
+ v4, v6 = state.Counts()
+ assert.Equal(t, 0, v4, "single disable releases the v4 reference")
+ assert.Equal(t, 0, v6, "single disable releases the v6 reference")
+}
+
+// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired
+// DisableRouting does not release references held by active DNAT rules.
+func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) {
+ m := newNftRefcountManager(t, true)
+ state := m.router.ipFwdState
+
+ r1, err := m.AddDNATRule(dnatV6(9095))
+ require.NoError(t, err, "add v6 dnat")
+
+ require.NoError(t, m.DisableRouting(), "unpaired disable")
+ _, v6 := state.Counts()
+ assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
+
+ require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
+ _, v6 = state.Counts()
+ assert.Equal(t, 0, v6, "delete releases the DNAT reference")
+}
+
+// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule
+// twice does not underflow the refcount (the second delete is a no-op).
+func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
+ m := newNftRefcountManager(t, true)
+ state := m.router.ipFwdState
+
+ r1, err := m.AddDNATRule(dnatV6(9093))
+ require.NoError(t, err)
+ _, v6 := state.Counts()
+ assert.Equal(t, 1, v6)
+
+ require.NoError(t, m.DeleteDNATRule(r1), "first delete")
+ _, v6 = state.Counts()
+ assert.Equal(t, 0, v6)
+
+ require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
+ _, v6 = state.Counts()
+ assert.Equal(t, 0, v6, "double delete must not underflow")
+}
diff --git a/client/firewall/nftables/legacy_rule_linux_test.go b/client/firewall/nftables/legacy_rule_linux_test.go
new file mode 100644
index 000000000..dc2f1c7a0
--- /dev/null
+++ b/client/firewall/nftables/legacy_rule_linux_test.go
@@ -0,0 +1,60 @@
+package nftables
+
+import (
+ "testing"
+
+ "github.com/google/nftables/expr"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildLegacyRouteRuleExpressions(t *testing.T) {
+ sourcePayload := &expr.Payload{}
+ sourceCmp := &expr.Cmp{}
+ destinationPayload := &expr.Payload{}
+ destinationCmp := &expr.Cmp{}
+ nilSourceDestination := &expr.Payload{}
+ nilDestinationSource := &expr.Cmp{}
+
+ tests := []struct {
+ name string
+ source []expr.Any
+ destination []expr.Any
+ matches []expr.Any
+ }{
+ {
+ name: "both non-empty",
+ source: []expr.Any{sourcePayload, sourceCmp},
+ destination: []expr.Any{destinationPayload, destinationCmp},
+ matches: []expr.Any{sourcePayload, sourceCmp, destinationPayload, destinationCmp},
+ },
+ {
+ name: "nil source",
+ destination: []expr.Any{nilSourceDestination},
+ matches: []expr.Any{nilSourceDestination},
+ },
+ {
+ name: "nil destination",
+ source: []expr.Any{nilDestinationSource},
+ matches: []expr.Any{nilDestinationSource},
+ },
+ {
+ name: "both nil",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := buildLegacyRouteRuleExpressions(tt.source, tt.destination)
+
+ require.Len(t, got, len(tt.matches)+2)
+ for i, match := range tt.matches {
+ require.Same(t, match, got[i])
+ }
+
+ require.IsType(t, &expr.Counter{}, got[len(tt.matches)])
+ verdict, ok := got[len(tt.matches)+1].(*expr.Verdict)
+ require.True(t, ok)
+ require.Equal(t, expr.VerdictAccept, verdict.Kind)
+ })
+ }
+}
diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go
index fdc7c2f3c..984b1c3ba 100644
--- a/client/firewall/nftables/manager_linux.go
+++ b/client/firewall/nftables/manager_linux.go
@@ -105,8 +105,8 @@ func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mt
return fmt.Errorf("create v6 router: %w", err)
}
- // Share the same IP forwarding state with the v4 router, since
- // EnableIPForwarding controls both v4 and v6 sysctls.
+ // Share the per-family forwarding refcounter with the v4 router so a v4
+ // rule and a v6 rule against the same state machine cooperate cleanly.
m.router6.ipFwdState = m.router.ipFwdState
m.aclManager6, err = newAclManager(workTable6, wgIface, chainNameRoutingFw)
@@ -530,17 +530,12 @@ func (m *Manager) SetLogLevel(log.Level) {
}
func (m *Manager) EnableRouting() error {
- if err := m.router.ipFwdState.RequestForwarding(); err != nil {
- return fmt.Errorf("enable IP forwarding: %w", err)
- }
- return nil
+ // v6 only when the overlay actually has v6.
+ return m.router.ipFwdState.RequestRouting(m.router6 != nil)
}
func (m *Manager) DisableRouting() error {
- if err := m.router.ipFwdState.ReleaseForwarding(); err != nil {
- return fmt.Errorf("disable IP forwarding: %w", err)
- }
- return nil
+ return m.router.ipFwdState.ReleaseRouting()
}
// Flush rule/chain/set operations from the buffer
diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go
index be4f65881..4eb466281 100644
--- a/client/firewall/nftables/manager_linux_test.go
+++ b/client/firewall/nftables/manager_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package nftables
import (
diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go
index 4214455a9..d3e031c5f 100644
--- a/client/firewall/nftables/router_linux.go
+++ b/client/firewall/nftables/router_linux.go
@@ -93,7 +93,7 @@ func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*rou
rules: make(map[string]*nftables.Rule),
af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4),
wgIface: wgIface,
- ipFwdState: ipfwdstate.NewIPForwardingState(),
+ ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()),
mtu: mtu,
}
@@ -953,6 +953,17 @@ func (r *router) addMSSClampingRules() error {
return r.conn.Flush()
}
+func buildLegacyRouteRuleExpressions(sourceExp, destExp []expr.Any) []expr.Any {
+ exprs := make([]expr.Any, 0, len(sourceExp)+len(destExp)+2)
+ exprs = append(exprs, sourceExp...)
+ exprs = append(exprs, destExp...)
+ exprs = append(exprs,
+ &expr.Counter{},
+ &expr.Verdict{Kind: expr.VerdictAccept},
+ )
+ return exprs
+}
+
// addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls
func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error {
sourceExp, err := r.applyNetwork(pair.Source, nil, true)
@@ -965,15 +976,7 @@ func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error {
return fmt.Errorf("apply destination: %w", err)
}
- exprs := []expr.Any{
- &expr.Counter{},
- &expr.Verdict{
- Kind: expr.VerdictAccept,
- },
- }
-
- exprs = append(exprs, sourceExp...)
- exprs = append(exprs, destExp...)
+ exprs := buildLegacyRouteRuleExpressions(sourceExp, destExp)
ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair)
@@ -1550,10 +1553,6 @@ func (r *router) refreshRulesMap() error {
}
func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
- if err := r.ipFwdState.RequestForwarding(); err != nil {
- return nil, err
- }
-
ruleKey := rule.ID()
if _, exists := r.rules[ruleKey+dnatSuffix]; exists {
return rule, nil
@@ -1564,7 +1563,18 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
return nil, fmt.Errorf("convert protocol to number: %w", err)
}
+ // Request forwarding before queueing rules: addDnatRedirect/addDnatMasq
+ // buffer netlink messages on r.conn that the next caller's Flush would
+ // commit if we returned without flushing them ourselves.
+ v6 := r.af.tableFamily == nftables.TableFamilyIPv6
+ if err := r.ipFwdState.RequestForwarding(v6); err != nil {
+ return nil, fmt.Errorf("enable forwarding: %w", err)
+ }
+
if err := r.addDnatRedirect(rule, protoNum, ruleKey); err != nil {
+ if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil {
+ log.Warnf("rollback forwarding refcount: %v", rerr)
+ }
return nil, err
}
@@ -1576,6 +1586,11 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
// TODO: find chains with drop policies and add rules there
if err := r.conn.Flush(); err != nil {
+ if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil {
+ log.Warnf("rollback forwarding refcount: %v", rerr)
+ }
+ delete(r.rules, ruleKey+dnatSuffix)
+ delete(r.rules, ruleKey+snatSuffix)
return nil, fmt.Errorf("flush rules: %w", err)
}
@@ -1778,16 +1793,18 @@ func (r *router) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleKey
}
func (r *router) DeleteDNATRule(rule firewall.Rule) error {
- if err := r.ipFwdState.ReleaseForwarding(); err != nil {
- log.Errorf("%v", err)
- }
-
ruleKey := rule.ID()
if err := r.refreshRulesMap(); err != nil {
return fmt.Errorf(refreshRulesMapError, err)
}
+ _, hadDNAT := r.rules[ruleKey+dnatSuffix]
+ _, hadSNAT := r.rules[ruleKey+snatSuffix]
+ if !hadDNAT && !hadSNAT {
+ return nil
+ }
+
var merr *multierror.Error
var needsFlush bool
@@ -1819,9 +1836,16 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error {
}
}
+ // Release the refcount only once the rules are gone from the kernel. On
+ // failure (including the refreshRulesMap error above) the rules and their
+ // map entries remain, keeping forwarding on until a retry removes them.
if merr == nil {
delete(r.rules, ruleKey+dnatSuffix)
delete(r.rules, ruleKey+snatSuffix)
+
+ if err := r.ipFwdState.ReleaseForwarding(r.af.tableFamily == nftables.TableFamilyIPv6); err != nil {
+ log.Errorf("%v", err)
+ }
}
return nberrors.FormatErrorOrNil(merr)
diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go
index c5d6729d9..2fc664d51 100644
--- a/client/firewall/nftables/router_linux_test.go
+++ b/client/firewall/nftables/router_linux_test.go
@@ -1,4 +1,4 @@
-//go:build !android
+//go:build !android && privileged
package nftables
diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go
index 91866dcab..7376e59ca 100644
--- a/client/firewall/uspfilter/filter.go
+++ b/client/firewall/uspfilter/filter.go
@@ -121,6 +121,7 @@ type Manager struct {
udpTracker *conntrack.UDPTracker
icmpTracker *conntrack.ICMPTracker
tcpTracker *conntrack.TCPTracker
+ fragments *fragmentTracker
forwarder atomic.Pointer[forwarder.Forwarder]
pendingCapture atomic.Pointer[forwarder.PacketCapture]
logger *nblog.Logger
@@ -183,6 +184,41 @@ func (d *decoder) decodePacket(data []byte) error {
}
}
+// decodeTransport decodes the transport header of a first fragment (which
+// gopacket leaves undecoded) into the decoder and appends its layer type to
+// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns
+// false if the protocol is unsupported or the header is truncated.
+func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool {
+ var l4 gopacket.DecodingLayer
+ var layerType gopacket.LayerType
+ var minLen int
+ switch proto {
+ case layers.IPProtocolTCP:
+ l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20
+ case layers.IPProtocolUDP:
+ l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8
+ case layers.IPProtocolICMPv4:
+ l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8
+ case layers.IPProtocolICMPv6:
+ l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8
+ default:
+ return false
+ }
+
+ // Reject a fragment too small to hold the full transport header before
+ // decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping
+ // the decode avoids gopacket allocating an error on the drop path.
+ if len(payload) < minLen {
+ return false
+ }
+
+ if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil {
+ return false
+ }
+ d.decoded = append(d.decoded, layerType)
+ return true
+}
+
// Create userspace firewall manager constructor
func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) {
return create(iface, nil, disableServerRoutes, flowLogger, mtu)
@@ -286,6 +322,8 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
if err := m.localipmanager.UpdateLocalIPs(iface); err != nil {
return nil, fmt.Errorf("update local IPs: %w", err)
}
+ m.fragments = newFragmentTracker(m.logger)
+
if disableConntrack {
log.Info("conntrack is disabled")
} else {
@@ -299,6 +337,7 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
}
}
if err := iface.SetFilter(m); err != nil {
+ m.fragments.Close()
return nil, fmt.Errorf("set filter: %w", err)
}
return m, nil
@@ -694,6 +733,10 @@ func (m *Manager) resetState() {
m.tcpTracker.Close()
}
+ if m.fragments != nil {
+ m.fragments.Close()
+ }
+
if fwder := m.forwarder.Load(); fwder != nil {
fwder.SetCapture(nil)
fwder.Stop()
@@ -1046,19 +1089,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
return true
}
- // TODO: pass fragments of routed packets to forwarder
+ // gopacket does not decode the transport header of any IP fragment, so
+ // fragments take a dedicated path: the first fragment's header is decoded
+ // and ACL-evaluated here, and the remaining fragments inherit its verdict.
if fragment {
- if m.logger.Enabled(nblog.LevelTrace) {
- if d.decoded[0] == layers.LayerTypeIPv4 {
- m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
- srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
- } else {
- m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
- }
- }
- return false
+ return m.filterInboundFragment(d, srcIP, dstIP, size)
}
+ return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size)
+}
+
+// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully
+// decoded (non-fragment) inbound packet. It returns true if the packet should
+// be dropped.
+func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
// TODO: optimize port DNAT by caching matched rules in conntrack
if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated {
// Re-decode after port DNAT translation to update port information
@@ -1089,33 +1133,226 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size)
}
+// fragmentMeta holds the reassembly identity and layout of an IP fragment,
+// extracted uniformly for IPv4 and IPv6.
+type fragmentMeta struct {
+ key fragmentKey
+ // offset is the fragment offset in 8-byte units (zero for the first
+ // fragment).
+ offset uint16
+ // moreFragments is the More Fragments bit. A first fragment with it unset is
+ // an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing
+ // fragments to inherit a verdict, so it must not be recorded.
+ moreFragments bool
+ proto layers.IPProtocol
+ // l4payload is the fragmentable payload of this fragment. For the first
+ // fragment it starts with the transport header.
+ l4payload []byte
+ // headerEndOctets is the first fragment's payload length in 8-byte units:
+ // the smallest offset a trailing fragment may start at without overlapping
+ // the inspected transport header.
+ headerEndOctets uint16
+}
+
+// fragmentMetadata extracts the fragment identity and layout from a decoded IP
+// fragment. It returns false for fragments it can't interpret (e.g. an IPv6
+// fragment header shorter than 8 bytes), which are then dropped.
+func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) {
+ switch d.decoded[0] {
+ case layers.LayerTypeIPv4:
+ payload := d.ip4.Payload
+ return fragmentMeta{
+ key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)},
+ offset: d.ip4.FragOffset,
+ moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0,
+ proto: d.ip4.Protocol,
+ l4payload: payload,
+ headerEndOctets: octets(len(payload)),
+ }, true
+
+ case layers.LayerTypeIPv6:
+ // IPv6 fragment extension header: 8 bytes, followed by the fragmentable
+ // payload. Layout: next header (1), reserved (1), offset+flags (2), id (4).
+ payload := d.ip6.Payload
+ if len(payload) < 8 {
+ return fragmentMeta{}, false
+ }
+ nextHeader := layers.IPProtocol(payload[0])
+ offsetFlags := binary.BigEndian.Uint16(payload[2:4])
+ id := binary.BigEndian.Uint32(payload[4:8])
+ l4 := payload[8:]
+ return fragmentMeta{
+ key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)},
+ offset: offsetFlags >> 3,
+ moreFragments: offsetFlags&1 != 0,
+ proto: nextHeader,
+ l4payload: l4,
+ headerEndOctets: octets(len(l4)),
+ }, true
+
+ default:
+ return fragmentMeta{}, false
+ }
+}
+
+// octets rounds a byte length up to whole 8-byte units, the granularity of the
+// IP fragment offset field.
+func octets(nbytes int) uint16 {
+ return uint16((nbytes + 7) / 8)
+}
+
+// filterInboundFragment decides the fate of an IP fragment. gopacket stops
+// decoding at the network layer for every fragment, so the first fragment's
+// transport header is decoded and ACL-evaluated here and its verdict recorded;
+// the remaining (headerless) fragments inherit that verdict. Anything that
+// cannot be tied to an allowed, non-overlapping first fragment is dropped.
+func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool {
+ meta, ok := fragmentMetadata(d, srcIP, dstIP)
+ if !ok {
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP)
+ }
+ return true
+ }
+
+ if meta.offset != 0 {
+ return m.filterTrailingFragment(meta, srcIP, dstIP)
+ }
+
+ // A new first fragment supersedes any recorded verdict for this datagram, so
+ // a re-sent or overlapping offset-zero fragment can't inherit the old one.
+ m.fragments.poison(meta.key)
+
+ // First fragment: decode its transport header so the ACL can evaluate it. A
+ // decode failure means the fragment is too small to hold the full transport
+ // header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it.
+ if !d.decodeTransport(meta.proto, meta.l4payload) {
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v",
+ srcIP, dstIP, meta.key.id)
+ }
+ return true
+ }
+
+ return m.filterFirstFragment(d, meta, srcIP, dstIP, size)
+}
+
+// filterTrailingFragment applies a recorded first-fragment verdict to a
+// non-first fragment.
+func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool {
+ switch m.fragments.verdict(meta.key, meta.offset) {
+ case fragmentAllow:
+ return false
+ case fragmentOverlap:
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v",
+ srcIP, dstIP, meta.key.id)
+ }
+ return true
+ default:
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v",
+ srcIP, dstIP, meta.key.id)
+ }
+ return true
+ }
+}
+
+// filterFirstFragment runs the verdict part of the inbound pipeline on a first
+// fragment with its transport header decoded. It mirrors filterInboundDecoded
+// but skips DNAT (port rewriting on fragments is unsupported) and forwarder
+// injection (fragments are left to the stack to reassemble, not forwarded).
+// Allowed fragments have their verdict recorded so the datagram's trailing
+// fragments inherit it.
+func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool {
+ if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) {
+ m.recordFirstFragment(meta)
+ return false
+ }
+
+ if m.localipmanager.IsLocalIP(dstIP) {
+ ruleID, blocked := m.peerACLsBlock(srcIP, d, nil)
+ if blocked {
+ m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
+ d, srcIP, dstIP, ruleID, size)
+ return true
+ }
+ m.trackInbound(d, srcIP, dstIP, ruleID, size)
+ m.recordFirstFragment(meta)
+ return false
+ }
+
+ if !m.routingEnabled.Load() {
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP)
+ }
+ return true
+ }
+ if m.nativeRouter.Load() {
+ m.trackInbound(d, srcIP, dstIP, nil, size)
+ m.recordFirstFragment(meta)
+ return false
+ }
+
+ // TODO: pass fragments of routed packets to the forwarder; until then
+ // allowed routed fragments go to the native stack.
+ srcPort, dstPort := getPortsFromPacket(d)
+ ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort)
+ if !pass {
+ m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
+ d, srcIP, dstIP, ruleID, size)
+ return true
+ }
+
+ m.recordFirstFragment(meta)
+ return false
+}
+
+// recordFirstFragment caches an allowed first fragment's verdict for its
+// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are
+// complete datagrams with no trailing fragments, so they are not cached and
+// cannot exhaust the verdict table.
+func (m *Manager) recordFirstFragment(meta fragmentMeta) {
+ if !meta.moreFragments {
+ return
+ }
+ m.fragments.recordAllowed(meta.key, meta.headerEndOctets)
+}
+
+// storeDropFlow logs and records a netflow drop event for an inbound packet
+// denied by the ACLs. msg is the trace format taking rule id, protocol, source
+// and destination.
+func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) {
+ pnum := getProtocolFromPacket(d)
+ srcPort, dstPort := getPortsFromPacket(d)
+
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
+ }
+
+ m.flowLogger.StoreEvent(nftypes.EventFields{
+ FlowID: uuid.New(),
+ Type: nftypes.TypeDrop,
+ RuleID: ruleID,
+ Direction: nftypes.Ingress,
+ Protocol: pnum,
+ SourceIP: srcIP,
+ DestIP: dstIP,
+ SourcePort: srcPort,
+ DestPort: dstPort,
+ // TODO: icmp type/code
+ RxPackets: 1,
+ RxBytes: uint64(size),
+ })
+}
+
// handleLocalTraffic handles local traffic.
// If it returns true, the packet should be dropped.
func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData)
if blocked {
- pnum := getProtocolFromPacket(d)
- srcPort, dstPort := getPortsFromPacket(d)
-
- if m.logger.Enabled(nblog.LevelTrace) {
- m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
- ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
- }
-
- m.flowLogger.StoreEvent(nftypes.EventFields{
- FlowID: uuid.New(),
- Type: nftypes.TypeDrop,
- RuleID: ruleID,
- Direction: nftypes.Ingress,
- Protocol: pnum,
- SourceIP: srcIP,
- DestIP: dstIP,
- SourcePort: srcPort,
- DestPort: dstPort,
- // TODO: icmp type/code
- RxPackets: 1,
- RxBytes: uint64(size),
- })
+ m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
+ d, srcIP, dstIP, ruleID, size)
return true
}
@@ -1168,27 +1405,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe
ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort)
if !pass {
- proto := getProtocolFromPacket(d)
-
- if m.logger.Enabled(nblog.LevelTrace) {
- m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
- ruleID, proto, srcIP, srcPort, dstIP, dstPort)
- }
-
- m.flowLogger.StoreEvent(nftypes.EventFields{
- FlowID: uuid.New(),
- Type: nftypes.TypeDrop,
- RuleID: ruleID,
- Direction: nftypes.Ingress,
- Protocol: proto,
- SourceIP: srcIP,
- DestIP: dstIP,
- SourcePort: srcPort,
- DestPort: dstPort,
- // TODO: icmp type/code
- RxPackets: 1,
- RxBytes: uint64(size),
- })
+ m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
+ d, srcIP, dstIP, ruleID, size)
return true
}
diff --git a/client/firewall/uspfilter/filter_filter_test.go b/client/firewall/uspfilter/filter_filter_test.go
index a64c83138..5ca8538be 100644
--- a/client/firewall/uspfilter/filter_filter_test.go
+++ b/client/firewall/uspfilter/filter_filter_test.go
@@ -5,7 +5,7 @@ import (
"net/netip"
"testing"
- "github.com/golang/mock/gomock"
+ "go.uber.org/mock/gomock"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/stretchr/testify/require"
diff --git a/client/firewall/uspfilter/filter_routeacl_test.go b/client/firewall/uspfilter/filter_routeacl_test.go
index 449554d8b..b6397d09b 100644
--- a/client/firewall/uspfilter/filter_routeacl_test.go
+++ b/client/firewall/uspfilter/filter_routeacl_test.go
@@ -4,7 +4,7 @@ import (
"net/netip"
"testing"
- "github.com/golang/mock/gomock"
+ "go.uber.org/mock/gomock"
"github.com/google/gopacket/layers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go
index 6291eb285..28320ad88 100644
--- a/client/firewall/uspfilter/forwarder/forwarder.go
+++ b/client/firewall/uspfilter/forwarder/forwarder.go
@@ -5,7 +5,9 @@ import (
"fmt"
"net"
"net/netip"
+ "os"
"runtime"
+ "strconv"
"sync"
"time"
@@ -31,6 +33,11 @@ const (
defaultMaxInFlight = 1024
iosReceiveWindow = 16384
iosMaxInFlight = 256
+
+ // envForceTCPRACK overrides the platform default for gVisor's RACK loss
+ // detection. Set to a truthy value to force RACK on, or a falsy value to
+ // force it off, on any platform.
+ envForceTCPRACK = "NB_FORCE_TCP_RACK"
)
type Forwarder struct {
@@ -152,6 +159,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
maxInFlight = iosMaxInFlight
}
+ configureTCPRecovery(s)
+
tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP)
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
@@ -466,3 +475,31 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool {
logger.Debug1("forwarder: raw %s socket access available", network)
return true
}
+
+// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where
+// it interacts poorly with the host and collapses throughput on routed TCP
+// connections (gVisor issue #9778). Other platforms keep the default. The
+// EnvForceTCPRACK environment variable overrides the platform default.
+func configureTCPRecovery(s *stack.Stack) {
+ disableRACK := runtime.GOOS == "windows"
+
+ if val := os.Getenv(envForceTCPRACK); val != "" {
+ force, err := strconv.ParseBool(val)
+ if err != nil {
+ log.Warnf("parse %s: %v", envForceTCPRACK, err)
+ } else {
+ disableRACK = !force
+ }
+ }
+
+ if !disableRACK {
+ return
+ }
+
+ opt := tcpip.TCPRecovery(0)
+ if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil {
+ log.Warnf("disable TCP RACK loss detection: %v", err)
+ return
+ }
+ log.Info("forwarder: TCP RACK loss detection disabled")
+}
diff --git a/client/firewall/uspfilter/fragment.go b/client/firewall/uspfilter/fragment.go
new file mode 100644
index 000000000..accc54365
--- /dev/null
+++ b/client/firewall/uspfilter/fragment.go
@@ -0,0 +1,204 @@
+package uspfilter
+
+import (
+ "context"
+ "net/netip"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+
+ nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
+)
+
+const (
+ // defaultFragmentTimeout bounds how long a first-fragment verdict is kept
+ // while the remaining fragments arrive. It mirrors the Linux IP reassembly
+ // timeout (net.ipv4.ipfrag_time).
+ defaultFragmentTimeout = 30 * time.Second
+ // fragmentCleanupInterval is how often expired verdicts are purged.
+ fragmentCleanupInterval = 10 * time.Second
+ // defaultMaxFragmentEntries caps the number of concurrently tracked
+ // fragmented datagrams. The table stays bounded because each datagram is a
+ // single small entry regardless of how many fragments it is split into, and
+ // the 13-bit IPv4 fragment-offset field limits any datagram to 64 KiB.
+ defaultMaxFragmentEntries = 16384
+
+ // EnvFragmentMaxEntries overrides defaultMaxFragmentEntries.
+ EnvFragmentMaxEntries = "NB_FRAGMENT_MAX_ENTRIES"
+)
+
+// fragmentVerdict is the decision for a trailing (headerless) fragment.
+type fragmentVerdict int
+
+const (
+ // fragmentDeny drops the fragment: no allowed first fragment is on record.
+ fragmentDeny fragmentVerdict = iota
+ // fragmentAllow passes the fragment: it belongs to an allowed datagram and
+ // does not overlap the already-inspected transport header.
+ fragmentAllow
+ // fragmentOverlap drops the fragment and poisons its datagram: it overlaps
+ // the transport header the ACL inspected (RFC 1858 §4, RFC 3128; RFC 5722
+ // requires discarding the whole datagram on overlap for IPv6).
+ fragmentOverlap
+)
+
+// fragmentKey identifies a fragmented datagram. It matches the RFC 791 / RFC
+// 8200 reassembly key: source, destination, protocol and identification. The id
+// is 32-bit to hold both the IPv4 (16-bit) and IPv6 (32-bit) identification.
+type fragmentKey struct {
+ srcIP netip.Addr
+ dstIP netip.Addr
+ id uint32
+ proto uint8
+}
+
+// fragmentEntry records the verdict of an allowed first fragment.
+type fragmentEntry struct {
+ // headerEndOctets is the offset, in 8-byte units, at which the first
+ // fragment's payload ended. A trailing fragment starting before this
+ // overlaps bytes the ACL already inspected and is rejected.
+ headerEndOctets uint16
+ // recordedAt is when the first fragment was accepted. The verdict expires a
+ // fixed timeout later and is not refreshed, mirroring the kernel reassembly
+ // timer so a trailing-fragment flood can't keep a datagram alive.
+ recordedAt time.Time
+}
+
+// fragmentTracker records the ACL verdict of a datagram's first fragment so the
+// remaining fragments, which carry no L4 header, can inherit the decision
+// without reassembling the datagram. Only allowed first fragments are stored;
+// anything that cannot be tied to an allowed, non-overlapping first fragment is
+// dropped (fail closed).
+type fragmentTracker struct {
+ logger *nblog.Logger
+ mutex sync.Mutex
+ entries map[fragmentKey]fragmentEntry
+ timeout time.Duration
+ // maxEntries caps the table; atCapacity dedups the capacity warning until
+ // the table drains below the cap again.
+ maxEntries int
+ atCapacity bool
+ cleanupTicker *time.Ticker
+ cancel context.CancelFunc
+}
+
+func newFragmentTracker(logger *nblog.Logger) *fragmentTracker {
+ ctx, cancel := context.WithCancel(context.Background())
+ t := &fragmentTracker{
+ logger: logger,
+ entries: make(map[fragmentKey]fragmentEntry),
+ timeout: defaultFragmentTimeout,
+ maxEntries: fragmentMaxEntries(logger),
+ cleanupTicker: time.NewTicker(fragmentCleanupInterval),
+ cancel: cancel,
+ }
+ go t.cleanupRoutine(ctx)
+ return t
+}
+
+func fragmentMaxEntries(logger *nblog.Logger) int {
+ v := os.Getenv(EnvFragmentMaxEntries)
+ if v == "" {
+ return defaultMaxFragmentEntries
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil || n <= 0 {
+ logger.Warn2("invalid %s=%q, using default", EnvFragmentMaxEntries, v)
+ return defaultMaxFragmentEntries
+ }
+ return n
+}
+
+// recordAllowed stores the verdict of an allowed first fragment. headerEndOctets
+// is the first fragment's payload length in 8-byte units. When the table is full
+// the record is dropped, which fails closed: the datagram's trailing fragments
+// will be denied.
+func (t *fragmentTracker) recordAllowed(key fragmentKey, headerEndOctets uint16) {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+
+ if t.entries == nil {
+ return
+ }
+ if _, ok := t.entries[key]; !ok && len(t.entries) >= t.maxEntries {
+ if !t.atCapacity {
+ t.atCapacity = true
+ t.logger.Warn2("fragment verdict table at capacity (%d/%d): trailing fragments of new datagrams will be dropped",
+ len(t.entries), t.maxEntries)
+ }
+ return
+ }
+ t.entries[key] = fragmentEntry{
+ headerEndOctets: headerEndOctets,
+ recordedAt: time.Now(),
+ }
+}
+
+// poison drops any recorded verdict for a datagram, so its later fragments are
+// denied until a new allowed first fragment is recorded. Called on every
+// offset-zero fragment to defeat offset-zero overlap rewrites (RFC 3128).
+func (t *fragmentTracker) poison(key fragmentKey) {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+ delete(t.entries, key)
+}
+
+// verdict decides the fate of a trailing fragment at fragOffsetOctets (the IPv4
+// fragment offset, in 8-byte units). A fragment overlapping the inspected
+// header poisons the datagram: the entry is removed so all further fragments of
+// that datagram are denied too.
+func (t *fragmentTracker) verdict(key fragmentKey, fragOffsetOctets uint16) fragmentVerdict {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+
+ entry, ok := t.entries[key]
+ if !ok {
+ return fragmentDeny
+ }
+ if time.Since(entry.recordedAt) > t.timeout {
+ delete(t.entries, key)
+ return fragmentDeny
+ }
+ if fragOffsetOctets < entry.headerEndOctets {
+ delete(t.entries, key)
+ return fragmentOverlap
+ }
+ return fragmentAllow
+}
+
+func (t *fragmentTracker) cleanupRoutine(ctx context.Context) {
+ defer t.cleanupTicker.Stop()
+ for {
+ select {
+ case <-t.cleanupTicker.C:
+ t.cleanup()
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+func (t *fragmentTracker) cleanup() {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+
+ for key, entry := range t.entries {
+ if time.Since(entry.recordedAt) > t.timeout {
+ delete(t.entries, key)
+ }
+ }
+
+ if len(t.entries) < t.maxEntries {
+ t.atCapacity = false
+ }
+}
+
+// Close stops the cleanup routine and releases resources.
+func (t *fragmentTracker) Close() {
+ t.cancel()
+
+ t.mutex.Lock()
+ t.entries = nil
+ t.mutex.Unlock()
+}
diff --git a/client/firewall/uspfilter/fragment_bench_test.go b/client/firewall/uspfilter/fragment_bench_test.go
new file mode 100644
index 000000000..a9e6d2d13
--- /dev/null
+++ b/client/firewall/uspfilter/fragment_bench_test.go
@@ -0,0 +1,115 @@
+package uspfilter
+
+import (
+ "encoding/binary"
+ "testing"
+)
+
+// benchFilterInbound drives filterInbound over a fixed packet in a tight loop.
+// Packets are built once, outside the timed region, so the benchmark measures
+// only pipeline cost, which is what an attacker can amplify.
+func benchFilterInbound(b *testing.B, pkt []byte) {
+ b.Helper()
+ b.ReportAllocs()
+ b.SetBytes(int64(len(pkt)))
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ m := benchManager
+ m.filterInbound(pkt, len(pkt))
+ }
+}
+
+// benchManager is a package-level manager reused across fragment benchmarks so
+// setup cost stays out of the timed region.
+var benchManager *Manager
+
+func setupBenchManager(b *testing.B) *Manager {
+ b.Helper()
+ m := newFragmentTestManager(b)
+ allowUDP(b, m, 8080)
+ // Disable conntrack so the allowed-first-fragment path measures transport
+ // decode + ACL every iteration instead of matching the connection tracked
+ // on the first iteration.
+ m.stateful = false
+ benchManager = m
+ return m
+}
+
+// BenchmarkInbound_NormalPacket is the baseline: a full, non-fragmented UDP
+// packet that passes the ACL. Fragment paths should stay comparable to this.
+func BenchmarkInbound_NormalPacket(b *testing.B) {
+ setupBenchManager(b)
+ pkt := normalUDPPacket(b, 8080, 32)
+ benchFilterInbound(b, pkt)
+}
+
+// BenchmarkInbound_FirstFragmentAllowed measures the first-fragment path:
+// transport decode + ACL evaluation + verdict record.
+func BenchmarkInbound_FirstFragmentAllowed(b *testing.B) {
+ setupBenchManager(b)
+ pkt := firstFragmentUDP(b, 0x2000, 8080, 32)
+ benchFilterInbound(b, pkt)
+}
+
+// BenchmarkInbound_TrailingFragmentAllowed measures the common trailing-fragment
+// path: a single map lookup after the first fragment is on record.
+func BenchmarkInbound_TrailingFragmentAllowed(b *testing.B) {
+ m := setupBenchManager(b)
+ first := firstFragmentUDP(b, 0x3000, 8080, 32)
+ m.filterInbound(first, len(first))
+ pkt := trailingFragment(b, 0x3000, 5, false, 24)
+ benchFilterInbound(b, pkt)
+}
+
+// BenchmarkInbound_TrailingFragmentNoFirst is the primary DoS vector: an
+// attacker floods trailing fragments with no first fragment on record. Each is
+// a map miss and must be cheap.
+func BenchmarkInbound_TrailingFragmentNoFirst(b *testing.B) {
+ setupBenchManager(b)
+ pkt := trailingFragment(b, 0x4000, 185, false, 40)
+ benchFilterInbound(b, pkt)
+}
+
+// BenchmarkInbound_TinyFirstFragment measures the tiny-fragment drop path: a
+// first fragment too small to decode a transport header.
+func BenchmarkInbound_TinyFirstFragment(b *testing.B) {
+ setupBenchManager(b)
+ pkt := trailingFragment(b, 0x5000, 0, true, 4)
+ benchFilterInbound(b, pkt)
+}
+
+// BenchmarkInbound_TrailingFragmentDistinctIDs is the worst case for the
+// verdict table: an attacker varies the datagram id on every packet so no first
+// fragment ever matches. Verdict lookups always miss and nothing is recorded,
+// so the table cannot grow. Each iteration rewrites the id field in place.
+func BenchmarkInbound_TrailingFragmentDistinctIDs(b *testing.B) {
+ setupBenchManager(b)
+ pkt := trailingFragment(b, 0x6000, 185, false, 40)
+ m := benchManager
+
+ b.ReportAllocs()
+ b.SetBytes(int64(len(pkt)))
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ // IPv4 identification field is at bytes 4:6.
+ binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
+ m.filterInbound(pkt, len(pkt))
+ }
+}
+
+// BenchmarkInbound_FirstFragmentDistinctIDs measures sustained first-fragment
+// pressure with distinct ids: transport decode + ACL + verdict insert until the
+// table caps, exercising the map growth and capacity guard.
+func BenchmarkInbound_FirstFragmentDistinctIDs(b *testing.B) {
+ setupBenchManager(b)
+ pkt := firstFragmentUDP(b, 0x7000, 8080, 32)
+ m := benchManager
+
+ b.ReportAllocs()
+ b.SetBytes(int64(len(pkt)))
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
+ m.filterInbound(pkt, len(pkt))
+ }
+}
diff --git a/client/firewall/uspfilter/fragment_test.go b/client/firewall/uspfilter/fragment_test.go
new file mode 100644
index 000000000..6960e4dda
--- /dev/null
+++ b/client/firewall/uspfilter/fragment_test.go
@@ -0,0 +1,554 @@
+package uspfilter
+
+import (
+ "encoding/binary"
+ "net"
+ "net/netip"
+ "testing"
+ "time"
+
+ "github.com/google/gopacket"
+ "github.com/google/gopacket/layers"
+ "github.com/stretchr/testify/require"
+
+ fw "github.com/netbirdio/netbird/client/firewall/manager"
+ nbiface "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/iface/device"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+)
+
+const (
+ fragTestSrc = "100.10.0.1"
+ fragTestDst = "100.10.0.100"
+ fragTestSrcV6 = "fd00::1"
+ fragTestDstV6 = "fd00::100"
+)
+
+func newFragmentTestManager(tb testing.TB) *Manager {
+ tb.Helper()
+
+ ifaceMock := &IFaceMock{
+ SetFilterFunc: func(device.PacketFilter) error { return nil },
+ AddressFunc: func() wgaddr.Address {
+ return wgaddr.Address{
+ IP: netip.MustParseAddr(fragTestDst),
+ Network: netip.MustParsePrefix("100.10.0.0/16"),
+ IPv6: netip.MustParseAddr(fragTestDstV6),
+ IPv6Net: netip.MustParsePrefix("fd00::/64"),
+ }
+ },
+ }
+
+ m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU)
+ require.NoError(tb, err)
+ require.NoError(tb, m.UpdateLocalIPs())
+ tb.Cleanup(func() { require.NoError(tb, m.Close(nil)) })
+ return m
+}
+
+// firstFragmentUDPTo builds the first fragment of a fragmented UDP datagram to
+// the given destination: it carries the full UDP header plus payloadLen bytes
+// of data, with the More Fragments flag set and offset zero.
+func firstFragmentUDPTo(tb testing.TB, dst string, id uint16, dstPort uint16, payloadLen int) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv4{
+ Version: 4,
+ TTL: 64,
+ Id: id,
+ Protocol: layers.IPProtocolUDP,
+ SrcIP: net.ParseIP(fragTestSrc),
+ DstIP: net.ParseIP(dst),
+ Flags: layers.IPv4MoreFragments,
+ }
+ udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
+ require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
+
+ buf := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
+ require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
+ return buf.Bytes()
+}
+
+func firstFragmentUDP(tb testing.TB, id uint16, dstPort uint16, payloadLen int) []byte {
+ tb.Helper()
+ return firstFragmentUDPTo(tb, fragTestDst, id, dstPort, payloadLen)
+}
+
+// firstFragmentTCP builds the first fragment of a fragmented TCP datagram: the
+// full 20-byte TCP header plus 12 bytes of data, with the More Fragments flag
+// set and offset zero.
+func firstFragmentTCP(tb testing.TB, id uint16, dstPort uint16) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv4{
+ Version: 4,
+ TTL: 64,
+ Id: id,
+ Protocol: layers.IPProtocolTCP,
+ SrcIP: net.ParseIP(fragTestSrc),
+ DstIP: net.ParseIP(fragTestDst),
+ Flags: layers.IPv4MoreFragments,
+ }
+ tcp := &layers.TCP{SrcPort: 40000, DstPort: layers.TCPPort(dstPort), SYN: true, Window: 64240}
+ require.NoError(tb, tcp.SetNetworkLayerForChecksum(ip))
+
+ buf := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
+ require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(make([]byte, 12))))
+ return buf.Bytes()
+}
+
+// trailingFragmentTo builds a non-first fragment to the given destination: an
+// IPv4 header at the given fragment offset (in 8-byte units) carrying raw
+// payload and no L4 header.
+func trailingFragmentTo(tb testing.TB, dst string, proto layers.IPProtocol, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv4{
+ Version: 4,
+ TTL: 64,
+ Id: id,
+ Protocol: proto,
+ SrcIP: net.ParseIP(fragTestSrc),
+ DstIP: net.ParseIP(dst),
+ FragOffset: fragOffsetOctets,
+ }
+ if moreFragments {
+ ip.Flags = layers.IPv4MoreFragments
+ }
+
+ buf := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{FixLengths: true}
+ require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, gopacket.Payload(make([]byte, payloadLen))))
+ return buf.Bytes()
+}
+
+func trailingFragment(tb testing.TB, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
+ tb.Helper()
+ return trailingFragmentTo(tb, fragTestDst, layers.IPProtocolUDP, id, fragOffsetOctets, moreFragments, payloadLen)
+}
+
+// outboundUDPPacket builds a complete outbound UDP packet from the local
+// address, used to establish conntrack state for reply-direction tests.
+func outboundUDPPacket(tb testing.TB, srcPort, dstPort uint16) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv4{
+ Version: 4,
+ TTL: 64,
+ Id: 1,
+ Protocol: layers.IPProtocolUDP,
+ SrcIP: net.ParseIP(fragTestDst),
+ DstIP: net.ParseIP(fragTestSrc),
+ }
+ udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)}
+ require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
+
+ buf := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
+ require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, 16))))
+ return buf.Bytes()
+}
+
+// normalUDPPacket builds a complete, non-fragmented UDP packet for baseline
+// comparisons against the fragment paths.
+func normalUDPPacket(tb testing.TB, dstPort uint16, payloadLen int) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv4{
+ Version: 4,
+ TTL: 64,
+ Id: 1,
+ Protocol: layers.IPProtocolUDP,
+ SrcIP: net.ParseIP(fragTestSrc),
+ DstIP: net.ParseIP(fragTestDst),
+ }
+ udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
+ require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
+
+ buf := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
+ require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
+ return buf.Bytes()
+}
+
+func allowUDP(tb testing.TB, m *Manager, dstPort uint16) {
+ tb.Helper()
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolUDP, nil,
+ &fw.Port{Values: []uint16{dstPort}}, fw.ActionAccept, "")
+ require.NoError(tb, err)
+}
+
+// TestFragment_TrailingWithoutFirstDropped is the core bypass repro: a trailing
+// fragment with no allowed first fragment on record must be dropped. Before the
+// fix, filterInbound returned false (allow) for any fragment.
+func TestFragment_TrailingWithoutFirstDropped(t *testing.T) {
+ m := newFragmentTestManager(t)
+
+ frag := trailingFragment(t, 0x1234, 185, false, 40)
+ require.True(t, m.filterInbound(frag, len(frag)),
+ "trailing fragment without an allowed first fragment must be dropped")
+}
+
+// TestFragment_AllowedFirstPassesTrailing verifies that once a first fragment
+// passes the ACL, its trailing fragments inherit the allow verdict.
+func TestFragment_AllowedFirstPassesTrailing(t *testing.T) {
+ m := newFragmentTestManager(t)
+ allowUDP(t, m, 8080)
+
+ // First fragment: UDP header (8) + 32 payload = 40 octets -> headerEnd = 5.
+ first := firstFragmentUDP(t, 0x2222, 8080, 32)
+ require.False(t, m.filterInbound(first, len(first)),
+ "allowed first fragment should pass and be recorded")
+
+ trailing := trailingFragment(t, 0x2222, 5, false, 24)
+ require.False(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of an allowed datagram should pass")
+}
+
+// TestFragment_DeniedFirstDropsTrailing verifies that a first fragment blocked
+// by the ACL leaves no verdict, so its trailing fragments are dropped.
+func TestFragment_DeniedFirstDropsTrailing(t *testing.T) {
+ m := newFragmentTestManager(t)
+ // No accept rule: local traffic defaults to deny.
+
+ first := firstFragmentUDP(t, 0x3333, 9999, 32)
+ require.True(t, m.filterInbound(first, len(first)),
+ "first fragment to a blocked port should be dropped by the ACL")
+
+ trailing := trailingFragment(t, 0x3333, 5, false, 24)
+ require.True(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of a denied datagram must be dropped")
+}
+
+// TestFragment_OverlappingHeaderDropped covers the RFC 1858 §4 / RFC 3128
+// overlapping-fragment rewrite: a trailing fragment starting inside the range
+// the ACL already inspected is dropped and poisons the datagram. TCP is used so
+// the overlap lands on real header bytes (the flags at byte 13).
+func TestFragment_OverlappingHeaderDropped(t *testing.T) {
+ m := newFragmentTestManager(t)
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
+ &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
+ require.NoError(t, err)
+
+ // First fragment: TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
+ first := firstFragmentTCP(t, 0x4444, 8080)
+ require.False(t, m.filterInbound(first, len(first)))
+
+ // Overlapping fragment at offset 1 (byte 8) falls inside the inspected TCP
+ // header, so it could rewrite the flags or port on reassembly.
+ overlap := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 1, true, 32)
+ require.True(t, m.filterInbound(overlap, len(overlap)),
+ "fragment overlapping the inspected header must be dropped")
+
+ // The datagram is now poisoned: a later, non-overlapping fragment is also
+ // dropped because the verdict was removed.
+ later := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 4, false, 24)
+ require.True(t, m.filterInbound(later, len(later)),
+ "fragments after an overlap must be dropped (datagram poisoned)")
+}
+
+// TestFragment_OffsetZeroOverlapPoisons covers the RFC 3128 offset-zero rewrite:
+// an allowed first fragment followed by a denied offset-zero fragment for the
+// same datagram must not leave the earlier allow verdict in place.
+func TestFragment_OffsetZeroOverlapPoisons(t *testing.T) {
+ m := newFragmentTestManager(t)
+ allowUDP(t, m, 8080)
+
+ allowed := firstFragmentUDP(t, 0x5A5A, 8080, 32)
+ require.False(t, m.filterInbound(allowed, len(allowed)),
+ "allowed first fragment should pass and be recorded")
+
+ // A second offset-zero fragment to a denied port supersedes the datagram's
+ // verdict; it is dropped and must not leave the allow in place.
+ denied := firstFragmentUDP(t, 0x5A5A, 9999, 32)
+ require.True(t, m.filterInbound(denied, len(denied)),
+ "denied offset-zero fragment must be dropped")
+
+ trailing := trailingFragment(t, 0x5A5A, 5, false, 24)
+ require.True(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment must be denied after the datagram was poisoned")
+}
+
+// TestFragment_TinyFirstDropped covers the tiny-fragment attack: a first
+// fragment too small to contain the full transport header can't be
+// ACL-evaluated and must be dropped.
+func TestFragment_TinyFirstDropped(t *testing.T) {
+ m := newFragmentTestManager(t)
+ allowUDP(t, m, 8080)
+
+ // IPv4 header + 4 raw bytes, MF set, offset 0: too small for the 8-byte UDP
+ // header, so it decodes to L3 only.
+ tiny := trailingFragment(t, 0x5555, 0, true, 4)
+ require.True(t, m.filterInbound(tiny, len(tiny)),
+ "tiny first fragment without a full L4 header must be dropped")
+}
+
+// TestFragment_TCPFirstFragment verifies the TCP arm of the transport decode: a
+// first fragment carrying the full 20-byte TCP header is ACL-evaluated and its
+// trailing fragments inherit the verdict.
+func TestFragment_TCPFirstFragment(t *testing.T) {
+ m := newFragmentTestManager(t)
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
+ &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
+ require.NoError(t, err)
+
+ // TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
+ first := firstFragmentTCP(t, 0x6666, 8080)
+ require.False(t, m.filterInbound(first, len(first)),
+ "allowed TCP first fragment should pass and be recorded")
+
+ trailing := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x6666, 4, false, 24)
+ require.False(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of an allowed TCP datagram should pass")
+}
+
+// TestFragment_TCPTinyFirstDropped verifies the TCP minimum header length: 12
+// bytes would satisfy a UDP header but falls short of the 20-byte TCP header.
+func TestFragment_TCPTinyFirstDropped(t *testing.T) {
+ m := newFragmentTestManager(t)
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
+ &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
+ require.NoError(t, err)
+
+ tiny := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x7777, 0, true, 12)
+ require.True(t, m.filterInbound(tiny, len(tiny)),
+ "first fragment shorter than the TCP header must be dropped")
+}
+
+// TestFragment_ConntrackAllowsFirstFragment verifies the conntrack branch: reply
+// fragments of an outbound-established UDP flow pass without any inbound rule.
+func TestFragment_ConntrackAllowsFirstFragment(t *testing.T) {
+ m := newFragmentTestManager(t)
+
+ out := outboundUDPPacket(t, 12345, 40000)
+ require.False(t, m.filterOutbound(out, len(out)))
+
+ first := firstFragmentUDP(t, 0x8888, 12345, 32)
+ require.False(t, m.filterInbound(first, len(first)),
+ "reply first fragment should pass via conntrack")
+
+ trailing := trailingFragment(t, 0x8888, 5, false, 24)
+ require.False(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of a tracked flow should pass")
+}
+
+// TestFragment_RoutingDisabledDropsFragment verifies routed first fragments are
+// dropped when routing is disabled.
+func TestFragment_RoutingDisabledDropsFragment(t *testing.T) {
+ m := newFragmentTestManager(t)
+ m.routingEnabled.Store(false)
+
+ first := firstFragmentUDPTo(t, "198.51.100.10", 0x9999, 8080, 32)
+ require.True(t, m.filterInbound(first, len(first)),
+ "routed first fragment must be dropped when routing is disabled")
+}
+
+// TestFragment_RouteACL verifies the route-ACL branch: fragments to a non-local
+// destination follow the route rules, allowed datagrams pass their trailing
+// fragments and denied ones don't.
+func TestFragment_RouteACL(t *testing.T) {
+ m := newFragmentTestManager(t)
+ m.routingEnabled.Store(true)
+ m.nativeRouter.Store(false)
+
+ _, err := m.AddRouteFiltering(
+ []byte("rt-1"),
+ []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")},
+ fw.Network{Prefix: netip.MustParsePrefix("198.51.100.0/24")},
+ fw.ProtocolUDP,
+ nil,
+ &fw.Port{Values: []uint16{8080}},
+ fw.ActionAccept,
+ )
+ require.NoError(t, err)
+
+ first := firstFragmentUDPTo(t, "198.51.100.10", 0xAAAA, 8080, 32)
+ require.False(t, m.filterInbound(first, len(first)),
+ "route-ACL-allowed first fragment should pass")
+ trailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xAAAA, 5, false, 24)
+ require.False(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of an allowed routed datagram should pass")
+
+ denied := firstFragmentUDPTo(t, "198.51.100.10", 0xBBBB, 9999, 32)
+ require.True(t, m.filterInbound(denied, len(denied)),
+ "route-ACL-denied first fragment must be dropped")
+ deniedTrailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xBBBB, 5, false, 24)
+ require.True(t, m.filterInbound(deniedTrailing, len(deniedTrailing)),
+ "trailing fragment of a denied routed datagram must be dropped")
+}
+
+// TestFragment_ExpiredVerdictDropsTrailing verifies a verdict older than the
+// tracker timeout no longer admits trailing fragments.
+func TestFragment_ExpiredVerdictDropsTrailing(t *testing.T) {
+ m := newFragmentTestManager(t)
+ allowUDP(t, m, 8080)
+
+ first := firstFragmentUDP(t, 0xCCCC, 8080, 32)
+ require.False(t, m.filterInbound(first, len(first)))
+
+ m.fragments.mutex.Lock()
+ for key, entry := range m.fragments.entries {
+ entry.recordedAt = time.Now().Add(-defaultFragmentTimeout - time.Second)
+ m.fragments.entries[key] = entry
+ }
+ m.fragments.mutex.Unlock()
+
+ trailing := trailingFragment(t, 0xCCCC, 5, false, 24)
+ require.True(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment after verdict expiry must be dropped")
+}
+
+// TestFragment_CapacityFailsClosed verifies the table cap: at capacity, new
+// datagram verdicts are not recorded (their trailing fragments are dropped)
+// while already-recorded datagrams keep working.
+func TestFragment_CapacityFailsClosed(t *testing.T) {
+ m := newFragmentTestManager(t)
+ allowUDP(t, m, 8080)
+
+ m.fragments.mutex.Lock()
+ m.fragments.maxEntries = 1
+ m.fragments.mutex.Unlock()
+
+ first1 := firstFragmentUDP(t, 0x0101, 8080, 32)
+ require.False(t, m.filterInbound(first1, len(first1)))
+
+ first2 := firstFragmentUDP(t, 0x0202, 8080, 32)
+ require.False(t, m.filterInbound(first2, len(first2)),
+ "first fragment itself still passes at capacity")
+
+ trailing2 := trailingFragment(t, 0x0202, 5, false, 24)
+ require.True(t, m.filterInbound(trailing2, len(trailing2)),
+ "trailing fragment of an unrecorded datagram must be dropped at capacity")
+
+ trailing1 := trailingFragment(t, 0x0101, 5, false, 24)
+ require.False(t, m.filterInbound(trailing1, len(trailing1)),
+ "already-recorded datagram should keep passing at capacity")
+}
+
+// v6FragmentHeader builds the 8-byte IPv6 fragment extension header for the
+// given inner protocol, offset (8-byte units), More Fragments bit and id.
+func v6FragmentHeader(proto layers.IPProtocol, offsetOctets uint16, moreFragments bool, id uint32) []byte {
+ offsetFlags := offsetOctets << 3
+ if moreFragments {
+ offsetFlags |= 1
+ }
+ hdr := make([]byte, 8)
+ hdr[0] = uint8(proto)
+ binary.BigEndian.PutUint16(hdr[2:4], offsetFlags)
+ binary.BigEndian.PutUint32(hdr[4:8], id)
+ return hdr
+}
+
+func v6UDPHeader(dstPort uint16, dataLen int) []byte {
+ hdr := make([]byte, 8)
+ binary.BigEndian.PutUint16(hdr[0:2], 40000)
+ binary.BigEndian.PutUint16(hdr[2:4], dstPort)
+ binary.BigEndian.PutUint16(hdr[4:6], uint16(8+dataLen))
+ return hdr
+}
+
+// firstFragmentUDPv6 builds the first fragment of a fragmented IPv6 UDP
+// datagram: fragment header (offset 0, More Fragments set) + full UDP header +
+// data.
+func firstFragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int) []byte {
+ tb.Helper()
+ return fragmentUDPv6(tb, id, dstPort, dataLen, true)
+}
+
+// fragmentUDPv6 builds an offset-zero IPv6 UDP fragment. With moreFragments
+// false it is an atomic fragment (a complete datagram, RFC 6946).
+func fragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int, moreFragments bool) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv6{
+ Version: 6,
+ NextHeader: layers.IPProtocolIPv6Fragment,
+ HopLimit: 64,
+ SrcIP: net.ParseIP(fragTestSrcV6),
+ DstIP: net.ParseIP(fragTestDstV6),
+ }
+ payload := append(v6FragmentHeader(layers.IPProtocolUDP, 0, moreFragments, id), v6UDPHeader(dstPort, dataLen)...)
+ payload = append(payload, make([]byte, dataLen)...)
+
+ buf := gopacket.NewSerializeBuffer()
+ require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
+ return buf.Bytes()
+}
+
+// trailingFragmentV6 builds a non-first IPv6 fragment: fragment header at the
+// given offset carrying raw data and no transport header.
+func trailingFragmentV6(tb testing.TB, id uint32, offsetOctets uint16, moreFragments bool, dataLen int) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv6{
+ Version: 6,
+ NextHeader: layers.IPProtocolIPv6Fragment,
+ HopLimit: 64,
+ SrcIP: net.ParseIP(fragTestSrcV6),
+ DstIP: net.ParseIP(fragTestDstV6),
+ }
+ payload := append(v6FragmentHeader(layers.IPProtocolUDP, offsetOctets, moreFragments, id), make([]byte, dataLen)...)
+
+ buf := gopacket.NewSerializeBuffer()
+ require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
+ return buf.Bytes()
+}
+
+// TestFragmentV6_TrailingWithoutFirstDropped verifies the IPv6 bypass is closed:
+// a trailing fragment with no allowed first fragment is dropped.
+func TestFragmentV6_TrailingWithoutFirstDropped(t *testing.T) {
+ m := newFragmentTestManager(t)
+
+ frag := trailingFragmentV6(t, 0xAABBCCDD, 100, false, 40)
+ require.True(t, m.filterInbound(frag, len(frag)),
+ "IPv6 trailing fragment without an allowed first fragment must be dropped")
+}
+
+// TestFragmentV6_AllowedFirstPassesTrailing verifies IPv6 fragments are
+// evaluated like IPv4: an allowed first fragment lets its trailing fragments
+// through.
+func TestFragmentV6_AllowedFirstPassesTrailing(t *testing.T) {
+ m := newFragmentTestManager(t)
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
+ &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
+ require.NoError(t, err)
+
+ // First fragment: UDP header (8) + 32 data = 40 octets -> headerEnd = 5.
+ first := firstFragmentUDPv6(t, 0xAABBCCDD, 8080, 32)
+ require.False(t, m.filterInbound(first, len(first)),
+ "allowed IPv6 first fragment should pass and be recorded")
+
+ trailing := trailingFragmentV6(t, 0xAABBCCDD, 5, false, 24)
+ require.False(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of an allowed IPv6 datagram should pass")
+}
+
+// TestFragmentV6_AtomicNotCached verifies an IPv6 atomic fragment (fragment
+// header with offset 0 and no More Fragments, a complete datagram per RFC 6946)
+// is evaluated but not recorded, so a flood of allowed atomic fragments can't
+// exhaust the verdict table.
+func TestFragmentV6_AtomicNotCached(t *testing.T) {
+ m := newFragmentTestManager(t)
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
+ &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
+ require.NoError(t, err)
+
+ atomic := fragmentUDPv6(t, 0xA70301C, 8080, 16, false)
+ require.False(t, m.filterInbound(atomic, len(atomic)),
+ "allowed IPv6 atomic fragment should pass")
+
+ m.fragments.mutex.Lock()
+ n := len(m.fragments.entries)
+ m.fragments.mutex.Unlock()
+ require.Zero(t, n, "atomic fragment must not create a verdict entry")
+
+ // A genuine fragmented datagram (More Fragments set) is still recorded.
+ first := fragmentUDPv6(t, 0xBEEF, 8080, 32, true)
+ require.False(t, m.filterInbound(first, len(first)))
+ m.fragments.mutex.Lock()
+ n = len(m.fragments.entries)
+ m.fragments.mutex.Unlock()
+ require.Equal(t, 1, n, "genuine first fragment must record a verdict")
+}
diff --git a/client/grpc/dialer_generic.go b/client/grpc/dialer_generic.go
index 479575996..8a80525e9 100644
--- a/client/grpc/dialer_generic.go
+++ b/client/grpc/dialer_generic.go
@@ -16,28 +16,47 @@ import (
"google.golang.org/grpc"
nbnet "github.com/netbirdio/netbird/client/net"
+ "github.com/netbirdio/netbird/client/netsweep"
)
func WithCustomDialer(_ bool, _ string) grpc.DialOption {
+ return grpc.WithContextDialer(dialContext)
+}
+
+// WithSweeper dials like WithCustomDialer but registers connections and
+// dials with the sweeper. Append it after WithCustomDialer: gRPC applies
+// dial options in order, so the later context dialer wins.
+func WithSweeper(sweeper *netsweep.Sweeper) grpc.DialOption {
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
- if runtime.GOOS == "linux" {
- currentUser, err := user.Current()
- if err != nil {
- return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
- }
+ dial := sweeper.StartDial(ctx)
+ defer dial.Release()
- // the custom dialer requires root permissions which are not required for use cases run as non-root
- if currentUser.Uid != "0" {
- log.Debug("Not running as root, using standard dialer")
- dialer := &net.Dialer{}
- return dialer.DialContext(ctx, "tcp", addr)
- }
- }
-
- conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
+ conn, err := dialContext(dial.Ctx(), addr)
if err != nil {
- return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
+ return nil, err
}
- return conn, nil
+ return dial.WrapConn(conn)
})
}
+
+func dialContext(ctx context.Context, addr string) (net.Conn, error) {
+ if runtime.GOOS == "linux" {
+ currentUser, err := user.Current()
+ if err != nil {
+ return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
+ }
+
+ // the custom dialer requires root permissions which are not required for use cases run as non-root
+ if currentUser.Uid != "0" {
+ log.Debug("Not running as root, using standard dialer")
+ dialer := &net.Dialer{}
+ return dialer.DialContext(ctx, "tcp", addr)
+ }
+ }
+
+ conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
+ }
+ return conn, nil
+}
diff --git a/client/grpc/dialer_js.go b/client/grpc/dialer_js.go
index b89ec3c21..8863756d7 100644
--- a/client/grpc/dialer_js.go
+++ b/client/grpc/dialer_js.go
@@ -3,6 +3,7 @@ package grpc
import (
"google.golang.org/grpc"
+ "github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/util/wsproxy/client"
)
@@ -11,3 +12,8 @@ import (
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
return client.WithWebSocketDialer(tlsEnabled, component)
}
+
+// WithSweeper is a no-op on WASM/JS: there is no network change signal.
+func WithSweeper(_ *netsweep.Sweeper) grpc.DialOption {
+ return grpc.EmptyDialOption{}
+}
diff --git a/client/grpc/retry.go b/client/grpc/retry.go
new file mode 100644
index 000000000..754ffa341
--- /dev/null
+++ b/client/grpc/retry.go
@@ -0,0 +1,49 @@
+package grpc
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+
+ "github.com/netbirdio/netbird/client/netstate"
+)
+
+// Retry mirrors backoff.Retry, but the sleep between attempts also wakes on
+// OS network availability transitions: an operation cut down by a network
+// change retries the moment the network settles instead of sleeping through
+// the recovery. A nil netState never fires, leaving plain backoff.Retry
+// behavior.
+func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, netState *netstate.State) error {
+ bo.Reset()
+ for {
+ err := operation()
+ if err == nil {
+ return nil
+ }
+
+ var permanent *backoff.PermanentError
+ if errors.As(err, &permanent) {
+ return permanent.Err
+ }
+
+ next := bo.NextBackOff()
+ if next == backoff.Stop {
+ if cerr := ctx.Err(); cerr != nil {
+ return cerr
+ }
+ return err
+ }
+
+ timer := time.NewTimer(next)
+ select {
+ case <-timer.C:
+ case <-netState.Changed():
+ timer.Stop()
+ case <-ctx.Done():
+ timer.Stop()
+ return ctx.Err()
+ }
+ }
+}
diff --git a/client/grpc/retry_test.go b/client/grpc/retry_test.go
new file mode 100644
index 000000000..4edca47b6
--- /dev/null
+++ b/client/grpc/retry_test.go
@@ -0,0 +1,91 @@
+package grpc
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/netstate"
+)
+
+func TestRetryWakesOnNetworkChange(t *testing.T) {
+ ns := netstate.New()
+ attempts := 0
+ operation := func() error {
+ attempts++
+ if attempts == 1 {
+ return errors.New("cut by network change")
+ }
+ return nil
+ }
+
+ go func() {
+ time.Sleep(20 * time.Millisecond)
+ ns.Set(false)
+ }()
+
+ start := time.Now()
+ err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Minute), ns)
+
+ require.NoError(t, err)
+ assert.Equal(t, 2, attempts, "network change must cause one immediate retry")
+ assert.Less(t, time.Since(start), time.Second, "the transition must cut the minute-long sleep short")
+}
+
+func TestRetryPermanentError(t *testing.T) {
+ sentinel := errors.New("permission denied")
+ operation := func() error {
+ return backoff.Permanent(sentinel)
+ }
+
+ err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
+ assert.ErrorIs(t, err, sentinel, "permanent errors must stop retries")
+}
+
+func TestRetryNilNetState(t *testing.T) {
+ attempts := 0
+ operation := func() error {
+ attempts++
+ if attempts < 3 {
+ return errors.New("transient")
+ }
+ return nil
+ }
+
+ err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
+ require.NoError(t, err)
+ assert.Equal(t, 3, attempts, "nil network state must preserve timed retries")
+}
+
+func TestRetryStops(t *testing.T) {
+ failure := errors.New("still failing")
+ operation := func() error {
+ return failure
+ }
+
+ err := Retry(context.Background(), operation, &backoff.StopBackOff{}, nil)
+ assert.ErrorIs(t, err, failure, "stop backoff must return the operation error")
+}
+
+func TestRetryCtxCancelDuringSleep(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ operation := func() error {
+ return errors.New("failing")
+ }
+
+ go func() {
+ time.Sleep(20 * time.Millisecond)
+ cancel()
+ }()
+
+ start := time.Now()
+ err := Retry(ctx, operation, backoff.NewConstantBackOff(time.Minute), netstate.New())
+
+ assert.ErrorIs(t, err, context.Canceled, "context cancellation must stop the retry loop")
+ assert.Less(t, time.Since(start), time.Second, "context cancellation must interrupt backoff sleep")
+}
diff --git a/client/iface/bind/ice_bind.go b/client/iface/bind/ice_bind.go
index 156450c61..2d35b9c6f 100644
--- a/client/iface/bind/ice_bind.go
+++ b/client/iface/bind/ice_bind.go
@@ -22,6 +22,16 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
+const (
+ // wgMsgTypeHandshakeInitiation is the lowest WireGuard message type.
+ wgMsgTypeHandshakeInitiation uint32 = 1
+ // wgMsgTypeTransport is the highest WireGuard message type.
+ wgMsgTypeTransport uint32 = 4
+ // wgMinMsgSize is the smallest WireGuard message: transport data with an empty
+ // payload, which is what a keepalive is.
+ wgMinMsgSize = 32
+)
+
type receiverCreator struct {
iceBind *ICEBind
}
@@ -216,8 +226,15 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO
for i := 0; i < numMsgs; i++ {
msg := &(*msgs)[i]
- // todo: handle err
- if ok, _ := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
+ if ok, err := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
+ if err != nil {
+ log.Debugf("failed to handle STUN packet from %s: %v", msg.Addr, err)
+ }
+ // WireGuard reuses sizes and eps across reads and only skips a slot
+ // whose size is below the minimum message size. Leaving a consumed
+ // slot untouched makes it process this buffer again under the
+ // previous packet's length and endpoint.
+ sizes[i] = 0
continue
}
sizes[i] = msg.N
@@ -271,11 +288,16 @@ func (s *ICEBind) createOrUpdateMux() {
func (s *ICEBind) filterOutStunMessages(buffers [][]byte, n int, addr net.Addr) (bool, error) {
for i := range buffers {
- if !stun.IsMessage(buffers[i]) {
+ if n > len(buffers[i]) {
+ continue
+ }
+ pkt := buffers[i][:n]
+
+ if isWireGuardMsg(pkt) || !stun.IsMessage(pkt) {
continue
}
- msg, err := s.parseSTUNMessage(buffers[i][:n])
+ msg, err := s.parseSTUNMessage(pkt)
if err != nil {
buffers[i] = []byte{}
return true, err
@@ -347,18 +369,34 @@ func putMessages(msgs *[]ipv6.Message, msgsPool *sync.Pool) {
msgsPool.Put(msgs)
}
-func isTransportPkg(buffers [][]byte, n int) bool {
- // The first buffer should contain at least 4 bytes for type
- if len(buffers[0]) < 4 {
- return true
+// isWireGuardMsg reports whether the packet carries a WireGuard message header: a
+// little-endian uint32 message type in the range 1..4, which leaves the three bytes
+// after the type byte zero, in a packet long enough to hold any WireGuard message.
+//
+// A well formed STUN message cannot take that shape. Its length field sits in the two
+// bytes the type must leave zero, and for a message of at least wgMinMsgSize bytes that
+// field holds at least 12, so the two framings do not overlap. The test has to be this
+// tight because stun.IsMessage only looks at the magic cookie, which in a WireGuard
+// message overlaps the receiver index: a session whose index happens to equal the cookie
+// would otherwise have all of its inbound data misrouted to the STUN handler until the
+// next rekey.
+func isWireGuardMsg(pkt []byte) bool {
+ if len(pkt) < wgMinMsgSize {
+ return false
}
- // WireGuard packet type is a little-endian uint32 at start
- packetType := binary.LittleEndian.Uint32(buffers[0][:4])
-
- // Check if packetType matches known WireGuard message types
- if packetType == 4 && n > 32 {
- return true
- }
- return false
+ msgType := binary.LittleEndian.Uint32(pkt[:4])
+ return msgType >= wgMsgTypeHandshakeInitiation && msgType <= wgMsgTypeTransport
+}
+
+// isTransportPkg reports whether the packet is WireGuard transport data carrying a
+// payload, which is what counts as peer activity. A keepalive holds no payload and is
+// exactly wgMinMsgSize bytes.
+func isTransportPkg(buffers [][]byte, n int) bool {
+ if n < 4 || n > len(buffers[0]) {
+ return false
+ }
+
+ msgType := binary.LittleEndian.Uint32(buffers[0][:4])
+ return msgType == wgMsgTypeTransport && n > wgMinMsgSize
}
diff --git a/client/iface/bind/stun_filter_test.go b/client/iface/bind/stun_filter_test.go
new file mode 100644
index 000000000..0e118e0fd
--- /dev/null
+++ b/client/iface/bind/stun_filter_test.go
@@ -0,0 +1,215 @@
+//go:build !js
+
+package bind
+
+import (
+ "encoding/binary"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/pion/stun/v3"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/net/ipv4"
+ wgConn "golang.zx2c4.com/wireguard/conn"
+)
+
+// magicCookieBytes is the STUN magic cookie as it appears on the wire. In a
+// WireGuard message the same offset holds the receiver (or sender) index, which is
+// a random uint32, so a session can draw exactly this value.
+var magicCookieBytes = []byte{0x21, 0x12, 0xA4, 0x42}
+
+const testBufSize = 1500
+
+// wgMsg builds a WireGuard message of the given type and size, with the index field
+// at bytes 4:8 set to index.
+func wgMsg(msgType uint32, size int, index []byte) []byte {
+ pkt := make([]byte, size)
+ binary.LittleEndian.PutUint32(pkt[:4], msgType)
+ copy(pkt[4:8], index)
+ return pkt
+}
+
+// intoBuffer copies pkt into a full-size receive buffer, the way the kernel read
+// does, so tests see the same buffer/length split as the hot path.
+func intoBuffer(pkt []byte) [][]byte {
+ buf := make([]byte, testBufSize)
+ copy(buf, pkt)
+ return [][]byte{buf}
+}
+
+func TestFilterOutStunMessages_PassesWireGuardWithCookieShapedIndex(t *testing.T) {
+ tests := []struct {
+ name string
+ msgType uint32
+ size int
+ }{
+ {"transport data", wgMsgTypeTransport, 128},
+ {"keepalive", wgMsgTypeTransport, wgMinMsgSize},
+ {"handshake initiation", wgMsgTypeHandshakeInitiation, 148},
+ {"handshake response", 2, 92},
+ {"cookie reply", 3, 64},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ pkt := wgMsg(tc.msgType, tc.size, magicCookieBytes)
+ require.True(t, stun.IsMessage(pkt), "precondition: pion sees this as STUN")
+
+ buffers := intoBuffer(pkt)
+ bind := &ICEBind{}
+
+ filtered, err := bind.filterOutStunMessages(buffers, tc.size, &net.UDPAddr{})
+ assert.NoError(t, err)
+ assert.False(t, filtered, "WireGuard message must be handed to WireGuard, not the STUN handler")
+ assert.Len(t, buffers[0], testBufSize, "buffer must be left intact for WireGuard")
+ })
+ }
+}
+
+func TestFilterOutStunMessages_FiltersRealSTUNMessage(t *testing.T) {
+ msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint)
+ require.NoError(t, err)
+
+ buffers := intoBuffer(msg.Raw)
+ bind := &ICEBind{}
+
+ filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{})
+ assert.NoError(t, err)
+ assert.True(t, filtered, "STUN message must be consumed by the STUN handler")
+ assert.Empty(t, buffers[0], "consumed buffer must be emptied so WireGuard does not see it")
+}
+
+// TestIsWireGuardMsg_DisjointFromSTUN locks the invariant the filter relies on: a
+// well formed STUN message long enough to be a WireGuard message always has a
+// non-zero length field, so it cannot be mistaken for a WireGuard header.
+func TestIsWireGuardMsg_DisjointFromSTUN(t *testing.T) {
+ types := []stun.MessageType{
+ stun.BindingRequest,
+ stun.BindingSuccess,
+ stun.BindingError,
+ {Method: stun.MethodBinding, Class: stun.ClassIndication},
+ }
+
+ for _, msgType := range types {
+ // Long enough that the length guard is not what makes this pass.
+ msg, err := stun.Build(msgType, stun.TransactionID,
+ stun.NewUsername("remoteUfrag:localUfrag"), stun.Fingerprint)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, len(msg.Raw), wgMinMsgSize, "precondition: %s", msgType)
+ assert.False(t, isWireGuardMsg(msg.Raw),
+ "%s must not look like a WireGuard message", msgType)
+ }
+}
+
+func TestIsWireGuardMsg(t *testing.T) {
+ tests := []struct {
+ name string
+ pkt []byte
+ want bool
+ }{
+ {"transport data", wgMsg(wgMsgTypeTransport, 128, nil), true},
+ {"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), true},
+ {"unknown type 5", wgMsg(5, 128, nil), false},
+ {"type 0", wgMsg(0, 128, nil), false},
+ {"non-zero reserved byte", []byte{0x04, 0x00, 0x01, 0x00}, false},
+ {"too short", []byte{0x04, 0x00, 0x00}, false},
+ {"empty", nil, false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, isWireGuardMsg(tc.pkt), "wrong classification for %s", tc.name)
+ })
+ }
+}
+
+// TestFilterOutStunMessages_IgnoresBytesBeyondPacket guards against classifying on
+// buffer contents left over from an earlier, longer packet.
+func TestFilterOutStunMessages_IgnoresBytesBeyondPacket(t *testing.T) {
+ buf := make([]byte, testBufSize)
+ copy(buf[4:8], magicCookieBytes)
+ buffers := [][]byte{buf}
+ bind := &ICEBind{}
+
+ filtered, err := bind.filterOutStunMessages(buffers, 2, &net.UDPAddr{})
+ assert.NoError(t, err)
+ assert.False(t, filtered, "a 2 byte packet must not be classified from stale buffer bytes")
+}
+
+// TestReceiveFn_ClearsSizeOfConsumedPacket covers the accounting WireGuard relies
+// on: sizes is reused across reads, so a slot whose packet was consumed as STUN must
+// be reported as empty. Otherwise WireGuard reprocesses the same buffer under the
+// previous packet's length, which for a WireGuard-shaped packet means it is handled
+// twice.
+func TestReceiveFn_ClearsSizeOfConsumedPacket(t *testing.T) {
+ conn := listenUDP(t, "udp4", "127.0.0.1:0")
+ defer conn.Close()
+
+ recvFn := receiverCreator{setupICEBind(t)}.CreateReceiverFn(
+ ipv4.NewPacketConn(conn), conn, false, createMsgPool(),
+ )
+
+ msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint)
+ require.NoError(t, err)
+
+ sender := listenUDP(t, "udp4", "127.0.0.1:0")
+ defer sender.Close()
+ _, err = sender.WriteTo(msg.Raw, conn.LocalAddr())
+ require.NoError(t, err)
+
+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(3*time.Second)))
+
+ bufs := [][]byte{make([]byte, 1500)}
+ // A leftover size from an earlier read, which is what makes the missing reset
+ // observable.
+ sizes := []int{148}
+ eps := make([]wgConn.Endpoint, 1)
+
+ n, err := recvFn(bufs, sizes, eps)
+ require.NoError(t, err)
+ require.Equal(t, 1, n)
+ assert.Zero(t, sizes[0], "consumed STUN packet must not leave a size behind for WireGuard")
+}
+
+func TestIsTransportPkg(t *testing.T) {
+ tests := []struct {
+ name string
+ pkt []byte
+ n int
+ want bool
+ }{
+ {"transport data with payload", wgMsg(wgMsgTypeTransport, 128, nil), 128, true},
+ {"keepalive", wgMsg(wgMsgTypeTransport, wgMinMsgSize, nil), wgMinMsgSize, false},
+ {"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), 148, false},
+ {"stale type bytes beyond packet", wgMsg(wgMsgTypeTransport, 128, nil), 2, false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, isTransportPkg(intoBuffer(tc.pkt), tc.n),
+ "wrong activity classification for %s", tc.name)
+ })
+ }
+}
+
+// TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType covers the one STUN
+// encoding whose leading bytes collide with a WireGuard message type: method 0x080 as a
+// request encodes to 0x0200, so the type byte reads as a handshake response and the byte
+// after it is zero. Only the length check keeps such a message out of WireGuard's hands.
+// pion implements no method in that range, so this is a synthetic worst case rather than
+// traffic ICE produces.
+func TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType(t *testing.T) {
+ msg, err := stun.Build(stun.NewType(stun.Method(0x080), stun.ClassRequest), stun.TransactionID)
+ require.NoError(t, err)
+ require.Equal(t, []byte{0x02, 0x00, 0x00, 0x00}, msg.Raw[:4],
+ "precondition: the leading bytes read as a WireGuard message type")
+
+ buffers := intoBuffer(msg.Raw)
+ bind := &ICEBind{}
+
+ filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{})
+ assert.NoError(t, err)
+ assert.True(t, filtered, "STUN message must be consumed despite its WireGuard-shaped type")
+}
diff --git a/client/iface/configurer/kernel_unix.go b/client/iface/configurer/kernel_unix.go
index a29fe181a..da69c2a35 100644
--- a/client/iface/configurer/kernel_unix.go
+++ b/client/iface/configurer/kernel_unix.go
@@ -17,12 +17,15 @@ import (
type KernelConfigurer struct {
deviceName string
+ statsCache *statsCache
}
func NewKernelConfigurer(deviceName string) *KernelConfigurer {
- return &KernelConfigurer{
+ c := &KernelConfigurer{
deviceName: deviceName,
}
+ c.statsCache = newStatsCache(statsCacheTTL, c.fetchStats)
+ return c
}
func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error {
@@ -246,12 +249,6 @@ func (c *KernelConfigurer) configure(config wgtypes.Config) error {
}
}()
- // validate if device with name exists
- _, err = wg.Device(c.deviceName)
- if err != nil {
- return err
- }
-
return wg.ConfigureDevice(c.deviceName, config)
}
@@ -300,6 +297,14 @@ func (c *KernelConfigurer) FullStats() (*Stats, error) {
}
func (c *KernelConfigurer) GetStats() (map[string]WGStats, error) {
+ return c.statsCache.get()
+}
+
+func (c *KernelConfigurer) LastActivities() map[string]monotime.Time {
+ return nil
+}
+
+func (c *KernelConfigurer) fetchStats() (map[string]WGStats, error) {
stats := make(map[string]WGStats)
wg, err := wgctrl.New()
if err != nil {
@@ -326,7 +331,3 @@ func (c *KernelConfigurer) GetStats() (map[string]WGStats, error) {
}
return stats, nil
}
-
-func (c *KernelConfigurer) LastActivities() map[string]monotime.Time {
- return nil
-}
diff --git a/client/iface/configurer/stats_cache.go b/client/iface/configurer/stats_cache.go
new file mode 100644
index 000000000..71a4e88fc
--- /dev/null
+++ b/client/iface/configurer/stats_cache.go
@@ -0,0 +1,52 @@
+package configurer
+
+import (
+ "sync"
+ "time"
+
+ "golang.org/x/sync/singleflight"
+)
+
+const statsCacheTTL = 1 * time.Second
+
+type statsCache struct {
+ ttl time.Duration
+ fetch func() (map[string]WGStats, error)
+
+ mu sync.RWMutex
+ value map[string]WGStats
+ expireAt time.Time
+
+ sf singleflight.Group
+}
+
+func newStatsCache(ttl time.Duration, fetch func() (map[string]WGStats, error)) *statsCache {
+ return &statsCache{ttl: ttl, fetch: fetch}
+}
+
+func (c *statsCache) get() (map[string]WGStats, error) {
+ c.mu.RLock()
+ if c.value != nil && time.Now().Before(c.expireAt) {
+ value := c.value
+ c.mu.RUnlock()
+ return value, nil
+ }
+ c.mu.RUnlock()
+
+ value, err, _ := c.sf.Do("stats", func() (interface{}, error) {
+ res, err := c.fetch()
+ if err != nil {
+ return nil, err
+ }
+
+ c.mu.Lock()
+ c.value = res
+ c.expireAt = time.Now().Add(c.ttl)
+ c.mu.Unlock()
+ return res, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return value.(map[string]WGStats), nil
+}
diff --git a/client/iface/configurer/stats_cache_test.go b/client/iface/configurer/stats_cache_test.go
new file mode 100644
index 000000000..bcee5cd52
--- /dev/null
+++ b/client/iface/configurer/stats_cache_test.go
@@ -0,0 +1,70 @@
+package configurer
+
+import (
+ "errors"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestStatsCache_CachesWithinTTL(t *testing.T) {
+ var calls atomic.Int64
+ c := newStatsCache(50*time.Millisecond, func() (map[string]WGStats, error) {
+ calls.Add(1)
+ return map[string]WGStats{"p": {}}, nil
+ })
+
+ for i := 0; i < 10; i++ {
+ _, err := c.get()
+ require.NoError(t, err)
+ }
+ require.Equal(t, int64(1), calls.Load(), "within TTL only one underlying fetch")
+
+ time.Sleep(60 * time.Millisecond)
+ _, err := c.get()
+ require.NoError(t, err)
+ require.Equal(t, int64(2), calls.Load(), "after TTL expiry a fresh fetch happens")
+}
+
+func TestStatsCache_SingleFlight(t *testing.T) {
+ var calls atomic.Int64
+ release := make(chan struct{})
+ c := newStatsCache(time.Minute, func() (map[string]WGStats, error) {
+ calls.Add(1)
+ <-release
+ return map[string]WGStats{}, nil
+ })
+
+ const n = 50
+ var wg sync.WaitGroup
+ wg.Add(n)
+ for i := 0; i < n; i++ {
+ go func() {
+ defer wg.Done()
+ _, _ = c.get()
+ }()
+ }
+ time.Sleep(20 * time.Millisecond)
+ close(release)
+ wg.Wait()
+
+ require.Equal(t, int64(1), calls.Load(), "concurrent misses collapse into one fetch")
+}
+
+func TestStatsCache_ErrorNotCached(t *testing.T) {
+ var calls atomic.Int64
+ wantErr := errors.New("dump failed")
+ c := newStatsCache(time.Minute, func() (map[string]WGStats, error) {
+ calls.Add(1)
+ return nil, wantErr
+ })
+
+ _, err := c.get()
+ require.ErrorIs(t, err, wantErr)
+ _, err = c.get()
+ require.ErrorIs(t, err, wantErr)
+ require.Equal(t, int64(2), calls.Load(), "errors are not cached; each call retries")
+}
diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go
index 9b070aab8..0a25c55bc 100644
--- a/client/iface/configurer/usp.go
+++ b/client/iface/configurer/usp.go
@@ -40,6 +40,7 @@ type WGUSPConfigurer struct {
device *device.Device
deviceName string
activityRecorder *bind.ActivityRecorder
+ statsCache *statsCache
uapiListener net.Listener
}
@@ -50,16 +51,19 @@ func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder
deviceName: deviceName,
activityRecorder: activityRecorder,
}
+ wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats)
wgCfg.startUAPI()
return wgCfg
}
func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer {
- return &WGUSPConfigurer{
+ wgCfg := &WGUSPConfigurer{
device: device,
deviceName: deviceName,
activityRecorder: activityRecorder,
}
+ wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats)
+ return wgCfg
}
func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error {
@@ -348,6 +352,10 @@ func (t *WGUSPConfigurer) Close() {
}
func (t *WGUSPConfigurer) GetStats() (map[string]WGStats, error) {
+ return t.statsCache.get()
+}
+
+func (t *WGUSPConfigurer) fetchStats() (map[string]WGStats, error) {
ipc, err := t.device.IpcGet()
if err != nil {
return nil, fmt.Errorf("ipc get: %w", err)
diff --git a/client/iface/device/device_filter_test.go b/client/iface/device/device_filter_test.go
index 0d86c9323..a75ef90f9 100644
--- a/client/iface/device/device_filter_test.go
+++ b/client/iface/device/device_filter_test.go
@@ -4,7 +4,7 @@ import (
"net"
"testing"
- "github.com/golang/mock/gomock"
+ "go.uber.org/mock/gomock"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go
index dbeb69bc6..89c8cd16e 100644
--- a/client/iface/iface_test.go
+++ b/client/iface/iface_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package iface
import (
@@ -462,6 +464,8 @@ func Test_RemovePeer(t *testing.T) {
}
func Test_ConnectPeers(t *testing.T) {
+ t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true")
+
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
peer1Key, _ := wgtypes.GeneratePrivateKey()
@@ -503,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) {
t.Fatal(err)
}
- localIP, err := getLocalIP()
- if err != nil {
- t.Fatal(err)
- }
-
- peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
+ localIP1 := "127.0.0.1"
+ peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
if err != nil {
t.Fatal(err)
}
@@ -544,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) {
t.Fatal(err)
}
- peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
+ localIP2 := "127.0.0.1"
+ peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
if err != nil {
t.Fatal(err)
}
@@ -567,17 +568,17 @@ func Test_ConnectPeers(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- // todo: investigate why in some tests execution we need 30s
+ // The peers use userspace WireGuard (stdnet transport). A tight busy-loop
+ // here starves the wireguard-go goroutines that process the handshake, so
+ // poll on a ticker instead and yield the CPU between checks. WireGuard also
+ // only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which
+ // is why the overall wait can occasionally stretch to tens of seconds.
timeout := 30 * time.Second
timeoutChannel := time.After(timeout)
+ ticker := time.NewTicker(500 * time.Millisecond)
+ defer ticker.Stop()
for {
- select {
- case <-timeoutChannel:
- t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
- default:
- }
-
peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String())
if gpErr != nil {
t.Fatal(gpErr)
@@ -586,6 +587,12 @@ func Test_ConnectPeers(t *testing.T) {
t.Log("peers successfully handshake")
break
}
+
+ select {
+ case <-timeoutChannel:
+ t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
+ case <-ticker.C:
+ }
}
}
@@ -613,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) {
}
return wgtypes.Peer{}, fmt.Errorf("peer not found")
}
-
-func getLocalIP() (string, error) {
- // Get all interfaces
- addrs, err := net.InterfaceAddrs()
- if err != nil {
- return "", err
- }
-
- for _, addr := range addrs {
- ipNet, ok := addr.(*net.IPNet)
- if !ok {
- continue
- }
- if ipNet.IP.IsLoopback() {
- continue
- }
-
- if ipNet.IP.To4() == nil {
- continue
- }
- return ipNet.IP.String(), nil
- }
-
- return "", fmt.Errorf("no local IP found")
-}
diff --git a/client/iface/mocks/filter.go b/client/iface/mocks/filter.go
index 5ae98039c..ff3dd0c8a 100644
--- a/client/iface/mocks/filter.go
+++ b/client/iface/mocks/filter.go
@@ -8,7 +8,7 @@ import (
"net/netip"
reflect "reflect"
- gomock "github.com/golang/mock/gomock"
+ gomock "go.uber.org/mock/gomock"
)
// MockPacketFilter is a mock of PacketFilter interface.
diff --git a/client/iface/mocks/tun.go b/client/iface/mocks/tun.go
index 677c82b0b..519ee6005 100644
--- a/client/iface/mocks/tun.go
+++ b/client/iface/mocks/tun.go
@@ -8,7 +8,7 @@ import (
os "os"
reflect "reflect"
- gomock "github.com/golang/mock/gomock"
+ gomock "go.uber.org/mock/gomock"
tun "golang.zx2c4.com/wireguard/tun"
)
diff --git a/client/iface/netstack/env.go b/client/iface/netstack/env.go
index dd8cf29a3..b069301c1 100644
--- a/client/iface/netstack/env.go
+++ b/client/iface/netstack/env.go
@@ -3,14 +3,31 @@
package netstack
import (
- "fmt"
+ "net"
"os"
"strconv"
log "github.com/sirupsen/logrus"
)
-const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
+const (
+ EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
+
+ // EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on.
+ EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT"
+
+ // EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to.
+ // The proxy is a bridge for local host applications into the userspace
+ // WireGuard netstack, so it binds to loopback by default. Override this only
+ // when the proxy must be reachable from other hosts (e.g. a container
+ // gateway); doing so exposes an unauthenticated SOCKS5 proxy on that
+ // address.
+ EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS"
+
+ // defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless
+ // overridden via EnvSocks5ListenerAddress.
+ defaultSocks5Host = "127.0.0.1"
+)
// IsEnabled todo: move these function to cmd layer
func IsEnabled() bool {
@@ -18,24 +35,40 @@ func IsEnabled() bool {
}
func ListenAddr() string {
- sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT")
+ return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort()))
+}
+
+// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to
+// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP.
+func listenHost() string {
+ addr := os.Getenv(EnvSocks5ListenerAddress)
+ if addr == "" {
+ return defaultSocks5Host
+ }
+ if net.ParseIP(addr) == nil {
+ log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host)
+ return defaultSocks5Host
+ }
+ return addr
+}
+
+// listenPort returns the port the SOCKS5 proxy binds to, defaulting to
+// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid.
+func listenPort() int {
+ sPort := os.Getenv(EnvSocks5ListenerPort)
if sPort == "" {
- return listenAddr(DefaultSocks5Port)
+ return DefaultSocks5Port
}
port, err := strconv.Atoi(sPort)
if err != nil {
log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port)
- return listenAddr(DefaultSocks5Port)
+ return DefaultSocks5Port
}
if port < 1 || port > 65535 {
log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port)
- return listenAddr(DefaultSocks5Port)
+ return DefaultSocks5Port
}
- return listenAddr(port)
-}
-
-func listenAddr(port int) string {
- return fmt.Sprintf("0.0.0.0:%d", port)
+ return port
}
diff --git a/client/iface/netstack/env_test.go b/client/iface/netstack/env_test.go
new file mode 100644
index 000000000..1083435a4
--- /dev/null
+++ b/client/iface/netstack/env_test.go
@@ -0,0 +1,63 @@
+//go:build !js
+
+package netstack
+
+import (
+ "net"
+ "strconv"
+ "testing"
+)
+
+func TestListenAddr_DefaultsToLoopback(t *testing.T) {
+ // No env overrides: must bind loopback, never all interfaces.
+ got := ListenAddr()
+ want := net.JoinHostPort("127.0.0.1", strconv.Itoa(DefaultSocks5Port))
+ if got != want {
+ t.Fatalf("ListenAddr() = %q, want %q", got, want)
+ }
+}
+
+func TestListenAddr_AddressOverride(t *testing.T) {
+ tests := []struct {
+ name string
+ env string
+ want string
+ }{
+ {name: "valid override honored", env: "0.0.0.0", want: "0.0.0.0"},
+ {name: "valid specific ip honored", env: "10.0.0.5", want: "10.0.0.5"},
+ {name: "ipv6 loopback bracketed", env: "::1", want: "::1"},
+ {name: "invalid falls back to loopback", env: "not-an-ip", want: "127.0.0.1"},
+ {name: "empty falls back to loopback", env: "", want: "127.0.0.1"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(EnvSocks5ListenerAddress, tc.env)
+ want := net.JoinHostPort(tc.want, strconv.Itoa(DefaultSocks5Port))
+ if got := ListenAddr(); got != want {
+ t.Fatalf("ListenAddr() = %q, want %q", got, want)
+ }
+ })
+ }
+}
+
+func TestListenAddr_PortOverride(t *testing.T) {
+ tests := []struct {
+ name string
+ env string
+ want int
+ }{
+ {name: "valid port honored", env: "1081", want: 1081},
+ {name: "non-numeric falls back", env: "abc", want: DefaultSocks5Port},
+ {name: "out of range falls back", env: "70000", want: DefaultSocks5Port},
+ {name: "zero falls back", env: "0", want: DefaultSocks5Port},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(EnvSocks5ListenerPort, tc.env)
+ want := net.JoinHostPort("127.0.0.1", strconv.Itoa(tc.want))
+ if got := ListenAddr(); got != want {
+ t.Fatalf("ListenAddr() = %q, want %q", got, want)
+ }
+ })
+ }
+}
diff --git a/client/iface/wgproxy/bind/proxy.go b/client/iface/wgproxy/bind/proxy.go
index be6f3806e..fcaee15c7 100644
--- a/client/iface/wgproxy/bind/proxy.go
+++ b/client/iface/wgproxy/bind/proxy.go
@@ -53,15 +53,15 @@ func NewProxyBind(bind Bind, mtu uint16) *ProxyBind {
return p
}
-// AddTurnConn adds a new connection to the bind.
+// AddRelayedConn adds a new connection to the bind.
// endpoint is the NetBird address of the remote peer. The SetEndpoint return with the address what will be used in the
// WireGuard configuration.
//
// Parameters:
// - ctx: Context is used for proxyToLocal to avoid unnecessary error messages
// - nbAddr: The NetBird UDP address of the remote peer, it required to generate fake address
-// - remoteConn: The established TURN connection to the remote peer
-func (p *ProxyBind) AddTurnConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error {
+// - remoteConn: The established relayed connection to the remote peer
+func (p *ProxyBind) AddRelayedConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error {
fakeNetIP, err := fakeAddress(nbAddr)
if err != nil {
return err
@@ -136,6 +136,11 @@ func (p *ProxyBind) CloseConn() error {
return p.close()
}
+// InjectPacket is a no-op for the userspace proxy: first-packet reinjection is kernel-only.
+func (p *ProxyBind) InjectPacket(_ []byte) error {
+ return nil
+}
+
func (p *ProxyBind) close() error {
if p.remoteConn == nil {
return nil
diff --git a/client/iface/wgproxy/ebpf/proxy.go b/client/iface/wgproxy/ebpf/proxy.go
index 1b1a8ce1c..91c741c0d 100644
--- a/client/iface/wgproxy/ebpf/proxy.go
+++ b/client/iface/wgproxy/ebpf/proxy.go
@@ -30,9 +30,9 @@ type WGEBPFProxy struct {
proxyPort int
mtu uint16
- ebpfManager ebpfMgr.Manager
- turnConnStore map[uint16]net.Conn
- turnConnMutex sync.Mutex
+ ebpfManager ebpfMgr.Manager
+ relayedConnStore map[uint16]net.Conn
+ relayedConnMutex sync.Mutex
lastUsedPort uint16
rawConnIPv4 net.PacketConn
@@ -50,7 +50,7 @@ func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy {
localWGListenPort: wgPort,
mtu: mtu,
ebpfManager: ebpf.GetEbpfManagerInstance(),
- turnConnStore: make(map[uint16]net.Conn),
+ relayedConnStore: make(map[uint16]net.Conn),
}
return wgProxy
}
@@ -110,14 +110,14 @@ func (p *WGEBPFProxy) Listen() error {
return nil
}
-// AddTurnConn add new turn connection for the proxy
-func (p *WGEBPFProxy) AddTurnConn(turnConn net.Conn) (*net.UDPAddr, error) {
- wgEndpointPort, err := p.storeTurnConn(turnConn)
+// AddRelayedConn add new relayed connection for the proxy
+func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) {
+ wgEndpointPort, err := p.storeRelayedConn(relayedConn)
if err != nil {
return nil, err
}
- log.Infof("turn conn added to wg proxy store: %s, endpoint port: :%d", turnConn.RemoteAddr(), wgEndpointPort)
+ log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort)
wgEndpoint := &net.UDPAddr{
IP: net.ParseIP(loopbackAddr),
@@ -186,48 +186,48 @@ func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error {
return fmt.Errorf("failed to read UDP packet from WG: %w", err)
}
- p.turnConnMutex.Lock()
- conn, ok := p.turnConnStore[uint16(addr.Port)]
- p.turnConnMutex.Unlock()
+ p.relayedConnMutex.Lock()
+ conn, ok := p.relayedConnStore[uint16(addr.Port)]
+ p.relayedConnMutex.Unlock()
if !ok {
if p.ctx.Err() == nil {
- log.Debugf("turn conn not found by port because conn already has been closed: %d", addr.Port)
+ log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port)
}
return nil
}
if _, err := conn.Write(buf[:n]); err != nil {
- return fmt.Errorf("failed to forward local WG packet (%d) to remote turn conn: %w", addr.Port, err)
+ return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err)
}
return nil
}
-func (p *WGEBPFProxy) storeTurnConn(turnConn net.Conn) (uint16, error) {
- p.turnConnMutex.Lock()
- defer p.turnConnMutex.Unlock()
+func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) {
+ p.relayedConnMutex.Lock()
+ defer p.relayedConnMutex.Unlock()
np, err := p.nextFreePort()
if err != nil {
return np, err
}
- p.turnConnStore[np] = turnConn
+ p.relayedConnStore[np] = relayedConn
return np, nil
}
-func (p *WGEBPFProxy) removeTurnConn(turnConnID uint16) {
- p.turnConnMutex.Lock()
- defer p.turnConnMutex.Unlock()
+func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) {
+ p.relayedConnMutex.Lock()
+ defer p.relayedConnMutex.Unlock()
- _, ok := p.turnConnStore[turnConnID]
+ _, ok := p.relayedConnStore[relayedConnID]
if ok {
- log.Debugf("remove turn conn from store by port: %d", turnConnID)
+ log.Debugf("remove relayed conn from store by port: %d", relayedConnID)
}
- delete(p.turnConnStore, turnConnID)
+ delete(p.relayedConnStore, relayedConnID)
}
func (p *WGEBPFProxy) nextFreePort() (uint16, error) {
- if len(p.turnConnStore) == 65535 {
- return 0, fmt.Errorf("reached maximum turn connection numbers")
+ if len(p.relayedConnStore) == 65535 {
+ return 0, fmt.Errorf("reached maximum relayed connection numbers")
}
generatePort:
if p.lastUsedPort == 65535 {
@@ -236,7 +236,7 @@ generatePort:
p.lastUsedPort++
}
- if _, ok := p.turnConnStore[p.lastUsedPort]; ok {
+ if _, ok := p.relayedConnStore[p.lastUsedPort]; ok {
goto generatePort
}
return p.lastUsedPort, nil
diff --git a/client/iface/wgproxy/ebpf/proxy_test.go b/client/iface/wgproxy/ebpf/proxy_test.go
index 3ec4f0eba..228c06c9b 100644
--- a/client/iface/wgproxy/ebpf/proxy_test.go
+++ b/client/iface/wgproxy/ebpf/proxy_test.go
@@ -9,32 +9,32 @@ import (
func TestWGEBPFProxy_connStore(t *testing.T) {
wgProxy := NewWGEBPFProxy(1, 1280)
- p, _ := wgProxy.storeTurnConn(nil)
+ p, _ := wgProxy.storeRelayedConn(nil)
if p != 1 {
t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort)
}
numOfConns := 10
for i := 0; i < numOfConns; i++ {
- p, _ = wgProxy.storeTurnConn(nil)
+ p, _ = wgProxy.storeRelayedConn(nil)
}
if p != uint16(numOfConns)+1 {
t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1)
}
- if len(wgProxy.turnConnStore) != numOfConns+1 {
- t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), numOfConns+1)
+ if len(wgProxy.relayedConnStore) != numOfConns+1 {
+ t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1)
}
}
func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) {
wgProxy := NewWGEBPFProxy(1, 1280)
- _, _ = wgProxy.storeTurnConn(nil)
+ _, _ = wgProxy.storeRelayedConn(nil)
wgProxy.lastUsedPort = 65535
- p, _ := wgProxy.storeTurnConn(nil)
+ p, _ := wgProxy.storeRelayedConn(nil)
- if len(wgProxy.turnConnStore) != 2 {
- t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), 2)
+ if len(wgProxy.relayedConnStore) != 2 {
+ t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2)
}
if p != 2 {
@@ -46,11 +46,11 @@ func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) {
wgProxy := NewWGEBPFProxy(1, 1280)
for i := 0; i < 65535; i++ {
- _, _ = wgProxy.storeTurnConn(nil)
+ _, _ = wgProxy.storeRelayedConn(nil)
}
- _, err := wgProxy.storeTurnConn(nil)
+ _, err := wgProxy.storeRelayedConn(nil)
if err == nil {
- t.Errorf("invalid turn conn store calculation")
+ t.Errorf("invalid relayed conn store calculation")
}
}
diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/ebpf/wrapper.go
index 6e80945c4..f75e21aa6 100644
--- a/client/iface/wgproxy/ebpf/wrapper.go
+++ b/client/iface/wgproxy/ebpf/wrapper.go
@@ -121,10 +121,10 @@ func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper {
}
}
-func (p *ProxyWrapper) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
- addr, err := p.wgeBPFProxy.AddTurnConn(remoteConn)
+func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
+ addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn)
if err != nil {
- return fmt.Errorf("add turn conn: %w", err)
+ return fmt.Errorf("add relayed conn: %w", err)
}
headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr)
@@ -219,6 +219,17 @@ func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) {
p.pausedCond.L.Unlock()
}
+// InjectPacket writes b to the remote peer over the underlying transport.
+func (p *ProxyWrapper) InjectPacket(b []byte) error {
+ if p.remoteConn == nil {
+ return errors.New("proxy not started")
+ }
+ if _, err := p.remoteConn.Write(b); err != nil {
+ return err
+ }
+ return nil
+}
+
// CloseConn close the remoteConn and automatically remove the conn instance from the map
func (p *ProxyWrapper) CloseConn() error {
if p.cancel == nil {
@@ -241,7 +252,7 @@ func (p *ProxyWrapper) CloseConn() error {
}
func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
- defer p.wgeBPFProxy.removeTurnConn(uint16(p.wgRelayedEndpointAddr.Port))
+ defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port))
buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead)
for {
@@ -262,7 +273,7 @@ func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
if ctx.Err() != nil {
return
}
- log.Errorf("failed to write out turn pkg to local conn: %v", err)
+ log.Errorf("failed to write out relayed pkg to local conn: %v", err)
}
}
}
@@ -275,7 +286,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err
}
p.closeListener.Notify()
if !errors.Is(err, io.EOF) {
- log.Errorf("failed to read from turn conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err)
+ log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err)
}
return 0, err
}
diff --git a/client/iface/wgproxy/proxy.go b/client/iface/wgproxy/proxy.go
index 3c8dfd30e..b0033bffa 100644
--- a/client/iface/wgproxy/proxy.go
+++ b/client/iface/wgproxy/proxy.go
@@ -7,7 +7,7 @@ import (
// Proxy is a transfer layer between the relayed connection and the WireGuard
type Proxy interface {
- AddTurnConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error
+ AddRelayedConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error
EndpointAddr() *net.UDPAddr // EndpointAddr returns the address of the WireGuard peer endpoint
Work() // Work start or resume the proxy
Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works.
@@ -18,4 +18,9 @@ type Proxy interface {
RedirectAs(endpoint *net.UDPAddr)
CloseConn() error
SetDisconnectListener(disconnected func())
+
+ // InjectPacket writes a raw packet directly to the remote peer over the underlying transport,
+ // bypassing WireGuard. Used to replay the captured lazyconn handshake initiation. Only the
+ // kernel-mode proxies act on it; the userspace proxy is a no-op since reinjection is kernel-only.
+ InjectPacket(b []byte) error
}
diff --git a/client/iface/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go
index 7f7abcb4a..e34dd3b6b 100644
--- a/client/iface/wgproxy/proxy_linux_test.go
+++ b/client/iface/wgproxy/proxy_linux_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package wgproxy
diff --git a/client/iface/wgproxy/proxy_seed_test.go b/client/iface/wgproxy/proxy_seed_test.go
index 9278029a5..4fb9ed77a 100644
--- a/client/iface/wgproxy/proxy_seed_test.go
+++ b/client/iface/wgproxy/proxy_seed_test.go
@@ -1,4 +1,4 @@
-//go:build !linux
+//go:build !linux || !privileged
package wgproxy
diff --git a/client/iface/wgproxy/proxy_test.go b/client/iface/wgproxy/proxy_test.go
index 1aeab66b7..d86cdbe80 100644
--- a/client/iface/wgproxy/proxy_test.go
+++ b/client/iface/wgproxy/proxy_test.go
@@ -95,7 +95,7 @@ func TestProxyCloseByRemoteConn(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
addr, _ := net.ResolveUDPAddr("udp", "100.108.135.221:51892")
relayedConn := newMockConn()
- err := tt.proxy.AddTurnConn(ctx, addr, relayedConn)
+ err := tt.proxy.AddRelayedConn(ctx, addr, relayedConn)
if err != nil {
t.Errorf("error: %v", err)
}
@@ -157,7 +157,7 @@ func redirectTraffic(t *testing.T, proxy Proxy, wgPort int, endPointAddr *net.UD
_ = relayedServer.Close()
}()
- if err := proxy.AddTurnConn(context.Background(), endPointAddr, relayedConn); err != nil {
+ if err := proxy.AddRelayedConn(context.Background(), endPointAddr, relayedConn); err != nil {
t.Errorf("error: %v", err)
}
defer func() {
diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go
index b52eead25..f0d59cc64 100644
--- a/client/iface/wgproxy/redirect_test.go
+++ b/client/iface/wgproxy/redirect_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package wgproxy
@@ -26,64 +26,6 @@ func compareUDPAddr(addr1, addr2 net.Addr) bool {
return udpAddr1.IP.Equal(udpAddr2.IP) && udpAddr1.Port == udpAddr2.Port
}
-// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses
-func TestRedirectAs_eBPF_IPv4(t *testing.T) {
- wgPort := 51850
- ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
- if err := ebpfProxy.Listen(); err != nil {
- t.Fatalf("failed to initialize ebpf proxy: %v", err)
- }
- defer func() {
- if err := ebpfProxy.Free(); err != nil {
- t.Errorf("failed to free ebpf proxy: %v", err)
- }
- }()
-
- proxy := ebpf.NewProxyWrapper(ebpfProxy)
-
- // NetBird UDP address of the remote peer
- nbAddr := &net.UDPAddr{
- IP: net.ParseIP("100.108.111.177"),
- Port: 38746,
- }
-
- p2pEndpoint := &net.UDPAddr{
- IP: net.ParseIP("192.168.0.56"),
- Port: 51820,
- }
-
- testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
-}
-
-// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses
-func TestRedirectAs_eBPF_IPv6(t *testing.T) {
- wgPort := 51851
- ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
- if err := ebpfProxy.Listen(); err != nil {
- t.Fatalf("failed to initialize ebpf proxy: %v", err)
- }
- defer func() {
- if err := ebpfProxy.Free(); err != nil {
- t.Errorf("failed to free ebpf proxy: %v", err)
- }
- }()
-
- proxy := ebpf.NewProxyWrapper(ebpfProxy)
-
- // NetBird UDP address of the remote peer
- nbAddr := &net.UDPAddr{
- IP: net.ParseIP("100.108.111.177"),
- Port: 38746,
- }
-
- p2pEndpoint := &net.UDPAddr{
- IP: net.ParseIP("fe80::56"),
- Port: 51820,
- }
-
- testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
-}
-
// TestRedirectAs_UDP_IPv4 tests RedirectAs with UDP proxy using IPv4 addresses
func TestRedirectAs_UDP_IPv4(t *testing.T) {
wgPort := 51852
@@ -177,9 +119,9 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *
}
defer relayConn.Close()
- // Add TURN connection to proxy
- if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil {
- t.Fatalf("failed to add TURN connection: %v", err)
+ // Add relayed connection to proxy
+ if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil {
+ t.Fatalf("failed to add relayed connection: %v", err)
}
defer func() {
if err := proxy.CloseConn(); err != nil {
@@ -256,6 +198,64 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *
}
}
+// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses
+func TestRedirectAs_eBPF_IPv4(t *testing.T) {
+ wgPort := 51850
+ ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
+ if err := ebpfProxy.Listen(); err != nil {
+ t.Fatalf("failed to initialize ebpf proxy: %v", err)
+ }
+ defer func() {
+ if err := ebpfProxy.Free(); err != nil {
+ t.Errorf("failed to free ebpf proxy: %v", err)
+ }
+ }()
+
+ proxy := ebpf.NewProxyWrapper(ebpfProxy)
+
+ // NetBird UDP address of the remote peer
+ nbAddr := &net.UDPAddr{
+ IP: net.ParseIP("100.108.111.177"),
+ Port: 38746,
+ }
+
+ p2pEndpoint := &net.UDPAddr{
+ IP: net.ParseIP("192.168.0.56"),
+ Port: 51820,
+ }
+
+ testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
+}
+
+// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses
+func TestRedirectAs_eBPF_IPv6(t *testing.T) {
+ wgPort := 51851
+ ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
+ if err := ebpfProxy.Listen(); err != nil {
+ t.Fatalf("failed to initialize ebpf proxy: %v", err)
+ }
+ defer func() {
+ if err := ebpfProxy.Free(); err != nil {
+ t.Errorf("failed to free ebpf proxy: %v", err)
+ }
+ }()
+
+ proxy := ebpf.NewProxyWrapper(ebpfProxy)
+
+ // NetBird UDP address of the remote peer
+ nbAddr := &net.UDPAddr{
+ IP: net.ParseIP("100.108.111.177"),
+ Port: 38746,
+ }
+
+ p2pEndpoint := &net.UDPAddr{
+ IP: net.ParseIP("fe80::56"),
+ Port: 51820,
+ }
+
+ testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
+}
+
// TestRedirectAs_Multiple_Switches tests switching between multiple endpoints
func TestRedirectAs_Multiple_Switches(t *testing.T) {
wgPort := 51856
@@ -304,8 +304,8 @@ func TestRedirectAs_Multiple_Switches(t *testing.T) {
Port: 38746,
}
- if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil {
- t.Fatalf("failed to add TURN connection: %v", err)
+ if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil {
+ t.Fatalf("failed to add relayed connection: %v", err)
}
defer func() {
if err := proxy.CloseConn(); err != nil {
diff --git a/client/iface/wgproxy/udp/proxy.go b/client/iface/wgproxy/udp/proxy.go
index 6069d1960..a0895c8c7 100644
--- a/client/iface/wgproxy/udp/proxy.go
+++ b/client/iface/wgproxy/udp/proxy.go
@@ -51,12 +51,12 @@ func NewWGUDPProxy(wgPort int, mtu uint16) *WGUDPProxy {
return p
}
-// AddTurnConn
+// AddRelayedConn dials the local WireGuard port and stores the relayed connection.
// The provided Context must be non-nil. If the context expires before
// the connection is complete, an error is returned. Once successfully
// connected, any expiration of the context will not affect the
// connection.
-func (p *WGUDPProxy) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
+func (p *WGUDPProxy) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
dialer := net.Dialer{}
localConn, err := dialer.DialContext(ctx, "udp", fmt.Sprintf(":%d", p.localWGListenPort))
if err != nil {
@@ -147,6 +147,17 @@ func (p *WGUDPProxy) RedirectAs(endpoint *net.UDPAddr) {
p.sendPkg = p.srcFakerConn.SendPkg
}
+// InjectPacket writes b to the remote peer over the underlying transport.
+func (p *WGUDPProxy) InjectPacket(b []byte) error {
+ if p.remoteConn == nil {
+ return errors.New("proxy not started")
+ }
+ if _, err := p.remoteConn.Write(b); err != nil {
+ return err
+ }
+ return nil
+}
+
// CloseConn close the localConn
func (p *WGUDPProxy) CloseConn() error {
if p.cancel == nil {
diff --git a/client/installer.nsis b/client/installer.nsis
index 63bff1c5b..eb2d7d5bd 100644
--- a/client/installer.nsis
+++ b/client/installer.nsis
@@ -6,7 +6,7 @@
!define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls."
!define INSTALLER_NAME "netbird-installer.exe"
!define MAIN_APP_EXE "Netbird"
-!define ICON "ui\\assets\\netbird.ico"
+!define ICON "ui\\build\\windows\\icon.ico"
!define BANNER "ui\\build\\banner.bmp"
!define LICENSE_DATA "..\\LICENSE"
@@ -22,8 +22,6 @@
!define UI_REG_APP_PATH "Software\Microsoft\Windows\CurrentVersion\App Paths\${UI_APP_EXE}"
!define UI_UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UI_APP_NAME}"
-!define AUTOSTART_REG_KEY "Software\Microsoft\Windows\CurrentVersion\Run"
-
!define NETBIRD_DATA_DIR "$COMMONPROGRAMDATA\Netbird"
Unicode True
@@ -79,8 +77,6 @@ ShowInstDetails Show
!insertmacro MUI_PAGE_DIRECTORY
-Page custom AutostartPage AutostartPageLeave
-
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
@@ -97,40 +93,12 @@ UninstPage custom un.DeleteDataPage un.DeleteDataPageLeave
!insertmacro MUI_LANGUAGE "English"
-; Variables for autostart option
-Var AutostartCheckbox
-Var AutostartEnabled
-
; Variables for uninstall data deletion option
Var DeleteDataCheckbox
Var DeleteDataEnabled
######################################################################
-; Function to create the autostart options page
-Function AutostartPage
- !insertmacro MUI_HEADER_TEXT "Startup Options" "Configure how ${APP_NAME} launches with Windows."
-
- nsDialogs::Create 1018
- Pop $0
-
- ${If} $0 == error
- Abort
- ${EndIf}
-
- ${NSD_CreateCheckbox} 0 20u 100% 10u "Start ${APP_NAME} UI automatically when Windows starts"
- Pop $AutostartCheckbox
- ${NSD_Check} $AutostartCheckbox
- StrCpy $AutostartEnabled "1"
-
- nsDialogs::Show
-FunctionEnd
-
-; Function to handle leaving the autostart page
-Function AutostartPageLeave
- ${NSD_GetState} $AutostartCheckbox $AutostartEnabled
-FunctionEnd
-
; Function to create the uninstall data deletion page
Function un.DeleteDataPage
!insertmacro MUI_HEADER_TEXT "Uninstall Options" "Choose whether to delete ${APP_NAME} data."
@@ -201,8 +169,6 @@ Pop $0
Function .onInit
StrCpy $INSTDIR "${INSTALL_DIR}"
-; Default autostart to enabled so silent installs (/S) match the interactive default
-StrCpy $AutostartEnabled "1"
; Pre-0.70.1 installers ran without SetRegView, so their uninstall keys live
; in the 32-bit view. Fall back to it so upgrades still find them.
@@ -260,18 +226,6 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
-; Create autostart registry entry based on checkbox
-DetailPrint "Autostart enabled: $AutostartEnabled"
-${If} $AutostartEnabled == "1"
- WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
- DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
-${Else}
- DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
- ; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
- DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
- DetailPrint "Autostart not enabled by user"
-${EndIf}
-
EnVar::SetHKLM
EnVar::AddValueEx "path" "$INSTDIR"
@@ -280,6 +234,43 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
SectionEnd
+# Install the Microsoft Edge WebView2 runtime if it isn't already present.
+# Macro adapted from Wails3's NSIS template (wails_tools.nsh): a registry
+# probe followed by a silent install of the embedded evergreen bootstrapper.
+# The MicrosoftEdgeWebview2Setup.exe payload is staged next to this script
+# by the sign-pipelines build step (`wails3 generate webview2bootstrapper`).
+!macro nb.webview2runtime
+ SetRegView 64
+ # Per-machine install marker — populated when the runtime ships with
+ # Edge or has been installed by an admin previously.
+ ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
+ ${If} $0 != ""
+ Goto webview2_ok
+ ${EndIf}
+ # Per-user fallback for HKCU installs.
+ ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
+ ${If} $0 != ""
+ Goto webview2_ok
+ ${EndIf}
+
+ SetDetailsPrint both
+ DetailPrint "Installing: WebView2 Runtime"
+ SetDetailsPrint listonly
+
+ InitPluginsDir
+ CreateDirectory "$pluginsdir\webview2bootstrapper"
+ SetOutPath "$pluginsdir\webview2bootstrapper"
+ File "MicrosoftEdgeWebview2Setup.exe"
+ ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
+
+ SetDetailsPrint both
+ webview2_ok:
+!macroend
+
+Section -WebView2
+ !insertmacro nb.webview2runtime
+SectionEnd
+
Section -Post
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
@@ -299,12 +290,6 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
DetailPrint "Terminating Netbird UI process..."
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
-; Remove autostart registry entry
-DetailPrint "Removing autostart registry entry if exists..."
-DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
-DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-
; Handle data deletion based on checkbox
DetailPrint "Checking if user requested data deletion..."
${If} $DeleteDataEnabled == "1"
@@ -326,9 +311,9 @@ DetailPrint "Deleting application files..."
Delete "$INSTDIR\${UI_APP_EXE}"
Delete "$INSTDIR\${MAIN_APP_EXE}"
Delete "$INSTDIR\wintun.dll"
-!if ${ARCH} == "amd64"
+# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove
+# any leftover copy on uninstall so old upgrades don't leave it behind.
Delete "$INSTDIR\opengl32.dll"
-!endif
DetailPrint "Removing application directory..."
RmDir /r "$INSTDIR"
diff --git a/client/internal/acl/manager.go b/client/internal/acl/manager.go
index c54a3e897..cbd9c5ab1 100644
--- a/client/internal/acl/manager.go
+++ b/client/internal/acl/manager.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/hashicorp/go-multierror"
+ "github.com/mitchellh/hashstructure/v2"
log "github.com/sirupsen/logrus"
nberrors "github.com/netbirdio/netbird/client/errors"
@@ -30,11 +31,13 @@ type Manager interface {
// DefaultManager uses firewall manager to handle
type DefaultManager struct {
- firewall firewall.Manager
- ipsetCounter int
- peerRulesPairs map[id.RuleID][]firewall.Rule
- routeRules map[id.RuleID]struct{}
- mutex sync.Mutex
+ firewall firewall.Manager
+ ipsetCounter int
+ peerRulesPairs map[id.RuleID][]firewall.Rule
+ routeRules map[id.RuleID]struct{}
+ previousConfigHash uint64
+ hasAppliedConfig bool
+ mutex sync.Mutex
}
func NewDefaultManager(fm firewall.Manager) *DefaultManager {
@@ -57,6 +60,23 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
return
}
+ // Skip the full rebuild + flush when the inputs that drive the firewall
+ // state are byte-for-byte identical to the last successfully applied
+ // update. Management re-sends the same network map far more often than it
+ // actually changes (account-wide updates, peer meta churn), and rebuilding
+ // every peer/route ACL and flushing the firewall on every such sync is the
+ // dominant client-side cost when nothing changed. Mirrors the same guard the
+ // DNS server already uses (previousConfigHash). Only the fields ApplyFiltering
+ // consumes participate in the hash, so an unrelated map change cannot mask a
+ // real ACL change.
+ hash, err := d.firewallConfigHash(networkMap, dnsRouteFeatureFlag)
+ if err != nil {
+ log.Errorf("unable to hash firewall configuration, applying unconditionally: %v", err)
+ } else if d.hasAppliedConfig && d.previousConfigHash == hash {
+ log.Debugf("not applying the firewall configuration update as there is nothing new (hash: %d)", hash)
+ return
+ }
+
start := time.Now()
defer func() {
total := 0
@@ -70,13 +90,49 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
d.applyPeerACLs(networkMap)
- if err := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag); err != nil {
- log.Errorf("Failed to apply route ACLs: %v", err)
+ routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag)
+ if routeErr != nil {
+ log.Errorf("Failed to apply route ACLs: %v", routeErr)
}
- if err := d.firewall.Flush(); err != nil {
- log.Error("failed to flush firewall rules: ", err)
+ flushErr := d.firewall.Flush()
+ if flushErr != nil {
+ log.Error("failed to flush firewall rules: ", flushErr)
}
+
+ // Only remember the hash once the firewall actually reflects this config.
+ // If applying or flushing failed, leave the previous hash untouched so the
+ // next (possibly identical) update is not skipped and gets a chance to
+ // reconcile the firewall state.
+ if err == nil && routeErr == nil && flushErr == nil {
+ d.previousConfigHash = hash
+ d.hasAppliedConfig = true
+ } else {
+ d.hasAppliedConfig = false
+ }
+}
+
+// firewallConfigHash hashes exactly the inputs ApplyFiltering uses to build the
+// firewall state, so an identical hash means an identical resulting ruleset.
+func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) {
+ return hashstructure.Hash(struct {
+ PeerRules []*mgmProto.FirewallRule
+ PeerRulesIsEmpty bool
+ RouteRules []*mgmProto.RouteFirewallRule
+ RouteRulesIsEmpty bool
+ DNSRouteFeatureFlag bool
+ }{
+ PeerRules: networkMap.GetFirewallRules(),
+ PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(),
+ RouteRules: networkMap.GetRoutesFirewallRules(),
+ RouteRulesIsEmpty: networkMap.GetRoutesFirewallRulesIsEmpty(),
+ DNSRouteFeatureFlag: dnsRouteFeatureFlag,
+ }, hashstructure.FormatV2, &hashstructure.HashOptions{
+ ZeroNil: true,
+ IgnoreZeroValue: true,
+ SlicesAsSets: true,
+ UseStringer: true,
+ })
}
func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) {
@@ -88,13 +144,13 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) {
log.Warn("this peer is connected to a NetBird Management service with an older version. Allowing all traffic from connected peers")
rules = append(rules,
&mgmProto.FirewallRule{
- PeerIP: "0.0.0.0",
+ PeerIP: "0.0.0.0", //nolint:staticcheck
Direction: mgmProto.RuleDirection_IN,
Action: mgmProto.RuleAction_ACCEPT,
Protocol: mgmProto.RuleProtocol_ALL,
},
&mgmProto.FirewallRule{
- PeerIP: "0.0.0.0",
+ PeerIP: "0.0.0.0", //nolint:staticcheck
Direction: mgmProto.RuleDirection_OUT,
Action: mgmProto.RuleAction_ACCEPT,
Protocol: mgmProto.RuleProtocol_ALL,
@@ -351,7 +407,6 @@ func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) st
return fmt.Sprintf("%v:%v:%v:%s:%v", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port, rule.PortInfo)
}
-
// extractRuleIP extracts the peer IP from a firewall rule.
// If sourcePrefixes is populated (new management), decode the first entry and use its address.
// Otherwise fall back to the deprecated PeerIP string field (old management).
diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go
index 408ed992f..8f737706e 100644
--- a/client/internal/acl/manager_test.go
+++ b/client/internal/acl/manager_test.go
@@ -1,12 +1,13 @@
package acl
import (
+ "fmt"
"net/netip"
"testing"
- "github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
"github.com/netbirdio/netbird/client/firewall"
"github.com/netbirdio/netbird/client/iface"
@@ -86,7 +87,7 @@ func TestDefaultManager(t *testing.T) {
networkMap.FirewallRules = append(
networkMap.FirewallRules,
&mgmProto.FirewallRule{
- PeerIP: "10.93.0.3",
+ PeerIP: "10.93.0.3", //nolint:staticcheck
Direction: mgmProto.RuleDirection_IN,
Action: mgmProto.RuleAction_DROP,
Protocol: mgmProto.RuleProtocol_ICMP,
@@ -485,3 +486,149 @@ func TestPortInfoEmpty(t *testing.T) {
})
}
}
+
+// TestApplyFilteringSkipsUnchangedConfig verifies that an identical network map
+// re-applied is recognized as a no-op (hash unchanged), while a real change to
+// any firewall-relevant input forces a re-apply (hash changes). This is the
+// guard that prevents a full ruleset rebuild + flush on every redundant sync.
+func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) {
+ t.Setenv("NB_WG_KERNEL_DISABLED", "true")
+ t.Setenv(firewall.EnvForceUserspaceFirewall, "true")
+
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ ifaceMock := mocks.NewMockIFaceMapper(ctrl)
+ ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes()
+ ifaceMock.EXPECT().SetFilter(gomock.Any())
+ network := netip.MustParsePrefix("172.0.0.1/32")
+ ifaceMock.EXPECT().Name().Return("lo").AnyTimes()
+ ifaceMock.EXPECT().Address().Return(wgaddr.Address{
+ IP: network.Addr(),
+ Network: network,
+ }).AnyTimes()
+ ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes()
+
+ fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU)
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, fw.Close(nil))
+ }()
+
+ acl := NewDefaultManager(fw)
+
+ networkMap := &mgmProto.NetworkMap{
+ FirewallRules: []*mgmProto.FirewallRule{
+ {
+ PeerIP: "10.93.0.1",
+ Direction: mgmProto.RuleDirection_IN,
+ Action: mgmProto.RuleAction_ACCEPT,
+ Protocol: mgmProto.RuleProtocol_TCP,
+ Port: "22",
+ },
+ },
+ FirewallRulesIsEmpty: false,
+ }
+
+ acl.ApplyFiltering(networkMap, false)
+ require.True(t, acl.hasAppliedConfig, "config should be marked applied after first apply")
+ firstHash := acl.previousConfigHash
+ require.NotZero(t, firstHash)
+
+ // Re-applying the identical map must not change the recorded hash: the
+ // expensive rebuild path was skipped.
+ acl.ApplyFiltering(networkMap, false)
+ assert.Equal(t, firstHash, acl.previousConfigHash,
+ "identical re-apply must be a no-op (hash unchanged)")
+
+ // A real change must produce a different hash and re-apply.
+ networkMap.FirewallRules[0].Action = mgmProto.RuleAction_DROP
+ acl.ApplyFiltering(networkMap, false)
+ assert.NotEqual(t, firstHash, acl.previousConfigHash,
+ "changing a rule's action must force a re-apply (hash changed)")
+
+ // The dnsRouteFeatureFlag also participates in the hash.
+ changedHash := acl.previousConfigHash
+ acl.ApplyFiltering(networkMap, true)
+ assert.NotEqual(t, changedHash, acl.previousConfigHash,
+ "flipping dnsRouteFeatureFlag must force a re-apply (hash changed)")
+}
+
+func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap {
+ nm := &mgmProto.NetworkMap{
+ FirewallRulesIsEmpty: peerRules == 0,
+ RoutesFirewallRulesIsEmpty: routeRules == 0,
+ }
+ for i := range peerRules {
+ nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{
+ PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff), //nolint:staticcheck
+ Direction: mgmProto.RuleDirection_IN,
+ Action: mgmProto.RuleAction_ACCEPT,
+ Protocol: mgmProto.RuleProtocol_TCP,
+ Port: fmt.Sprintf("%d", 1024+i%64511),
+ })
+ }
+ for i := range routeRules {
+ nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, &mgmProto.RouteFirewallRule{
+ Destination: fmt.Sprintf("192.168.%d.0/24", i%256),
+ SourceRanges: []string{fmt.Sprintf("10.0.%d.0/24", i%256)},
+ Action: mgmProto.RuleAction_ACCEPT,
+ Protocol: mgmProto.RuleProtocol_ALL,
+ })
+ }
+ return nm
+}
+
+func BenchmarkFirewallConfigHash_Small(b *testing.B) {
+ d := &DefaultManager{}
+ nm := buildNetworkMap(10, 5)
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = d.firewallConfigHash(nm, false)
+ }
+}
+
+func BenchmarkFirewallConfigHash_Medium(b *testing.B) {
+ d := &DefaultManager{}
+ nm := buildNetworkMap(100, 50)
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = d.firewallConfigHash(nm, false)
+ }
+}
+
+func BenchmarkFirewallConfigHash_Large(b *testing.B) {
+ d := &DefaultManager{}
+ nm := buildNetworkMap(1000, 200)
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = d.firewallConfigHash(nm, false)
+ }
+}
+
+// TestFirewallConfigHashDeterministic verifies the hash is stable for equal
+// inputs and order-independent for the rule slices (management does not
+// guarantee rule order).
+func TestFirewallConfigHashDeterministic(t *testing.T) {
+ d := &DefaultManager{}
+
+ nm1 := &mgmProto.NetworkMap{
+ FirewallRules: []*mgmProto.FirewallRule{
+ {PeerIP: "10.0.0.1", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_TCP, Port: "22"},
+ {PeerIP: "10.0.0.2", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_TCP, Port: "80"},
+ },
+ }
+ // Same rules, reversed order.
+ nm2 := &mgmProto.NetworkMap{
+ FirewallRules: []*mgmProto.FirewallRule{
+ nm1.FirewallRules[1],
+ nm1.FirewallRules[0],
+ },
+ }
+
+ h1, err := d.firewallConfigHash(nm1, false)
+ require.NoError(t, err)
+ h2, err := d.firewallConfigHash(nm2, false)
+ require.NoError(t, err)
+ assert.Equal(t, h1, h2, "hash must be order-independent for rule slices")
+}
diff --git a/client/internal/acl/mocks/iface_mapper.go b/client/internal/acl/mocks/iface_mapper.go
index 95d5a2c58..f8cca1c2d 100644
--- a/client/internal/acl/mocks/iface_mapper.go
+++ b/client/internal/acl/mocks/iface_mapper.go
@@ -7,7 +7,7 @@ package mocks
import (
reflect "reflect"
- gomock "github.com/golang/mock/gomock"
+ gomock "go.uber.org/mock/gomock"
wgdevice "golang.zx2c4.com/wireguard/device"
"github.com/netbirdio/netbird/client/iface/device"
diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go
index afc8ee77f..b3a9e1158 100644
--- a/client/internal/auth/auth.go
+++ b/client/internal/auth/auth.go
@@ -3,6 +3,7 @@ package auth
import (
"context"
"net/url"
+ "strings"
"sync"
"time"
@@ -21,6 +22,25 @@ import (
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
+// peerLoginExpiredMsg is the exact phrase the management server returns
+// when a previously SSO-enrolled peer's login has expired. Sourced from
+// shared/management/status/error.go (NewPeerLoginExpiredError). Matched
+// by substring so a future server-side rewording that keeps the phrase
+// still triggers the friendly fallback in Login().
+const peerLoginExpiredMsg = "peer login has expired"
+
+// errSetupKeyOnSSOExpiredPeer replaces the raw management error when the
+// user runs `netbird login -k ` against a peer that was
+// originally enrolled via SSO. Wrapped in a PermissionDenied gRPC status
+// so callers' existing isPermissionDenied / isAuthError checks still
+// classify it correctly (early-exit from retry backoff, StatusNeedsLogin
+// in the server state machine).
+var errSetupKeyOnSSOExpiredPeer = status.Error(
+ codes.PermissionDenied,
+ "this peer was originally enrolled via SSO and its session has expired. "+
+ "Setup keys can only enrol new peers — run `netbird up` (interactive SSO) to re-login.",
+)
+
// Auth manages authentication operations with the management server
// It maintains a long-lived connection and automatically handles reconnection with backoff
type Auth struct {
@@ -118,26 +138,37 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection
// This avoids creating a new connection to the management server
-func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool) (OAuthFlow, error) {
+func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) {
var flow OAuthFlow
- var err error
- err = a.withRetry(ctx, func(client *mgm.GrpcClient) error {
+ err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
if forceDeviceAuth {
- flow, err = a.getDeviceFlow(client)
- return err
+ deviceFlow, err := a.getDeviceFlow(client)
+ if err != nil {
+ return err
+ }
+ deviceFlow.SetLoginHint(hint)
+ flow = deviceFlow
+ return nil
}
// Try PKCE flow first
- flow, err = a.getPKCEFlow(client)
+ pkceFlow, err := a.getPKCEFlow(client)
if err != nil {
// If PKCE not supported, try Device flow
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
- flow, err = a.getDeviceFlow(client)
- return err
+ deviceFlow, err := a.getDeviceFlow(client)
+ if err != nil {
+ return err
+ }
+ deviceFlow.SetLoginHint(hint)
+ flow = deviceFlow
+ return nil
}
return err
}
+ pkceFlow.SetLoginHint(hint)
+ flow = pkceFlow
return nil
})
@@ -184,6 +215,15 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
log.Debugf("peer registration required")
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
if err != nil {
+ // The peer pub-key is already on file with the management
+ // server (originally enrolled via SSO) and the session has
+ // expired. The setup-key path can only enrol new peers, so
+ // retrying with -k will keep failing. Replace the raw mgm
+ // message with an actionable hint that tells the user to
+ // re-authenticate via SSO instead.
+ if setupKey != "" && jwtToken == "" && isPeerLoginExpired(err) {
+ err = errSetupKeyOnSSOExpiredPeer
+ }
isAuthError = isPermissionDenied(err)
return err
}
@@ -322,7 +362,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
a.config.BlockLANAccess,
a.config.BlockInbound,
a.config.DisableIPv6,
- a.config.LazyConnectionEnabled,
+ a.config.SyncMessageVersion,
a.config.EnableSSHRoot,
a.config.EnableSSHSFTP,
a.config.EnableSSHLocalPortForwarding,
@@ -474,3 +514,16 @@ func isLoginNeeded(err error) bool {
func isRegistrationNeeded(err error) bool {
return isPermissionDenied(err)
}
+
+// isPeerLoginExpired reports whether err is the management server's
+// "peer login has expired" PermissionDenied response. Used by Login to
+// detect the case where the caller passed a setup-key but the peer is
+// actually an SSO-enrolled record whose session needs refreshing — the
+// setup-key path cannot help there.
+func isPeerLoginExpired(err error) bool {
+ if !isPermissionDenied(err) {
+ return false
+ }
+ s, _ := status.FromError(err)
+ return strings.Contains(s.Message(), peerLoginExpiredMsg)
+}
diff --git a/client/internal/auth/auth_test.go b/client/internal/auth/auth_test.go
new file mode 100644
index 000000000..e393beccb
--- /dev/null
+++ b/client/internal/auth/auth_test.go
@@ -0,0 +1,80 @@
+package auth
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+func TestIsPeerLoginExpired(t *testing.T) {
+ cases := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {
+ name: "nil",
+ err: nil,
+ want: false,
+ },
+ {
+ name: "plain error (not a gRPC status)",
+ err: errors.New("network read: connection reset"),
+ want: false,
+ },
+ {
+ name: "PermissionDenied with different message",
+ err: status.Error(codes.PermissionDenied, "user is blocked"),
+ want: false,
+ },
+ {
+ name: "Unauthenticated with the expected phrase",
+ // Wrong status code — must still return false.
+ err: status.Error(codes.Unauthenticated, "peer login has expired, please log in once more"),
+ want: false,
+ },
+ {
+ name: "exact server message",
+ err: status.Error(codes.PermissionDenied, "peer login has expired, please log in once more"),
+ want: true,
+ },
+ {
+ name: "phrase as substring",
+ // Future-proofing: if mgm reworords but keeps the phrase,
+ // the friendly fallback must still kick in.
+ err: status.Error(codes.PermissionDenied, "session refused: peer login has expired (account=foo)"),
+ want: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := isPeerLoginExpired(tc.err); got != tc.want {
+ t.Fatalf("isPeerLoginExpired(%v) = %v, want %v", tc.err, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestErrSetupKeyOnSSOExpiredPeer(t *testing.T) {
+ // Sentinel must surface as PermissionDenied so the upstream
+ // isPermissionDenied / isAuthError checks classify it correctly
+ // (short-circuit retry backoff, set StatusNeedsLogin).
+ if !isPermissionDenied(errSetupKeyOnSSOExpiredPeer) {
+ t.Fatalf("errSetupKeyOnSSOExpiredPeer must be a PermissionDenied gRPC error")
+ }
+
+ // Message must actually mention SSO and `netbird up` so it is
+ // actionable for the end user. Loose substring checks keep the
+ // test resilient to copy edits.
+ s, _ := status.FromError(errSetupKeyOnSSOExpiredPeer)
+ msg := strings.ToLower(s.Message())
+ for _, want := range []string{"sso", "netbird up"} {
+ if !strings.Contains(msg, want) {
+ t.Errorf("sentinel message should contain %q, got %q", want, s.Message())
+ }
+ }
+}
diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go
index e33765300..9dec7cf53 100644
--- a/client/internal/auth/device_flow.go
+++ b/client/internal/auth/device_flow.go
@@ -259,12 +259,18 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
ticker := time.NewTicker(interval)
defer ticker.Stop()
+ log.Infof("device flow: waiting for user authorization, polling token endpoint every %s, code expires in %s", interval, timeout)
+
+ start := time.Now()
+ polls := 0
+
for {
select {
case <-waitCtx.Done():
return TokenInfo{}, waitCtx.Err()
case <-ticker.C:
+ polls++
tokenResponse, err := d.requestToken(info)
if err != nil {
return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err)
@@ -272,10 +278,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
if tokenResponse.Error != "" {
if tokenResponse.Error == "authorization_pending" {
+ log.Tracef("device flow: authorization still pending after poll %d", polls)
continue
} else if tokenResponse.Error == "slow_down" {
interval += (3 * time.Second)
ticker.Reset(interval)
+ log.Infof("device flow: IdP requested slow_down, polling interval increased to %s", interval)
continue
}
@@ -291,11 +299,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
UseIDToken: d.providerConfig.UseIDToken,
}
- err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
+ err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
if err != nil {
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
}
+ log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
return tokenInfo, err
}
}
diff --git a/client/internal/auth/oauth.go b/client/internal/auth/oauth.go
index a50a2ce6f..91329c98b 100644
--- a/client/internal/auth/oauth.go
+++ b/client/internal/auth/oauth.go
@@ -97,9 +97,7 @@ func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
}
- if hint != "" {
- pkceFlowInfo.SetLoginHint(hint)
- }
+ pkceFlowInfo.SetLoginHint(hint)
return pkceFlowInfo, nil
}
@@ -127,9 +125,7 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
}
}
- if hint != "" {
- deviceFlowInfo.SetLoginHint(hint)
- }
+ deviceFlowInfo.SetLoginHint(hint)
return deviceFlowInfo, nil
}
diff --git a/client/internal/auth/pending_flow.go b/client/internal/auth/pending_flow.go
new file mode 100644
index 000000000..daeb18bc2
--- /dev/null
+++ b/client/internal/auth/pending_flow.go
@@ -0,0 +1,89 @@
+package auth
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+// PendingFlow stores an in-progress OAuth flow between the RPC that
+// initiates it (returns the verification URI to the UI) and the RPC
+// that waits for the user to complete it. The flow handle, the
+// device-code info, and the absolute expiry are kept together so the
+// waiting RPC can validate the device code and reuse the same flow.
+//
+// PendingFlow is safe for concurrent use; callers must not access the
+// stored fields directly.
+type PendingFlow struct {
+ mu sync.Mutex
+ flow OAuthFlow
+ info AuthFlowInfo
+ expiresAt time.Time
+ waitCancel context.CancelFunc
+}
+
+// NewPendingFlow returns an empty PendingFlow ready to be populated by Set.
+func NewPendingFlow() *PendingFlow {
+ return &PendingFlow{}
+}
+
+// Set stores the flow and its authorization info, computing the absolute
+// expiry from info.ExpiresIn (seconds, as returned by the IdP).
+func (p *PendingFlow) Set(flow OAuthFlow, info AuthFlowInfo) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.flow = flow
+ p.info = info
+ p.expiresAt = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second)
+}
+
+// Get returns the stored flow, info, and whether a flow is currently
+// pending. Returns (nil, zero, false) after Clear or before Set.
+func (p *PendingFlow) Get() (OAuthFlow, AuthFlowInfo, bool) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.flow == nil {
+ return nil, AuthFlowInfo{}, false
+ }
+ return p.flow, p.info, true
+}
+
+// ExpiresAt returns the absolute expiry of the pending flow. Returns
+// the zero time when no flow is pending.
+func (p *PendingFlow) ExpiresAt() time.Time {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.expiresAt
+}
+
+// SetWaitCancel records the cancel function for the goroutine currently
+// blocked in WaitToken so a new RequestAuth can preempt it.
+func (p *PendingFlow) SetWaitCancel(cancel context.CancelFunc) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.waitCancel = cancel
+}
+
+// CancelWait invokes and clears the stored wait-cancel, if any. Safe to
+// call when no wait is in progress.
+func (p *PendingFlow) CancelWait() {
+ p.mu.Lock()
+ cancel := p.waitCancel
+ p.waitCancel = nil
+ p.mu.Unlock()
+ if cancel != nil {
+ cancel()
+ }
+}
+
+// Clear resets the pending flow to empty. Any stored wait-cancel is
+// dropped without being invoked — call CancelWait first if the waiting
+// goroutine must be stopped.
+func (p *PendingFlow) Clear() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.flow = nil
+ p.info = AuthFlowInfo{}
+ p.expiresAt = time.Time{}
+ p.waitCancel = nil
+}
diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go
index 84fa8a214..be64cc6a8 100644
--- a/client/internal/auth/pkce_flow.go
+++ b/client/internal/auth/pkce_flow.go
@@ -188,6 +188,8 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
+ log.Infof("pkce flow: waiting for authorization callback on %s, timeout %s", p.oAuthConfig.RedirectURL, timeout)
+
tokenChan := make(chan *oauth2.Token, 1)
errChan := make(chan error, 1)
@@ -221,6 +223,7 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo
func (p *PKCEAuthorizationFlow) startServer(server *http.Server, tokenChan chan<- *oauth2.Token, errChan chan<- error) {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
+ log.Infof("pkce flow: received authorization callback from IdP")
cert := p.providerConfig.ClientCertPair
if cert != nil {
tr := &http.Transport{
@@ -271,11 +274,18 @@ func (p *PKCEAuthorizationFlow) handleRequest(req *http.Request) (*oauth2.Token,
return nil, fmt.Errorf("authentication failed: missing code")
}
- return p.oAuthConfig.Exchange(
+ exchangeStart := time.Now()
+ token, err := p.oAuthConfig.Exchange(
req.Context(),
code,
oauth2.SetAuthURLParam("code_verifier", p.codeVerifier),
)
+ if err != nil {
+ return nil, err
+ }
+
+ log.Infof("pkce flow: authorization code exchanged for token in %s", time.Since(exchangeStart).Round(time.Millisecond))
+ return token, nil
}
func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, error) {
@@ -296,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
audience = p.providerConfig.ClientID
}
- if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil {
+ if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil {
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
}
@@ -310,6 +320,11 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
return tokenInfo, nil
}
+// parseEmailFromIDToken extracts the email (or name) claim from an ID token
+// without verifying its signature. The value is best-effort and used only as a
+// UX convenience (login hint prefill and display); it never drives an
+// authorization decision. The authoritative identity is established server-side
+// from the signature-verified token.
func parseEmailFromIDToken(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
diff --git a/client/internal/auth/sessionwatch/event.go b/client/internal/auth/sessionwatch/event.go
new file mode 100644
index 000000000..3e55b26dd
--- /dev/null
+++ b/client/internal/auth/sessionwatch/event.go
@@ -0,0 +1,82 @@
+package sessionwatch
+
+import (
+ "strconv"
+ "time"
+)
+
+// internal event kinds are no longer exposed: the watcher drives the Sink
+// directly (NotifyStateChange on deadline change/clear, PublishEvent at
+// each warning lead). Tests use a mock Sink to observe what the watcher
+// emits.
+
+// Metadata keys attached by the daemon to session-warning SystemEvents.
+// The UI tray reads these to build a locale-aware notification without
+// relying on the daemon's locale-less UserMessage string, and to
+// disambiguate the T-WarningLead notification from the T-FinalWarningLead
+// fallback that auto-opens the SessionAboutToExpire dialog.
+const (
+ // MetaSessionWarning is set to "true" on both warning events (T-10 and
+ // T-2) so the UI can detect a session-warning SystemEvent without
+ // matching on the message text. Use MetaSessionFinal to distinguish
+ // the two.
+ MetaSessionWarning = "session_warning"
+ // MetaSessionFinal is set to "true" on the T-FinalWarningLead event
+ // only. Consumers that need to auto-open the SessionAboutToExpire
+ // dialog gate on this; T-WarningLead events leave the field unset.
+ MetaSessionFinal = "session_final_warning"
+ // MetaSessionExpiresAt carries the absolute UTC deadline encoded with
+ // FormatExpiresAt; consumers must decode with ParseExpiresAt so a
+ // future format change stays a single edit.
+ MetaSessionExpiresAt = "session_expires_at"
+ // MetaSessionLeadMinutes carries the lead in whole minutes (WarningLead
+ // for the T-10 event, FinalWarningLead for the T-2 event) so the UI
+ // can show "expires in ~N minutes" without hardcoding either constant.
+ MetaSessionLeadMinutes = "lead_minutes"
+ // MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION
+ // SystemEvent the daemon emits when it discards a deadline from the
+ // management server (pre-epoch, too far in the future, or past the
+ // clock-skew tolerance). The value is the rejection reason string.
+ // userMessage is left empty; the UI detects the event via this key
+ // and builds a localized notification — same pattern as the session
+ // warnings above.
+ MetaSessionDeadlineRejected = "session_deadline_rejected"
+)
+
+// expiresAtLayout is the wire format used for MetaSessionExpiresAt.
+// Producer and consumers both go through FormatExpiresAt/ParseExpiresAt
+// so this layout stays a single source of truth.
+const expiresAtLayout = time.RFC3339
+
+// FormatExpiresAt encodes a deadline for MetaSessionExpiresAt. Always
+// emits UTC so a consumer in another timezone reads the same wall-clock
+// deadline.
+func FormatExpiresAt(t time.Time) string {
+ return t.UTC().Format(expiresAtLayout)
+}
+
+// ParseExpiresAt decodes the MetaSessionExpiresAt value back to a UTC
+// time. Returns an error when the field is empty or malformed; the
+// caller decides whether to fall back (zero value) or propagate.
+func ParseExpiresAt(s string) (time.Time, error) {
+ t, err := time.Parse(expiresAtLayout, s)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return t.UTC(), nil
+}
+
+// FormatLeadMinutes encodes a lead duration for MetaSessionLeadMinutes
+// as the integer count of whole minutes. Sub-minute residuals are
+// truncated — the field is informational ("expires in ~N minutes") and
+// fractional minutes don't change what the UI displays.
+func FormatLeadMinutes(d time.Duration) string {
+ return strconv.Itoa(int(d / time.Minute))
+}
+
+// ParseLeadMinutes decodes a MetaSessionLeadMinutes value. Returns 0
+// and the parse error for malformed input; consumers that prefer a
+// silent fallback can simply ignore the error.
+func ParseLeadMinutes(s string) (int, error) {
+ return strconv.Atoi(s)
+}
diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go
new file mode 100644
index 000000000..e685c28d0
--- /dev/null
+++ b/client/internal/auth/sessionwatch/watcher.go
@@ -0,0 +1,382 @@
+// Package sessionwatch tracks the SSO session expiry deadline that the
+// management server publishes via LoginResponse / SyncResponse and fires
+// two warning events at fixed lead times before expiry: an interactive
+// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
+// fallback dialog.
+//
+// The watcher is idempotent: Update may be called as often as the network
+// map snapshots arrive. Repeating the same deadline is a no-op; a new
+// deadline reschedules the timers and arms a fresh warning cycle.
+//
+// Warning firing is edge-detected. Each unique deadline value fires each
+// warning callback at most once.
+package sessionwatch
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ cProto "github.com/netbirdio/netbird/client/proto"
+)
+
+const (
+ maxPastHorizon = 30 * 24 * time.Hour
+
+ // maxDeadlineHorizon caps how far in the future an accepted deadline
+ // can sit. A timestamp beyond this is almost certainly a protocol
+ // glitch, and silently arming a 100-year timer would hide the bug.
+ maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
+
+ // WarningLead is how far before expiry the first (interactive)
+ // warning fires. Drives the T-10 OS notification with
+ // Extend/Dismiss actions.
+ WarningLead = 10 * time.Minute
+
+ // FinalWarningLead is how far before expiry the fallback final
+ // warning fires. Drives the auto-opened SessionAboutToExpire dialog,
+ // but only when the user has not dismissed the T-WarningLead warning
+ // for the same deadline. Must be strictly less than WarningLead.
+ FinalWarningLead = 2 * time.Minute
+)
+
+var (
+ // ErrDeadlineBeforeEpoch is returned by Update when the supplied
+ // deadline pre-dates 1970-01-01.
+ ErrDeadlineBeforeEpoch = errors.New("session deadline before unix epoch")
+
+ // ErrDeadlineTooFarFuture is returned by Update when the supplied
+ // deadline is more than maxDeadlineHorizon in the future.
+ ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
+
+ // ErrDeadlineInPast is returned by Update when the supplied deadline
+ // is more than maxPastHorizon in the past.
+ ErrDeadlineInPast = errors.New("session deadline in the past")
+)
+
+// StatusRecorder is the side-effect surface the watcher drives on every
+// state transition. Production wires this to peer.Status (SetSessionExpiresAt
+// for deadline change/clear, PublishEvent for the two warnings); tests pass
+// a fake recorder so the same surface is observable without an engine.
+//
+// While the watcher runs, it owns the deadline propagated to the recorder:
+// every set, clear and sanity-check rejection routes the value through
+// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can
+// never drift from the watcher's timer state. (SetSessionExpiresAt fans
+// out its own state-change notification, so no separate notify is needed.)
+// The recorder is server-scoped and outlives this engine-scoped watcher;
+// Close deliberately leaves the recorder value in place so transient engine
+// restarts don't blank it — the client run loop clears it on real teardown.
+//
+// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
+// composes the metadata internally so the wire format (MetaSession*) is
+// owned by sessionwatch, not the caller.
+type StatusRecorder interface {
+ SetSessionExpiresAt(deadline time.Time)
+ PublishEvent(
+ severity cProto.SystemEvent_Severity,
+ category cProto.SystemEvent_Category,
+ message string,
+ userMessage string,
+ metadata map[string]string,
+ )
+}
+
+// Watcher observes the latest session deadline and fires two warnings
+// before it expires: the interactive T-WarningLead notification, and the
+// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
+// the first one for the same deadline). Safe for concurrent use.
+type Watcher struct {
+ lead time.Duration
+ finalLead time.Duration
+
+ mu sync.Mutex
+ current time.Time
+ timer *time.Timer
+ finalTimer *time.Timer
+ firedAt time.Time // deadline value the T-WarningLead callback last fired against
+ finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against
+ dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
+ closed bool
+ recorder StatusRecorder
+}
+
+// New returns a watcher with the package defaults WarningLead and
+// FinalWarningLead. Pass nil for recorder to silence side effects (handy
+// in unit tests that exercise sanity checks without observing the publish
+// path).
+func New(recorder StatusRecorder) *Watcher {
+ return NewWithLeads(WarningLead, FinalWarningLead, recorder)
+}
+
+// NewWithLeads returns a watcher with custom lead times. Useful for tests.
+// final must be strictly less than lead; otherwise both timers fire in the
+// wrong order or simultaneously and the UI flow breaks. A zero final lead
+// disables the final-warning timer entirely (see armTimerLocked) so a
+// millisecond-scale deadline doesn't flush both timers in one tick.
+func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
+ return &Watcher{
+ lead: lead,
+ finalLead: final,
+ recorder: recorder,
+ }
+}
+
+// Update sets the latest deadline. Pass the zero time to clear (e.g. when
+// a Sync push from the server omits the field because login expiration
+// was disabled).
+//
+// Same-value updates are no-ops. A different non-zero value cancels any
+// pending timer, resets the "already fired" guards, and — when the
+// deadline lies in the future — arms fresh warning timers. A deadline
+// already in the past (within maxPastHorizon) is recorded as-is with no
+// timers: the session has expired and consumers render it that way.
+//
+// Returns one of the sentinel Err* values when the deadline fails the
+// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
+// In every error case the watcher first clears its state so it stays
+// consistent with what the caller will push into its other sinks (e.g.
+// applySessionDeadline forces a zero deadline into the status recorder
+// after a non-nil error).
+func (w *Watcher) Update(deadline time.Time) error {
+ w.mu.Lock()
+ if w.closed {
+ w.mu.Unlock()
+ return nil
+ }
+
+ if deadline.IsZero() {
+ w.clearLocked()
+ return nil
+ }
+
+ now := time.Now()
+ switch {
+ case deadline.Before(time.Unix(0, 0)):
+ w.clearLocked()
+ return fmt.Errorf("%w: %v", ErrDeadlineBeforeEpoch, deadline)
+ case deadline.After(now.Add(maxDeadlineHorizon)):
+ w.clearLocked()
+ return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
+ case deadline.Before(now.Add(-maxPastHorizon)):
+ w.clearLocked()
+ return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
+ }
+
+ if deadline.Equal(w.current) {
+ w.mu.Unlock()
+ return nil
+ }
+
+ w.stopTimerLocked()
+ w.current = deadline
+ // Reset every per-deadline guard so a refreshed deadline arms a fresh
+ // warning cycle: both edge triggers and the user Dismiss decision
+ // (the user agreed to the old deadline expiring; a new deadline
+ // restarts the contract).
+ w.firedAt = time.Time{}
+ w.finalFiredAt = time.Time{}
+ w.dismissedAt = time.Time{}
+
+ if deadline.After(now) {
+ w.armTimerLocked(deadline)
+ }
+ recorder := w.recorder
+ w.mu.Unlock()
+ if recorder != nil {
+ recorder.SetSessionExpiresAt(deadline)
+ }
+ log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
+ return nil
+}
+
+// Deadline returns the most recently observed deadline. Zero when no
+// deadline is currently tracked.
+func (w *Watcher) Deadline() time.Time {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.current
+}
+
+// Dismiss records the user's "Dismiss" action against the current deadline
+// and suppresses the upcoming final-warning callback for that deadline.
+// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
+// deadline resets the dismissal so the final-warning cycle re-arms.
+//
+// No-op when the watcher holds no deadline or has been closed.
+func (w *Watcher) Dismiss() {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.closed || w.current.IsZero() {
+ return
+ }
+ if w.dismissedAt.Equal(w.current) {
+ return
+ }
+ w.dismissedAt = w.current
+ // Cancel the armed final-warning timer eagerly. fireFinal would also
+ // gate on dismissedAt, but stopping the timer avoids a wakeup with
+ // nothing to do and makes the intent visible.
+ if w.finalTimer != nil {
+ w.finalTimer.Stop()
+ w.finalTimer = nil
+ }
+ log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
+}
+
+// Close stops any pending timer. Update calls after Close are ignored.
+// The recorder keeps its deadline: the watcher is engine-scoped and closes
+// on every engine restart (network change, sleep/wake, stream errors)
+// while the SSO deadline stays valid across those, so clearing here would
+// blank the UI's "expires in" row on every transient reconnect. The
+// client run loop clears the server-scoped recorder when it exits for
+// real (Down, profile switch, permanent login failure).
+func (w *Watcher) Close() {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.closed {
+ return
+ }
+ w.closed = true
+ w.stopTimerLocked()
+ w.current = time.Time{}
+ w.firedAt = time.Time{}
+ w.finalFiredAt = time.Time{}
+ w.dismissedAt = time.Time{}
+}
+
+// clearLocked drops the tracked deadline and notifies the recorder so
+// downstream consumers (SubscribeStatus stream, UI) drop their anchor.
+// The caller must hold w.mu; this helper releases it before invoking
+// the recorder.
+func (w *Watcher) clearLocked() {
+ if w.current.IsZero() {
+ w.mu.Unlock()
+ return
+ }
+ w.stopTimerLocked()
+ w.current = time.Time{}
+ w.firedAt = time.Time{}
+ w.finalFiredAt = time.Time{}
+ w.dismissedAt = time.Time{}
+ recorder := w.recorder
+ w.mu.Unlock()
+ if recorder != nil {
+ recorder.SetSessionExpiresAt(time.Time{})
+ }
+ log.Infof("auth session deadline cleared")
+}
+
+func (w *Watcher) stopTimerLocked() {
+ if w.timer != nil {
+ w.timer.Stop()
+ w.timer = nil
+ }
+ if w.finalTimer != nil {
+ w.finalTimer.Stop()
+ w.finalTimer = nil
+ }
+}
+
+func (w *Watcher) armTimerLocked(deadline time.Time) {
+ w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) })
+ // finalLead <= 0 disables the final-warning timer entirely. Used by
+ // tests that predate the final-warning fallback so a millisecond-scale
+ // deadline does not flush both timers at once.
+ if w.finalLead > 0 {
+ w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) })
+ }
+}
+
+func (w *Watcher) fire(armedFor time.Time) {
+ w.mu.Lock()
+ if w.closed || !w.current.Equal(armedFor) {
+ // Deadline moved while we were waiting (e.g. a successful extend).
+ // The reschedule path armed a fresh timer; this one is stale.
+ w.mu.Unlock()
+ return
+ }
+ if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) {
+ w.mu.Unlock()
+ return
+ }
+ w.firedAt = armedFor
+ recorder := w.recorder
+ w.mu.Unlock()
+ if recorder == nil {
+ return
+ }
+ log.Infof("auth session expiry soon warning fired")
+ publishWarning(recorder, armedFor, false)
+}
+
+// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra
+// dismiss-gate: if the user dismissed the T-WarningLead notification for
+// this deadline, the final warning is suppressed entirely.
+func (w *Watcher) fireFinal(armedFor time.Time) {
+ w.mu.Lock()
+ if w.closed || !w.current.Equal(armedFor) {
+ w.mu.Unlock()
+ return
+ }
+ if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) {
+ w.mu.Unlock()
+ return
+ }
+ if w.dismissedAt.Equal(armedFor) {
+ w.mu.Unlock()
+ log.Infof("auth session final-warning skipped (dismissed by user)")
+ return
+ }
+ w.finalFiredAt = armedFor
+ recorder := w.recorder
+ w.mu.Unlock()
+ if recorder == nil {
+ return
+ }
+ log.Infof("auth session final-warning fired")
+ publishWarning(recorder, armedFor, true)
+}
+
+// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
+// past it dispatches on the next scheduler tick so a state-change recorder
+// notification (invoked after w.mu is released) lands first. Caller must
+// hold w.mu.
+func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer {
+ delay := time.Until(fireAt)
+ if delay <= 0 {
+ return time.AfterFunc(0, cb)
+ }
+ return time.AfterFunc(delay, cb)
+}
+
+// publishWarning composes the SystemEvent for a watcher-fired warning and
+// pushes it through the recorder. Severity is CRITICAL on both — bypassing
+// the user's Notifications toggle is deliberate: missing the warning
+// window forces the post-mortem SessionExpired flow (tunnel torn down,
+// lock icon, manual re-login), which is the UX we are trying to avoid.
+func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
+ lead := WarningLead
+ message := "session expiry warning"
+ meta := map[string]string{
+ MetaSessionWarning: "true",
+ MetaSessionExpiresAt: FormatExpiresAt(deadline),
+ }
+ if final {
+ lead = FinalWarningLead
+ message = "session expiry final warning"
+ meta[MetaSessionFinal] = "true"
+ }
+ meta[MetaSessionLeadMinutes] = FormatLeadMinutes(lead)
+
+ recorder.PublishEvent(
+ cProto.SystemEvent_CRITICAL,
+ cProto.SystemEvent_AUTHENTICATION,
+ message,
+ "",
+ meta,
+ )
+}
diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go
new file mode 100644
index 000000000..4b49a94b6
--- /dev/null
+++ b/client/internal/auth/sessionwatch/watcher_test.go
@@ -0,0 +1,529 @@
+package sessionwatch
+
+import (
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ cProto "github.com/netbirdio/netbird/client/proto"
+)
+
+// fakeRecorder satisfies StatusRecorder and records every call so tests
+// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent
+// land in the same ordered events slice (with the Kind distinguishing
+// them) so tests that care about ordering still work. lastDeadline holds
+// the most recent value passed to SetSessionExpiresAt so tests can assert
+// the recorder ended up cleared/set as expected.
+type fakeRecorder struct {
+ mu sync.Mutex
+ events []event
+ lastDeadline time.Time
+}
+
+type eventKind int
+
+const (
+ stateChange eventKind = iota
+ publish
+)
+
+type event struct {
+ kind eventKind
+ // Set only for publish events.
+ severity cProto.SystemEvent_Severity
+ category cProto.SystemEvent_Category
+ message string
+ meta map[string]string
+}
+
+// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op,
+// a real change records the new value and fans out a state-change (the
+// production recorder calls notifyStateChange internally). The baseline
+// is the zero time, so an initial clear before any deadline is set emits
+// nothing — matching the real recorder.
+func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.lastDeadline.Equal(deadline) {
+ return
+ }
+ r.lastDeadline = deadline
+ r.events = append(r.events, event{kind: stateChange})
+}
+
+func (r *fakeRecorder) deadline() time.Time {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.lastDeadline
+}
+
+func (r *fakeRecorder) PublishEvent(
+ severity cProto.SystemEvent_Severity,
+ category cProto.SystemEvent_Category,
+ message string,
+ _ string,
+ metadata map[string]string,
+) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.events = append(r.events, event{
+ kind: publish,
+ severity: severity,
+ category: category,
+ message: message,
+ meta: metadata,
+ })
+}
+
+func (r *fakeRecorder) snapshot() []event {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ out := make([]event, len(r.events))
+ copy(out, r.events)
+ return out
+}
+
+func (e event) isFinalWarning() bool {
+ return e.kind == publish && e.meta[MetaSessionFinal] == "true"
+}
+
+func (e event) isWarning() bool {
+ return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true"
+}
+
+func countWhere(events []event, pred func(event) bool) int {
+ n := 0
+ for _, e := range events {
+ if pred(e) {
+ n++
+ }
+ }
+ return n
+}
+
+func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
+ t.Helper()
+ deadline := time.Now().Add(500 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ if got := r.snapshot(); len(got) >= want {
+ return got
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ got := r.snapshot()
+ t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got)
+ return nil
+}
+
+// newWatcher builds a watcher with the final timer disabled (finalLead=0),
+// matching the lead-only behaviour the pre-final-warning tests assume.
+func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
+ return NewWithLeads(lead, 0, r)
+}
+
+func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(50*time.Millisecond, r)
+ defer w.Close()
+
+ _ = w.Update(time.Time{})
+
+ if got := r.snapshot(); len(got) != 0 {
+ t.Fatalf("expected no events on initial zero, got %+v", got)
+ }
+}
+
+func TestUpdateNonZeroFiresStateChange(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(50*time.Millisecond, r)
+ defer w.Close()
+
+ d := time.Now().Add(time.Hour)
+ _ = w.Update(d)
+
+ events := waitForEvents(t, r, 1)
+ if events[0].kind != stateChange {
+ t.Fatalf("expected stateChange, got %+v", events[0])
+ }
+ if !w.Deadline().Equal(d) {
+ t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d)
+ }
+}
+
+func TestSameDeadlineIsNoop(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(50*time.Millisecond, r)
+ defer w.Close()
+
+ d := time.Now().Add(time.Hour)
+ _ = w.Update(d)
+ _ = w.Update(d)
+ _ = w.Update(d)
+
+ events := waitForEvents(t, r, 1)
+ if len(events) != 1 {
+ t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events)
+ }
+}
+
+func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
+ r := &fakeRecorder{}
+ lead := 50 * time.Millisecond
+ w := newWatcher(lead, r)
+ defer w.Close()
+
+ // Deadline 80ms out — warning should fire after ~30ms.
+ d := time.Now().Add(80 * time.Millisecond)
+ _ = w.Update(d)
+
+ events := waitForEvents(t, r, 2)
+ if events[0].kind != stateChange {
+ t.Fatalf("event[0] should be stateChange, got %+v", events[0])
+ }
+ if !events[1].isWarning() {
+ t.Fatalf("event[1] should be a warning publish, got %+v", events[1])
+ }
+}
+
+func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(time.Hour, r) // lead > delta => fire immediately
+ defer w.Close()
+
+ d := time.Now().Add(10 * time.Millisecond)
+ _ = w.Update(d)
+
+ events := waitForEvents(t, r, 2)
+ if !events[1].isWarning() {
+ t.Fatalf("expected immediate warning publish, got %+v", events[1])
+ }
+}
+
+func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
+ r := &fakeRecorder{}
+ lead := 50 * time.Millisecond
+ w := newWatcher(lead, r)
+ defer w.Close()
+
+ first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
+ _ = w.Update(first)
+
+ // Replace with a far-future deadline before the warning fires.
+ time.Sleep(5 * time.Millisecond)
+ second := time.Now().Add(time.Hour)
+ _ = w.Update(second)
+
+ // Wait past when first's warning would have fired.
+ time.Sleep(80 * time.Millisecond)
+
+ if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
+ t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot())
+ }
+}
+
+func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
+ r := &fakeRecorder{}
+ lead := 150 * time.Millisecond
+ w := newWatcher(lead, r)
+ defer w.Close()
+
+ // Warning fires ~20ms in; the deadline itself stays 150ms away so the
+ // replacement below lands well before it.
+ first := time.Now().Add(170 * time.Millisecond)
+ _ = w.Update(first)
+
+ // Wait for stateChange + warning of the first cycle.
+ waitForEvents(t, r, 2)
+
+ // Simulate a successful extend: brand new deadline.
+ second := time.Now().Add(60 * time.Millisecond)
+ _ = w.Update(second)
+
+ // 4 events total: stateChange, warning (first), stateChange, warning (second).
+ events := waitForEvents(t, r, 4)
+ if events[2].kind != stateChange {
+ t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2])
+ }
+ if !events[3].isWarning() {
+ t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3])
+ }
+}
+
+func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(time.Hour, r)
+ defer w.Close()
+
+ d := time.Now().Add(2 * time.Hour)
+ _ = w.Update(d)
+ waitForEvents(t, r, 1)
+
+ _ = w.Update(time.Time{})
+
+ events := waitForEvents(t, r, 2)
+ if events[1].kind != stateChange {
+ t.Fatalf("expected stateChange on clear, got %+v", events[1])
+ }
+ if !w.Deadline().IsZero() {
+ t.Fatalf("Deadline should be zero after clear")
+ }
+}
+
+func TestUpdateRejectsBeforeEpoch(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(50*time.Millisecond, r)
+ defer w.Close()
+
+ good := time.Now().Add(time.Hour)
+ if err := w.Update(good); err != nil {
+ t.Fatalf("seed Update: %v", err)
+ }
+
+ err := w.Update(time.Unix(-100, 0))
+ if !errors.Is(err, ErrDeadlineBeforeEpoch) {
+ t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err)
+ }
+ if !w.Deadline().IsZero() {
+ t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline())
+ }
+}
+
+func TestUpdateRejectsTooFarFuture(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(50*time.Millisecond, r)
+ defer w.Close()
+
+ good := time.Now().Add(time.Hour)
+ if err := w.Update(good); err != nil {
+ t.Fatalf("seed Update: %v", err)
+ }
+
+ err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour))
+ if !errors.Is(err, ErrDeadlineTooFarFuture) {
+ t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err)
+ }
+ if !w.Deadline().IsZero() {
+ t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline())
+ }
+}
+
+func TestUpdateRecentPastRecordedAsExpired(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(50*time.Millisecond, r)
+ defer w.Close()
+
+ d := time.Now().Add(-1 * time.Hour)
+ if err := w.Update(d); err != nil {
+ t.Fatalf("recent-past Update should succeed, got %v", err)
+ }
+ if !w.Deadline().Equal(d) {
+ t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d)
+ }
+ if got := r.deadline(); !got.Equal(d) {
+ t.Fatalf("recorder deadline = %v, want %v", got, d)
+ }
+
+ time.Sleep(80 * time.Millisecond)
+ if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
+ t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot())
+ }
+}
+
+func TestUpdateAncientPastRejected(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(50*time.Millisecond, r)
+ defer w.Close()
+
+ good := time.Now().Add(time.Hour)
+ if err := w.Update(good); err != nil {
+ t.Fatalf("seed Update: %v", err)
+ }
+ // Drain the stateChange from the seed.
+ waitForEvents(t, r, 1)
+
+ err := w.Update(time.Now().Add(-31 * 24 * time.Hour))
+ if !errors.Is(err, ErrDeadlineInPast) {
+ t.Fatalf("want ErrDeadlineInPast, got %v", err)
+ }
+ if !w.Deadline().IsZero() {
+ t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline())
+ }
+ events := waitForEvents(t, r, 2)
+ if events[1].kind != stateChange {
+ t.Fatalf("expected stateChange on clear, got %+v", events[1])
+ }
+}
+
+func TestCloseSilencesUpdates(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(50*time.Millisecond, r)
+ w.Close()
+
+ if err := w.Update(time.Now().Add(time.Hour)); err != nil {
+ t.Fatalf("Update after Close: want nil, got %v", err)
+ }
+ if got := r.snapshot(); len(got) != 0 {
+ t.Fatalf("expected no events after Close, got %+v", got)
+ }
+}
+
+// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher
+// closes on every engine restart (network change, sleep/wake) while the
+// SSO deadline stays valid across those, so Close must leave the
+// server-scoped recorder's value in place. The client run loop clears the
+// recorder when it exits for real.
+func TestCloseKeepsRecorderDeadline(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(time.Hour, r)
+
+ d := time.Now().Add(2 * time.Hour)
+ if err := w.Update(d); err != nil {
+ t.Fatalf("seed Update: %v", err)
+ }
+ if got := r.deadline(); !got.Equal(d) {
+ t.Fatalf("recorder deadline after Update = %v, want %v", got, d)
+ }
+
+ w.Close()
+
+ if got := r.deadline(); !got.Equal(d) {
+ t.Fatalf("recorder deadline after Close = %v, want %v", got, d)
+ }
+}
+
+// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric
+// case: closing a watcher that never held a deadline must not emit a
+// redundant clear (the recorder may legitimately hold a value written by
+// some other path; the watcher only owns what it set).
+func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(time.Hour, r)
+
+ w.Close()
+
+ if got := r.snapshot(); len(got) != 0 {
+ t.Fatalf("expected no events from Close on an empty watcher, got %+v", got)
+ }
+}
+
+func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
+ r := &fakeRecorder{}
+ // Warning fires at deadline-80ms, final at deadline-30ms.
+ w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
+ defer w.Close()
+
+ d := time.Now().Add(100 * time.Millisecond)
+ _ = w.Update(d)
+
+ // Expect stateChange + warning + final-warning.
+ events := waitForEvents(t, r, 3)
+
+ if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 {
+ t.Fatalf("expected exactly 1 stateChange, got %+v", events)
+ }
+ if countWhere(events, event.isWarning) != 1 {
+ t.Fatalf("expected exactly 1 warning publish, got %+v", events)
+ }
+ if countWhere(events, event.isFinalWarning) != 1 {
+ t.Fatalf("expected exactly 1 final-warning publish, got %+v", events)
+ }
+
+ // Warning must precede final (same deadline, longer lead fires first).
+ var wIdx, fIdx int
+ for i, e := range events {
+ switch {
+ case e.isWarning():
+ wIdx = i
+ case e.isFinalWarning():
+ fIdx = i
+ }
+ }
+ if wIdx > fIdx {
+ t.Fatalf("warning must publish before final-warning, got order %+v", events)
+ }
+}
+
+func TestDismissSuppressesFinalWarning(t *testing.T) {
+ r := &fakeRecorder{}
+ w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
+ defer w.Close()
+
+ d := time.Now().Add(100 * time.Millisecond)
+ _ = w.Update(d)
+
+ // Wait for the warning publish so we know we're inside the warning
+ // window, then dismiss before the final timer would fire.
+ deadline := time.Now().Add(500 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ if countWhere(r.snapshot(), event.isWarning) >= 1 {
+ break
+ }
+ time.Sleep(2 * time.Millisecond)
+ }
+ if countWhere(r.snapshot(), event.isWarning) < 1 {
+ t.Fatalf("warning did not publish in time, events=%+v", r.snapshot())
+ }
+
+ w.Dismiss()
+
+ // Now wait past when the final would have fired.
+ time.Sleep(120 * time.Millisecond)
+
+ if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 {
+ t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot())
+ }
+}
+
+func TestDismissResetByNewDeadline(t *testing.T) {
+ r := &fakeRecorder{}
+ w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
+ defer w.Close()
+
+ first := time.Now().Add(100 * time.Millisecond)
+ _ = w.Update(first)
+
+ // Dismiss against the first deadline.
+ w.Dismiss()
+
+ // Replace with a fresh deadline before the first's timers complete.
+ time.Sleep(10 * time.Millisecond)
+ second := time.Now().Add(100 * time.Millisecond)
+ _ = w.Update(second)
+
+ // The second cycle must publish a final-warning (the dismiss state
+ // did not carry over).
+ deadline := time.Now().Add(500 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
+ break
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ if countWhere(r.snapshot(), event.isFinalWarning) < 1 {
+ t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot())
+ }
+}
+
+func TestDismissBeforeUpdateIsNoop(t *testing.T) {
+ r := &fakeRecorder{}
+ w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
+ defer w.Close()
+
+ // No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
+ w.Dismiss()
+
+ d := time.Now().Add(100 * time.Millisecond)
+ _ = w.Update(d)
+
+ // Final warning should still publish — Dismiss only acts on the current
+ // deadline, and there was none at the time of the call.
+ deadline := time.Now().Add(500 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
+}
diff --git a/client/internal/auth/util.go b/client/internal/auth/util.go
index 31c81d701..1800584a2 100644
--- a/client/internal/auth/util.go
+++ b/client/internal/auth/util.go
@@ -20,14 +20,26 @@ func randomBytesInHex(count int) (string, error) {
return hex.EncodeToString(buf), nil
}
-// isValidAccessToken is a simple validation of the access token
-func isValidAccessToken(token string, audience string) error {
+// validateTokenAudience checks that the token is a well-formed JWT whose
+// audience claim matches the expected audience.
+//
+// It does NOT verify the token's cryptographic signature and therefore must not
+// be treated as an authenticity check. The token is obtained by the client
+// directly from the IdP token endpoint over TLS, and its signature is verified
+// server-side by the management server against the IdP's JWKS
+// (see shared/auth/jwt/validator.go). This function is only a client-side
+// sanity check that the returned token targets the expected audience.
+func validateTokenAudience(token string, audience string) error {
if token == "" {
return fmt.Errorf("token received is empty")
}
- encodedClaims := strings.Split(token, ".")[1]
- claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims)
+ parts := strings.Split(token, ".")
+ if len(parts) != 3 {
+ return fmt.Errorf("token is not a well-formed JWT")
+ }
+
+ claimsString, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return err
}
diff --git a/client/internal/auth/util_test.go b/client/internal/auth/util_test.go
new file mode 100644
index 000000000..7f225bb86
--- /dev/null
+++ b/client/internal/auth/util_test.go
@@ -0,0 +1,108 @@
+package auth
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "testing"
+)
+
+// makeJWT builds an unsigned JWT-shaped string (header.payload.signature) with
+// the given claims payload. The signature part is arbitrary because
+// validateTokenAudience intentionally does not verify it.
+func makeJWT(t *testing.T, claims map[string]interface{}) string {
+ t.Helper()
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
+ payloadBytes, err := json.Marshal(claims)
+ if err != nil {
+ t.Fatalf("marshal claims: %v", err)
+ }
+ payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
+ return header + "." + payload + ".unverified-signature"
+}
+
+func TestValidateTokenAudience(t *testing.T) {
+ tests := []struct {
+ name string
+ token string
+ audience string
+ wantErr bool
+ }{
+ {
+ name: "empty token",
+ token: "",
+ audience: "netbird",
+ wantErr: true,
+ },
+ {
+ name: "not a JWT - no dots",
+ token: "notajwt",
+ audience: "netbird",
+ wantErr: true,
+ },
+ {
+ name: "not a JWT - two parts only",
+ token: "header.payload",
+ audience: "netbird",
+ wantErr: true,
+ },
+ {
+ name: "matching string audience",
+ token: makeJWT(t, map[string]interface{}{"aud": "netbird"}),
+ audience: "netbird",
+ wantErr: false,
+ },
+ {
+ name: "mismatching string audience",
+ token: makeJWT(t, map[string]interface{}{"aud": "other"}),
+ audience: "netbird",
+ wantErr: true,
+ },
+ {
+ name: "matching audience in array",
+ token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"other", "netbird"}}),
+ audience: "netbird",
+ wantErr: false,
+ },
+ {
+ name: "mismatching audience array",
+ token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"a", "b"}}),
+ audience: "netbird",
+ wantErr: true,
+ },
+ {
+ name: "missing audience claim",
+ token: makeJWT(t, map[string]interface{}{"sub": "user"}),
+ audience: "netbird",
+ wantErr: true,
+ },
+ {
+ name: "invalid base64 payload",
+ token: "header.!!!not-base64!!!.sig",
+ audience: "netbird",
+ wantErr: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := validateTokenAudience(tc.token, tc.audience)
+ if tc.wantErr && err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+ if !tc.wantErr && err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ })
+ }
+}
+
+// TestValidateTokenAudienceNoPanic guards the regression where a non-empty
+// token without the JWT dot structure caused an index-out-of-range panic.
+func TestValidateTokenAudienceNoPanic(t *testing.T) {
+ inputs := []string{"a", ".", "a.", "aaaa", "no-dots-here"}
+ for _, in := range inputs {
+ if err := validateTokenAudience(in, "netbird"); err == nil {
+ t.Fatalf("expected error for malformed token %q, got nil", in)
+ }
+ }
+}
diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go
index 112559132..ad0f00c5d 100644
--- a/client/internal/conn_mgr.go
+++ b/client/internal/conn_mgr.go
@@ -16,6 +16,16 @@ import (
"github.com/netbirdio/netbird/route"
)
+// lazyForce is the resolved local decision for lazy connections, layered above the
+// management feature flag. lazyForceNone defers to management.
+type lazyForce int
+
+const (
+ lazyForceNone lazyForce = iota
+ lazyForceOn
+ lazyForceOff
+)
+
// ConnMgr coordinates both lazy connections (established on-demand) and permanent peer connections.
//
// The connection manager is responsible for:
@@ -24,47 +34,69 @@ import (
// - Handling connection establishment based on peer signaling
//
// The implementation is not thread-safe; it is protected by engine.syncMsgMux.
+// The only exception is ActivatePeer, which is safe for concurrent use so the
+// DNS warm-up path can call it without contending on the engine mutex.
type ConnMgr struct {
peerStore *peerstore.Store
statusRecorder *peer.Status
iface lazyconn.WGIface
- enabledLocally bool
+ force lazyForce
rosenpassEnabled bool
lazyConnMgr *manager.Manager
+ // lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
+ // engine loop (ActivatePeer). Writers hold it in addition to
+ // engine.syncMsgMux; all other reads stay under engine.syncMsgMux only.
+ lazyConnMgrMu sync.RWMutex
+
+ // reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is
+ // (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
+ reconcileRoutedIPs func(peerKey string) error
wg sync.WaitGroup
lazyCtx context.Context
lazyCtxCancel context.CancelFunc
}
+// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when
+// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts.
+func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
+ e.reconcileRoutedIPs = fn
+}
+
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
e := &ConnMgr{
peerStore: peerStore,
statusRecorder: statusRecorder,
iface: iface,
+ force: resolveLazyForce(engineConfig.LazyConnection),
rosenpassEnabled: engineConfig.RosenpassEnabled,
}
- if engineConfig.LazyConnectionEnabled || lazyconn.IsLazyConnEnabledByEnv() {
- e.enabledLocally = true
- }
return e
}
-// Start initializes the connection manager and starts the lazy connection manager if enabled by env var or cmd line option.
+// Start initializes the connection manager. It starts the lazy connection manager when a
+// local override forces it on; with no local override it waits for the management feature flag.
func (e *ConnMgr) Start(ctx context.Context) {
if e.lazyConnMgr != nil {
log.Errorf("lazy connection manager is already started")
return
}
- if !e.enabledLocally {
- log.Infof("lazy connection manager is disabled")
+ switch e.force {
+ case lazyForceOff:
+ log.Infof("lazy connection manager is disabled by local override (%s or MDM policy)", lazyconn.EnvLazyConn)
+ e.statusRecorder.UpdateLazyConnection(false)
+ return
+ case lazyForceNone:
+ log.Infof("lazy connection manager is managed by the management feature flag")
+ e.statusRecorder.UpdateLazyConnection(false)
return
}
if e.rosenpassEnabled {
log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started")
+ e.statusRecorder.UpdateLazyConnection(false)
return
}
@@ -76,8 +108,8 @@ func (e *ConnMgr) Start(ctx context.Context) {
// If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again.
// If disabled, then it closes the lazy connection manager and open the connections to all peers.
func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error {
- // do not disable lazy connection manager if it was enabled by env var
- if e.enabledLocally {
+ // a local override (NB_LAZY_CONN or local config) takes precedence over management
+ if e.force != lazyForceNone {
return nil
}
@@ -89,15 +121,17 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er
if e.rosenpassEnabled {
log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started")
+ e.statusRecorder.UpdateLazyConnection(false)
return nil
}
- log.Warnf("lazy connection manager is enabled by management feature flag")
+ log.Infof("lazy connection manager is enabled by the management feature flag")
e.initLazyManager(ctx)
e.statusRecorder.UpdateLazyConnection(true)
return e.addPeersToLazyConnManager()
} else {
if e.lazyConnMgr == nil {
+ e.statusRecorder.UpdateLazyConnection(false)
return nil
}
log.Infof("lazy connection manager is disabled by management feature flag")
@@ -220,12 +254,20 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) {
conn.Log.Infof("removed peer from lazy conn manager")
}
+// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is
+// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu
+// and the manager itself is internally synchronized, so callers outside the
+// engine loop (DNS warm-up) do not need engine.syncMsgMux.
func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) {
- if !e.isStartedWithLazyMgr() {
+ e.lazyConnMgrMu.RLock()
+ lazyConnMgr := e.lazyConnMgr
+ started := lazyConnMgr != nil && e.lazyCtxCancel != nil
+ e.lazyConnMgrMu.RUnlock()
+ if !started {
return
}
- if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found {
+ if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found {
if err := conn.Open(ctx); err != nil {
conn.Log.Errorf("failed to open connection: %v", err)
}
@@ -250,16 +292,22 @@ func (e *ConnMgr) Close() {
e.lazyCtxCancel()
e.wg.Wait()
+
+ e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil
+ e.lazyConnMgrMu.Unlock()
}
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
cfg := manager.Config{
InactivityThreshold: inactivityThresholdEnv(),
+ ReconcileAllowedIPs: e.reconcileRoutedIPs,
}
- e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
+ e.lazyConnMgrMu.Lock()
+ e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
+ e.lazyConnMgrMu.Unlock()
e.wg.Add(1)
go func() {
@@ -298,7 +346,10 @@ func (e *ConnMgr) closeManager(ctx context.Context) {
e.lazyCtxCancel()
e.wg.Wait()
+
+ e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil
+ e.lazyConnMgrMu.Unlock()
for _, peerID := range e.peerStore.PeersPubKey() {
e.peerStore.PeerConnOpen(ctx, peerID)
@@ -309,17 +360,45 @@ func (e *ConnMgr) isStartedWithLazyMgr() bool {
return e.lazyConnMgr != nil && e.lazyCtxCancel != nil
}
+// resolveLazyForce determines the local override. NB_LAZY_CONN takes precedence; when it
+// is unset the MDM policy override (mdmState) applies. Either wins in both directions over
+// the management feature flag; StateUnset for both defers to management.
+func resolveLazyForce(mdmState lazyconn.State) lazyForce {
+ state := lazyconn.EnvState()
+ if state == lazyconn.StateUnset {
+ state = mdmState
+ }
+
+ switch state {
+ case lazyconn.StateOn:
+ return lazyForceOn
+ case lazyconn.StateOff:
+ return lazyForceOff
+ default:
+ return lazyForceNone
+ }
+}
+
func inactivityThresholdEnv() *time.Duration {
envValue := os.Getenv(lazyconn.EnvInactivityThreshold)
if envValue == "" {
return nil
}
- parsedMinutes, err := strconv.Atoi(envValue)
- if err != nil || parsedMinutes <= 0 {
- return nil
+ // Documented format: a Go duration such as "30m" or "1h".
+ if d, err := time.ParseDuration(envValue); err == nil {
+ if d <= 0 {
+ return nil
+ }
+ return &d
}
- d := time.Duration(parsedMinutes) * time.Minute
- return &d
+ // Backwards compatibility: a bare integer used to be interpreted as minutes.
+ if parsedMinutes, err := strconv.Atoi(envValue); err == nil && parsedMinutes > 0 {
+ d := time.Duration(parsedMinutes) * time.Minute
+ return &d
+ }
+
+ log.Warnf("invalid %s value %q: expected a Go duration such as 30m or 1h", lazyconn.EnvInactivityThreshold, envValue)
+ return nil
}
diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go
new file mode 100644
index 000000000..ac5d6f2c8
--- /dev/null
+++ b/client/internal/conn_mgr_test.go
@@ -0,0 +1,141 @@
+package internal
+
+import (
+ "context"
+ "net"
+ "net/netip"
+ "os"
+ "sync"
+ "testing"
+ "time"
+
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/lazyconn"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/peerstore"
+ "github.com/netbirdio/netbird/monotime"
+)
+
+func TestResolveLazyForce(t *testing.T) {
+ tests := []struct {
+ name string
+ env string
+ envSet bool
+ mdm lazyconn.State
+ want lazyForce
+ }{
+ {name: "env unset, mdm unset -> defer to management", mdm: lazyconn.StateUnset, want: lazyForceNone},
+ {name: "env on -> force on", env: "on", envSet: true, mdm: lazyconn.StateUnset, want: lazyForceOn},
+ {name: "env off -> force off", env: "off", envSet: true, mdm: lazyconn.StateUnset, want: lazyForceOff},
+ {name: "env unset, mdm on -> force on", mdm: lazyconn.StateOn, want: lazyForceOn},
+ {name: "env unset, mdm off -> force off", mdm: lazyconn.StateOff, want: lazyForceOff},
+ {name: "env on beats mdm off", env: "on", envSet: true, mdm: lazyconn.StateOff, want: lazyForceOn},
+ {name: "env off beats mdm on", env: "off", envSet: true, mdm: lazyconn.StateOn, want: lazyForceOff},
+ {name: "unrecognized env, mdm on -> mdm wins", env: "auto", envSet: true, mdm: lazyconn.StateOn, want: lazyForceOn},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv(lazyconn.EnvLazyConn, tt.env)
+ if !tt.envSet {
+ os.Unsetenv(lazyconn.EnvLazyConn)
+ }
+
+ if got := resolveLazyForce(tt.mdm); got != tt.want {
+ t.Fatalf("resolveLazyForce(%v) = %v, want %v", tt.mdm, got, tt.want)
+ }
+ })
+ }
+}
+
+type mockLazyWGIface struct{}
+
+func (mockLazyWGIface) RemovePeer(string) error { return nil }
+func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
+ return nil
+}
+func (mockLazyWGIface) IsUserspaceBind() bool { return false }
+func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} }
+func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil }
+func (mockLazyWGIface) MTU() uint16 { return 1280 }
+
+// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from
+// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle,
+// which stays on the engine loop. Run with -race: it fails if ActivatePeer
+// still requires engine.syncMsgMux for safety.
+func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) {
+ t.Setenv(lazyconn.EnvLazyConn, "on")
+
+ status := peer.NewRecorder("https://mgm")
+ store := peerstore.NewConnStore()
+ connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{})
+
+ conn := newTestPeerConn(t, "peerA")
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ connMgr.Start(ctx)
+
+ done := make(chan struct{})
+ var wg sync.WaitGroup
+ for range 4 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for {
+ select {
+ case <-done:
+ return
+ default:
+ connMgr.ActivatePeer(ctx, conn)
+ }
+ }
+ }()
+ }
+
+ // Let the activators spin against the started manager, then tear it down
+ // underneath them and let them spin against the stopped manager.
+ time.Sleep(100 * time.Millisecond)
+ connMgr.Close()
+ time.Sleep(50 * time.Millisecond)
+
+ close(done)
+ wg.Wait()
+}
+
+func TestInactivityThresholdEnv(t *testing.T) {
+ tests := []struct {
+ name string
+ val string
+ want *time.Duration
+ }{
+ {name: "unset", val: "", want: nil},
+ {name: "go duration minutes", val: "30m", want: durPtr(30 * time.Minute)},
+ {name: "go duration hours", val: "1h", want: durPtr(time.Hour)},
+ {name: "go duration seconds", val: "90s", want: durPtr(90 * time.Second)},
+ {name: "bare integer is minutes (backwards compat)", val: "5", want: durPtr(5 * time.Minute)},
+ {name: "zero duration", val: "0s", want: nil},
+ {name: "zero integer", val: "0", want: nil},
+ {name: "negative duration", val: "-5m", want: nil},
+ {name: "garbage", val: "abc", want: nil},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(lazyconn.EnvInactivityThreshold, tc.val)
+ got := inactivityThresholdEnv()
+ switch {
+ case tc.want == nil && got != nil:
+ t.Fatalf("want nil, got %v", *got)
+ case tc.want != nil && got == nil:
+ t.Fatalf("want %v, got nil", *tc.want)
+ case tc.want != nil && *got != *tc.want:
+ t.Fatalf("want %v, got %v", *tc.want, *got)
+ }
+ })
+ }
+}
+
+func durPtr(d time.Duration) *time.Duration { return &d }
diff --git a/client/internal/connect.go b/client/internal/connect.go
index d93b62bb5..e45ecca44 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -11,6 +11,7 @@ import (
"runtime/debug"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/cenkalti/backoff/v4"
@@ -26,15 +27,19 @@ import (
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/internal/dns"
+ "github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/metrics"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/stdnet"
+ "github.com/netbirdio/netbird/client/internal/tunnelnotifier"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/internal/updater/installer"
nbnet "github.com/netbirdio/netbird/client/net"
+ "github.com/netbirdio/netbird/client/netstate"
+ "github.com/netbirdio/netbird/client/netsweep"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ssh"
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
@@ -54,6 +59,10 @@ var androidRunOverride func(c *ConnectClient, runningChan chan struct{}, logPath
type ConnectClient struct {
ctx context.Context
+ runCancel context.CancelFunc
+ runExited chan struct{}
+ runOnce sync.Once
+ runStarted atomic.Bool
config *profilemanager.Config
statusRecorder *peer.Status
@@ -63,19 +72,53 @@ type ConnectClient struct {
updateManager *updater.Manager
persistSyncResponse bool
+
+ // netState gates every reconnection loop on OS-reported network
+ // availability. Nil (the default) disables gating; mobile platforms
+ // inject it via WithNetworkState.
+ netState *netstate.State
+
+ // sweeper cuts the management, signal and relay connections on network
+ // change; nil disables it.
+ sweeper *netsweep.Sweeper
+}
+
+// ConnectClientOption configures optional ConnectClient behavior.
+type ConnectClientOption func(*ConnectClient)
+
+// WithNetworkState injects the OS network availability state that gates every
+// reconnection loop; without it gating is disabled.
+func WithNetworkState(netState *netstate.State) ConnectClientOption {
+ return func(c *ConnectClient) { c.netState = netState }
+}
+
+// WithSweeper injects the network change sweeper.
+func WithSweeper(sweeper *netsweep.Sweeper) ConnectClientOption {
+ return func(c *ConnectClient) { c.sweeper = sweeper }
}
func NewConnectClient(
ctx context.Context,
config *profilemanager.Config,
statusRecorder *peer.Status,
+ opts ...ConnectClientOption,
) *ConnectClient {
- return &ConnectClient{
- ctx: ctx,
+ // Derive the run context here so Stop owns the cancel that unblocks the run
+ // loop. runCancel is set once at construction, so Stop can call it without
+ // racing the run loop's startup. Callers therefore need not cancel before Stop.
+ runCtx, runCancel := context.WithCancel(ctx)
+ c := &ConnectClient{
+ ctx: runCtx,
+ runCancel: runCancel,
+ runExited: make(chan struct{}),
config: config,
statusRecorder: statusRecorder,
engineMutex: sync.Mutex{},
}
+ for _, opt := range opts {
+ opt(c)
+ }
+ return c
}
func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
@@ -100,11 +143,14 @@ func (c *ConnectClient) RunOnAndroid(
stateFilePath string,
cacheDir string,
) error {
+ notifier := tunnelnotifier.New(networkChangeListener, nil)
+ defer notifier.Close()
+
// in case of non Android os these variables will be nil
mobileDependency := MobileDependency{
TunAdapter: tunAdapter,
IFaceDiscover: iFaceDiscover,
- NetworkChangeListener: networkChangeListener,
+ NetworkChangeListener: notifier,
HostDNSAddresses: dnsAddresses,
DnsReadyListener: dnsReadyListener,
StateFilePath: stateFilePath,
@@ -124,10 +170,13 @@ func (c *ConnectClient) RunOniOS(
// Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension.
debug.SetGCPercent(5)
+ notifier := tunnelnotifier.New(networkChangeListener, dnsManager)
+ defer notifier.Close()
+
mobileDependency := MobileDependency{
FileDescriptor: fileDescriptor,
- NetworkChangeListener: networkChangeListener,
- DnsManager: dnsManager,
+ NetworkChangeListener: notifier,
+ DnsManager: notifier,
StateFilePath: stateFilePath,
TempDir: cacheDir,
}
@@ -135,6 +184,11 @@ func (c *ConnectClient) RunOniOS(
}
func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error {
+ // Mark the loop as started and signal exit on return so Stop can wait for
+ // the loop to finish (and skip the wait if the loop never ran).
+ c.runStarted.Store(true)
+ defer c.runOnce.Do(func() { close(c.runExited) })
+
defer func() {
if r := recover(); r != nil {
rec := c.statusRecorder
@@ -240,13 +294,23 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
log.Errorf("failed to clean up temporary installer file: %v", err)
}
- defer c.statusRecorder.ClientStop()
+ defer func() {
+ c.statusRecorder.SetSessionExpiresAt(time.Time{})
+ c.statusRecorder.ClientStop()
+ }()
operation := func() error {
// if context cancelled we not start new backoff cycle
if c.ctx.Err() != nil {
return nil
}
+ // suspend connection attempts while the OS reports no usable network
+ if waited, err := c.netState.Wait(c.ctx); err != nil {
+ return nil
+ } else if waited {
+ backOff.Reset()
+ }
+
state.Set(StatusConnecting)
engineCtx, cancel := context.WithCancel(c.ctx)
@@ -258,8 +322,18 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
}()
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
- mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
+ mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled,
+ mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper))
if err != nil {
+ // On daemon shutdown / Down() the parent context is cancelled
+ // and the dial fails with "context canceled". Wrapping that
+ // into state would leave the snapshot stuck at Connecting+err
+ // until the backoff loop wakes up — instead let the operation
+ // return cleanly so the deferred state.Set(StatusIdle) takes
+ // effect on the next iteration.
+ if c.ctx.Err() != nil {
+ return nil
+ }
return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err))
}
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
@@ -290,7 +364,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
log.Debug(err)
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
state.Set(StatusNeedsLogin)
- _ = c.Stop()
+ c.runCancel()
return backoff.Permanent(wrapErr(err)) // unrecoverable error
}
return wrapErr(err)
@@ -298,6 +372,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), true)
c.statusRecorder.MarkManagementConnected()
+ if metricsConfig := loginResp.GetNetbirdConfig().GetMetrics(); metricsConfig != nil {
+ c.clientMetrics.UpdatePushFromMgm(c.ctx, metricsConfig.GetEnabled())
+ }
+
localPeerState := peer.LocalPeerState{
IP: loginResp.GetPeerConfig().GetAddress(),
PubKey: myPrivateKey.PublicKey().String(),
@@ -320,7 +398,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
}()
// with the global Netbird config in hand connect (just a connection, no stream yet) Signal
- signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey)
+ signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netState, c.sweeper)
if err != nil {
log.Error(err)
return wrapErr(err)
@@ -356,7 +434,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
engineConfig.StateDir = filepath.Dir(path)
}
- relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
+ relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU,
+ relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper))
c.statusRecorder.SetRelayMgr(relayManager)
if len(relayURLs) > 0 {
if token != nil {
@@ -383,6 +462,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
StateManager: stateManager,
UpdateManager: c.updateManager,
ClientMetrics: c.clientMetrics,
+ MetricsCtx: c.ctx,
+ NetState: c.netState,
}, mobileDependency)
engine.SetSyncResponsePersistence(c.persistSyncResponse)
c.engine = engine
@@ -393,6 +474,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return wrapErr(err)
}
+ // Seed the session-expiry deadline from the LoginResponse. Subsequent
+ // changes flow in through SyncResponse and are applied in handleSync.
+ engine.ApplySessionDeadline(loginResp.GetSessionExpiresAt())
+
log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress())
state.Set(StatusConnected)
@@ -410,14 +495,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
c.engine = nil
c.engineMutex.Unlock()
- // todo: consider to remove this condition. Is not thread safe.
- // We should always call Stop(), but we need to verify that it is idempotent
- if engine.wgInterface != nil {
- log.Infof("ensuring %s is removed, Netbird engine context cancelled", engine.wgInterface.Name())
+ log.Infof("ensuring wg interface is removed, Netbird engine context cancelled")
- if err := engine.Stop(); err != nil {
- log.Errorf("Failed to stop engine: %v", err)
- }
+ if err := engine.Stop(); err != nil {
+ log.Errorf("Failed to stop engine: %v", err)
}
c.statusRecorder.ClientTeardown()
@@ -433,12 +514,26 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
}
c.statusRecorder.ClientStart()
- err = backoff.Retry(operation, backOff)
+ // Wrap the backoff with c.ctx so Down()/actCancel propagates into the
+ // inter-attempt sleep — otherwise a 15s MaxInterval can keep the retry
+ // loop alive long after the caller asked to give up, leaving the
+ // status stream stuck at Connecting.
+ err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
if err != nil {
+ // Once the client context is cancelled backoff.WithContext surfaces the
+ // bare context error, and any attempt torn down mid-flight reports the
+ // same. That cancellation is the caller asking us to stop (Stop, Down or
+ // an engine restart), so exit cleanly instead of handing back a failure
+ // the caller would have to distinguish from a real one.
+ if c.ctx.Err() != nil && errors.Is(err, context.Canceled) {
+ log.Info("exiting client retry loop, context cancelled")
+ return nil
+ }
+
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
state.Set(StatusNeedsLogin)
- _ = c.Stop()
+ c.runCancel()
}
return err
}
@@ -516,11 +611,9 @@ func (c *ConnectClient) Status() StatusType {
}
func (c *ConnectClient) Stop() error {
- engine := c.Engine()
- if engine != nil {
- if err := engine.Stop(); err != nil {
- return fmt.Errorf("stop engine: %w", err)
- }
+ c.runCancel()
+ if c.runStarted.Load() {
+ <-c.runExited
}
return nil
}
@@ -585,8 +678,9 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
BlockLANAccess: config.BlockLANAccess,
BlockInbound: config.BlockInbound,
DisableIPv6: config.DisableIPv6,
+ SyncMessageVersion: config.SyncMessageVersion,
- LazyConnectionEnabled: config.LazyConnectionEnabled,
+ LazyConnection: lazyconn.ParseState(config.LazyConnection),
MTU: selectMTU(config.MTU, peerConfig.Mtu),
LogPath: logPath,
@@ -629,7 +723,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 {
}
// connectToSignal creates Signal Service client and established a connection
-func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) {
+func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) {
var sigTLSEnabled bool
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
sigTLSEnabled = true
@@ -637,7 +731,8 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
sigTLSEnabled = false
}
- signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled)
+ signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled,
+ signal.WithNetworkState(netState), signal.WithSweeper(sweeper))
if err != nil {
log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err)
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err)
@@ -660,7 +755,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
config.BlockLANAccess,
config.BlockInbound,
config.DisableIPv6,
- config.LazyConnectionEnabled,
+ config.SyncMessageVersion,
config.EnableSSHRoot,
config.EnableSSHSFTP,
config.EnableSSHLocalPortForwarding,
diff --git a/client/internal/daemonaddr/owner.go b/client/internal/daemonaddr/owner.go
new file mode 100644
index 000000000..c476f9ae6
--- /dev/null
+++ b/client/internal/daemonaddr/owner.go
@@ -0,0 +1,15 @@
+package daemonaddr
+
+// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very
+// user. That is what makes an unprivileged daemon authorize this process for the
+// changes it otherwise restricts to root or an administrator, so a client can tell
+// up front whether those controls are usable instead of letting a save fail.
+//
+// It is answered from the ownership of the socket or pipe the daemon created, so it
+// costs no round trip and needs no cooperation from the daemon. Ownership that
+// cannot be read is reported as false, including for a TCP address, so a caller
+// reading this as "the daemon would allow it" fails closed. The daemon remains the
+// only thing that authorizes anything: this only decides what a client offers.
+func DaemonRunsAsSelf(addr string) bool {
+ return daemonRunsAsSelf(addr)
+}
diff --git a/client/internal/daemonaddr/owner_unix.go b/client/internal/daemonaddr/owner_unix.go
new file mode 100644
index 000000000..493e6528d
--- /dev/null
+++ b/client/internal/daemonaddr/owner_unix.go
@@ -0,0 +1,40 @@
+//go:build !windows
+
+package daemonaddr
+
+import (
+ "os"
+ "strings"
+ "syscall"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this
+// process's uid. Root is not treated specially here: a root caller is privileged
+// on its own merits, and a root-owned socket says nothing about the caller.
+func daemonRunsAsSelf(addr string) bool {
+ path, ok := strings.CutPrefix(addr, "unix://")
+ if !ok {
+ return false
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ log.Debugf("stat daemon socket %s: %v", path, err)
+ return false
+ }
+
+ // Only a socket says anything about a daemon. A directory or a leftover
+ // regular file at that path is not one, and reading it as "the daemon runs as
+ // us" would offer controls the daemon then refuses.
+ if info.Mode()&os.ModeSocket == 0 {
+ return false
+ }
+
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return false
+ }
+ return stat.Uid == uint32(os.Getuid())
+}
diff --git a/client/internal/daemonaddr/owner_unix_test.go b/client/internal/daemonaddr/owner_unix_test.go
new file mode 100644
index 000000000..363c7d95d
--- /dev/null
+++ b/client/internal/daemonaddr/owner_unix_test.go
@@ -0,0 +1,62 @@
+//go:build !windows
+
+package daemonaddr
+
+import (
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// A socket this user created means the daemon runs as this user, which is the
+// rootless case where the daemon delegates its authority to its own identity.
+func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "netbird.sock")
+ ln, err := net.Listen("unix", path)
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := ln.Close(); err != nil {
+ t.Logf("close listener: %v", err)
+ }
+ })
+
+ if !DaemonRunsAsSelf("unix://" + path) {
+ t.Error("a socket owned by this user must count as the daemon running as us")
+ }
+}
+
+// Everything that is not a readable socket of ours has to answer false, because
+// the caller reads a true as "the daemon would authorize me".
+func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) {
+ dir := t.TempDir()
+
+ // A socket owned by another user, which is what a root-run daemon looks like
+ // to an unprivileged client. Only assertable when we are not root ourselves.
+ rootOwned := "unix:///var/run/netbird.sock"
+ if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 {
+ if DaemonRunsAsSelf(rootOwned) {
+ t.Error("a socket owned by another user must not count as ours")
+ }
+ }
+
+ for name, addr := range map[string]string{
+ "missing socket": "unix://" + filepath.Join(dir, "absent.sock"),
+ "tcp address": "tcp://127.0.0.1:41731",
+ "named pipe": "npipe://netbird",
+ "empty": "",
+ "no scheme": filepath.Join(dir, "absent.sock"),
+ "directory": "unix://" + dir,
+ "unknown scheme": "http://localhost:8080",
+ "scheme only": "unix://",
+ "relative socket": "unix://netbird.sock",
+ } {
+ t.Run(name, func(t *testing.T) {
+ if DaemonRunsAsSelf(addr) {
+ t.Errorf("%q must not count as a daemon running as us", addr)
+ }
+ })
+ }
+}
diff --git a/client/internal/daemonaddr/owner_windows.go b/client/internal/daemonaddr/owner_windows.go
new file mode 100644
index 000000000..1cd2bba15
--- /dev/null
+++ b/client/internal/daemonaddr/owner_windows.go
@@ -0,0 +1,42 @@
+//go:build windows
+
+package daemonaddr
+
+import (
+ "context"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+)
+
+// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the
+// service account owns its pipe as LocalSystem, and an elevated one as
+// BUILTIN\Administrators, so only a daemon the user started themselves matches.
+func daemonRunsAsSelf(addr string) bool {
+ name, ok := strings.CutPrefix(addr, pipeScheme)
+ if !ok {
+ return false
+ }
+
+ for _, path := range PipePaths(name) {
+ // Bounded: this runs on the UI's path for deciding which controls to
+ // offer, so a pipe that does not answer promptly must not stall it. A
+ // timeout leaves the caller unprivileged, which only disables controls.
+ ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
+ conn, err := dialPipe(ctx, path)
+ cancel()
+ if err != nil {
+ continue
+ }
+
+ owned := ipcauth.PipeOwnedBySelf(conn)
+ if cerr := conn.Close(); cerr != nil {
+ log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr)
+ }
+ return owned
+ }
+
+ return false
+}
diff --git a/client/internal/daemonaddr/pipe.go b/client/internal/daemonaddr/pipe.go
new file mode 100644
index 000000000..51815ef5e
--- /dev/null
+++ b/client/internal/daemonaddr/pipe.go
@@ -0,0 +1,103 @@
+package daemonaddr
+
+import (
+ "context"
+ "net"
+ "runtime"
+ "strings"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+)
+
+const (
+ // WindowsPipeAddr is the default daemon address on Windows. A named pipe
+ // carries the connecting process's token, which loopback TCP does not, so
+ // it is the only Windows transport on which the daemon can tell who is
+ // calling it.
+ WindowsPipeAddr = "npipe://netbird"
+
+ // legacyWindowsAddr is the loopback-TCP address the Windows daemon used
+ // before named-pipe support.
+ legacyWindowsAddr = "tcp://127.0.0.1:41731"
+
+ pipeScheme = "npipe://"
+
+ // protectedPrefix is the NPFS namespace in which only LocalSystem and
+ // members of BUILTIN\Administrators may create a pipe. A daemon running as
+ // the service account creates its pipe there so that an unprivileged process
+ // cannot pre-create the name, which would keep the daemon from starting and
+ // leave callers talking to the squatter. Opening such a pipe needs no
+ // privilege, so unprivileged clients still reach the daemon.
+ protectedPrefix = `ProtectedPrefix\Administrators\`
+)
+
+// DialTarget returns the gRPC dial target and transport options for a daemon
+// address. The npipe scheme needs a context dialer because gRPC has no
+// named-pipe resolver; unix and tcp are handled by gRPC itself.
+func DialTarget(addr string) (string, []grpc.DialOption) {
+ opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
+
+ if name, ok := strings.CutPrefix(addr, pipeScheme); ok {
+ paths := PipePaths(name)
+ opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
+ return dialPipePaths(ctx, paths)
+ }))
+ return "passthrough:///netbird-daemon-pipe", opts
+ }
+
+ return strings.TrimPrefix(addr, "tcp://"), opts
+}
+
+// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a
+// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as
+// is.
+func PipePath(name string) string {
+ if strings.HasPrefix(name, `\\`) {
+ return name
+ }
+ return `\\.\pipe\` + name
+}
+
+// PipePaths returns the paths a daemon control pipe may live at for an npipe
+// address name, in the order both sides must try them: the protected name first,
+// then the plain one.
+//
+// The daemon serves the first it can create, which is the protected name when it
+// runs as the service account and the plain one when it runs as an ordinary user,
+// as it does in netstack mode. Clients therefore have to try both, and because a
+// client cannot tell from the name alone who created the pipe, the plain name is
+// only usable once the server's identity has been checked: see
+// verifyPipeServer.
+//
+// A fully qualified path is what the operator asked for and is used as is.
+func PipePaths(name string) []string {
+ if strings.HasPrefix(name, `\\`) {
+ return []string{name}
+ }
+ return []string{PipePath(protectedPrefix + name), PipePath(name)}
+}
+
+// IsProtectedPipePath reports whether a pipe path is in the namespace only an
+// administrator or LocalSystem can create in, which is what lets a client trust
+// such a pipe from its name alone.
+func IsProtectedPipePath(path string) bool {
+ return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix)
+}
+
+// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named
+// pipe, reporting whether it rewrote the address. Existing installs persist the
+// daemon address, so without this an upgraded daemon would keep listening on
+// loopback TCP, where callers carry no identity and privileged operations would
+// have to be refused for everyone. Only the exact legacy default is rewritten:
+// a deliberately chosen custom address is left alone.
+func MigrateLegacy(addr string) (string, bool) {
+ return migrateLegacyForOS(runtime.GOOS, addr)
+}
+
+func migrateLegacyForOS(goos, addr string) (string, bool) {
+ if goos == "windows" && addr == legacyWindowsAddr {
+ return WindowsPipeAddr, true
+ }
+ return addr, false
+}
diff --git a/client/internal/daemonaddr/pipe_other.go b/client/internal/daemonaddr/pipe_other.go
new file mode 100644
index 000000000..04e8e7331
--- /dev/null
+++ b/client/internal/daemonaddr/pipe_other.go
@@ -0,0 +1,15 @@
+//go:build !windows
+
+package daemonaddr
+
+import (
+ "context"
+ "fmt"
+ "net"
+)
+
+// dialPipePaths is Windows-only: no other platform serves the daemon on a named
+// pipe.
+func dialPipePaths(context.Context, []string) (net.Conn, error) {
+ return nil, fmt.Errorf("named pipes are only supported on Windows")
+}
diff --git a/client/internal/daemonaddr/pipe_test.go b/client/internal/daemonaddr/pipe_test.go
new file mode 100644
index 000000000..b9dfd90f1
--- /dev/null
+++ b/client/internal/daemonaddr/pipe_test.go
@@ -0,0 +1,30 @@
+package daemonaddr
+
+import (
+ "slices"
+ "testing"
+)
+
+// The protected name must be tried before the plain one on both sides: it is the
+// one an unprivileged process cannot create, so preferring it is what keeps a
+// squatter from owning the name the service daemon would otherwise use.
+func TestPipePaths_PrefersTheProtectedName(t *testing.T) {
+ got := PipePaths("netbird")
+ want := []string{
+ `\\.\pipe\ProtectedPrefix\Administrators\netbird`,
+ `\\.\pipe\netbird`,
+ }
+ if !slices.Equal(got, want) {
+ t.Errorf("PipePaths = %q, want %q", got, want)
+ }
+}
+
+// An operator who passes a full path chose exactly one pipe, so neither side may
+// look anywhere else.
+func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) {
+ path := `\\.\pipe\custom-netbird`
+ got := PipePaths(path)
+ if !slices.Equal(got, []string{path}) {
+ t.Errorf("PipePaths = %q, want just %q", got, path)
+ }
+}
diff --git a/client/internal/daemonaddr/pipe_windows.go b/client/internal/daemonaddr/pipe_windows.go
new file mode 100644
index 000000000..3cd10a6c3
--- /dev/null
+++ b/client/internal/daemonaddr/pipe_windows.go
@@ -0,0 +1,59 @@
+//go:build windows
+
+package daemonaddr
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+
+ "github.com/Microsoft/go-winio"
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows"
+
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+)
+
+// dialPipePaths connects to the first path that answers with a pipe server this
+// client may trust, and returns the last error when none does.
+func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) {
+ var lastErr error
+ for _, path := range paths {
+ conn, err := dialPipe(ctx, path)
+ if err != nil {
+ log.Debugf("dial daemon pipe %s: %v", path, err)
+ lastErr = err
+ continue
+ }
+
+ // A pipe in the protected namespace could only have been created by an
+ // administrator or LocalSystem, so its name is the guarantee. Any other
+ // name has to be checked, because any local user can create one.
+ if !IsProtectedPipePath(path) {
+ if err := ipcauth.PipeServerTrusted(conn); err != nil {
+ if closeErr := conn.Close(); closeErr != nil {
+ log.Debugf("close untrusted pipe %s: %v", path, closeErr)
+ }
+ lastErr = fmt.Errorf("%s: %w", path, err)
+ continue
+ }
+ }
+
+ return conn, nil
+ }
+
+ if lastErr == nil {
+ lastErr = errors.New("no daemon pipe to connect to")
+ }
+ return nil, lastErr
+}
+
+// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION.
+// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon
+// cannot read the caller's token at all. Identification lets the daemon read the
+// caller's SID and groups without granting it the ability to act as the caller.
+func dialPipe(ctx context.Context, path string) (net.Conn, error) {
+ access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
+ return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
+}
diff --git a/client/internal/daemonaddr/resolve_pipe_other.go b/client/internal/daemonaddr/resolve_pipe_other.go
new file mode 100644
index 000000000..1aede8453
--- /dev/null
+++ b/client/internal/daemonaddr/resolve_pipe_other.go
@@ -0,0 +1,9 @@
+//go:build !windows
+
+package daemonaddr
+
+// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe
+// default to fall back from.
+func ResolveDaemonAddr(addr string) string {
+ return addr
+}
diff --git a/client/internal/daemonaddr/resolve_pipe_windows.go b/client/internal/daemonaddr/resolve_pipe_windows.go
new file mode 100644
index 000000000..d12ddb15d
--- /dev/null
+++ b/client/internal/daemonaddr/resolve_pipe_windows.go
@@ -0,0 +1,82 @@
+//go:build windows
+
+package daemonaddr
+
+import (
+ "net"
+ "strings"
+ "time"
+
+ "github.com/Microsoft/go-winio"
+ log "github.com/sirupsen/logrus"
+)
+
+// probeTimeout bounds each transport probe. Both are local, so a daemon that is
+// listening answers immediately and one that is not fails immediately.
+const probeTimeout = 300 * time.Millisecond
+
+// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it
+// off. When the pipe does not answer it checks the legacy loopback TCP address, so
+// a client meeting a daemon that has not restarted since the upgrade can say what
+// is wrong, but it does not connect there.
+//
+// Using that address automatically would be a downgrade the user never asked for:
+// any local process can bind 127.0.0.1 while the daemon is not listening, and the
+// transport carries no caller identity, so a client that accepted whatever answered
+// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An
+// operator who needs the legacy address during the upgrade window can still pass
+// --daemon-addr explicitly, which is a deliberate choice and still refuses the
+// privileged operations.
+//
+// Only the pipe address is resolved. A custom address is left alone, though passing
+// --daemon-addr npipe://netbird explicitly is indistinguishable from the default
+// here, so it is treated the same way.
+func ResolveDaemonAddr(addr string) string {
+ if addr != WindowsPipeAddr {
+ return addr
+ }
+
+ for _, path := range PipePaths("netbird") {
+ if pipeAvailable(path) {
+ return addr
+ }
+ }
+
+ if tcpAvailable(legacyWindowsAddr) {
+ log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+
+ "Restart the NetBird service so it serves the pipe. That address is not used automatically: "+
+ "any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+
+ "explicitly if you accept that",
+ WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr)
+ }
+
+ return addr
+}
+
+func pipeAvailable(path string) bool {
+ timeout := probeTimeout
+ conn, err := winio.DialPipe(path, &timeout)
+ if err != nil {
+ return false
+ }
+ if err := conn.Close(); err != nil {
+ log.Debugf("close daemon pipe probe: %v", err)
+ }
+ return true
+}
+
+func tcpAvailable(addr string) bool {
+ host := addr
+ if _, after, ok := strings.Cut(addr, "://"); ok {
+ host = after
+ }
+
+ conn, err := net.DialTimeout("tcp", host, probeTimeout)
+ if err != nil {
+ return false
+ }
+ if err := conn.Close(); err != nil {
+ log.Debugf("close daemon TCP probe: %v", err)
+ }
+ return true
+}
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index a65d8bd05..1d31c75ca 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -34,9 +34,8 @@ import (
"github.com/netbirdio/netbird/shared/netiputil"
)
-const readmeContent = `Netbird debug bundle
-This debug bundle contains the following files.
-If the --anonymize flag is set, the files are anonymized to protect sensitive information.
+const readmeContent = `This debug bundle contains the following files.
+If anonymization is enabled (--anonymize / --anonymize-level), the files are anonymized to protect sensitive information.
status.txt: Anonymized status information of the NetBird client.
client.log: Most recent, anonymized client log file of the NetBird client.
@@ -52,6 +51,7 @@ nftables.txt: Anonymized nftables rules with packet counters across all families
sysctls.txt: Forwarding, reverse-path filter, source-validation, and conntrack accounting sysctl values that the NetBird client may read or modify, if --system-info flag was provided (Linux only).
resolv.conf: DNS resolver configuration from /etc/resolv.conf (Unix systems only), if --system-info flag was provided.
scutil_dns.txt: DNS configuration from scutil --dns (macOS only), if --system-info flag was provided.
+dns_windows.txt: Anonymized NRPT rules and policy table in effect, DNS client policy, and per-interface and per-adapter DNS configuration (Windows only), if --system-info flag was provided.
resolved_domains.txt: Anonymized resolved domain IP addresses from the status recorder.
config.txt: Anonymized configuration information of the NetBird client.
network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules.
@@ -70,21 +70,34 @@ capture.pcap: Packet capture in pcap format. Only present when capture was runni
Anonymization Process
-The files in this bundle have been anonymized to protect sensitive information. Here's how the anonymization was applied:
+The files in this bundle have been anonymized to protect sensitive information. The level applied to this bundle is recorded at the top of this file. Here's how the anonymization was applied:
IP Addresses
-IPv4 addresses are replaced with addresses starting from 198.51.100.0
-IPv6 addresses are replaced with addresses starting from 100::
+Default level:
+- Public IPv4 addresses are replaced with addresses starting from 198.51.100.0
+- Public IPv6 addresses are replaced with addresses starting from 2001:db8:ffff::
+- IPv6 unique local addresses (fc00::/7) are anonymized as well: their random global ID uniquely identifies the network.
+- IP addresses from internal IPv4 ranges and well-known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., 169.254., fe80::).
+
+Strict level (--anonymize-level strict), in addition to the default level:
+- Private (RFC 1918), CGNAT (100.64.0.0/10), and link-local (169.254.0.0/16, fe80::/10) addresses are anonymized too.
+- Internal IPv4 addresses are replaced with addresses starting from 198.18.0.0 and internal IPv6 addresses with addresses starting from 2001:db8:1::, so internal addresses remain distinguishable from public ones.
+- Addresses are mapped in order of first appearance: subnet structure, allocation scheme, and gateway conventions are not preserved. Prefix lengths of networks are preserved.
+- Peer names in front of NetBird domains are replaced with numbered placeholders (e.g. peer-1.netbird.cloud), and subdomain labels of other domains with host-N placeholders.
+- WireGuard public keys are replaced with consistent placeholder keys.
-IP addresses from non public ranges and well known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., etc.).
Reoccuring IP addresses are replaced with the same anonymized address.
Note: The anonymized IP addresses in the status file do not match those in the log and routes files. However, the anonymized IP addresses are consistent within the status file and across the routes and log files.
+MAC Addresses
+MAC addresses are replaced at every anonymization level with consistent placeholders counting up from 02:00:00:00:00:01. Broadcast, multicast, and all-zero addresses are kept. At the default level a preserved IPv6 link-local address may still embed a MAC address (EUI-64); the strict level anonymizes those addresses.
+
Domains
All domain names (except for the netbird domains) are replaced with randomly generated strings ending in ".domain". Anonymized domains are consistent across all files in the bundle.
Reoccuring domain names are replaced with the same anonymized domain.
+At the strict level, the peer name labels in front of netbird domains are anonymized as well.
Sync Response
The network_map.json file contains the following anonymized information:
@@ -225,6 +238,13 @@ scutil_dns.txt (macOS only):
- Shows DNS configuration for all network interfaces
- Includes search domains, nameservers, and DNS resolver settings
- All IP addresses and domain names are anonymized
+
+dns_windows.txt (Windows only):
+- Lists the NRPT rules of both policy stores, the local one and the group policy one, marking the rules the client created
+- Follows them with the policy table the resolver has loaded, which differs from the rules while a change has not been picked up yet
+- Includes the DNS client group policy, the global TCP/IP and Dnscache parameters, and the DNS values of every interface that has any
+- Ends with the resolver configuration in effect per adapter, from GetAdaptersAddresses
+- All IP addresses and domain names are anonymized
`
const (
@@ -232,6 +252,12 @@ const (
errorLogFile = "netbird.err"
stdoutLogFile = "netbird.out"
+ // Rotated-log glob prefixes (base log name without extension) passed to
+ // addRotatedLogFiles. The daemon's own log and the GUI log live in the same
+ // dir, so the prefixes must be disjoint to keep their rotated siblings apart.
+ clientLogPrefix = "client"
+ uiLogPrefix = "gui-client"
+
darwinErrorLogPath = "/var/log/netbird.out.log"
darwinStdoutLogPath = "/var/log/netbird.err.log"
)
@@ -241,6 +267,20 @@ type MetricsExporter interface {
Export(w io.Writer) error
}
+// LogOpener opens a log file for inclusion in the bundle. It exists so that log
+// files whose path was supplied by an IPC caller can be opened under a check
+// the daemon defines, instead of being opened with the daemon's privileges
+// unconditionally.
+type LogOpener func(path string) (*os.File, error)
+
+func openLogFile(path string) (*os.File, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", path, err)
+ }
+ return f, nil
+}
+
type BundleGenerator struct {
anonymizer *anonymize.Anonymizer
@@ -249,6 +289,8 @@ type BundleGenerator struct {
statusRecorder *peer.Status
syncResponse *mgmProto.SyncResponse
logPath string
+ uiLogPath string
+ uiLogOpener LogOpener
tempDir string
statePath string
cpuProfile []byte
@@ -259,6 +301,7 @@ type BundleGenerator struct {
cliVersion string
anonymize bool
+ anonymizeLevel anonymize.Level
includeSystemInfo bool
logFileCount uint32
@@ -266,7 +309,10 @@ type BundleGenerator struct {
}
type BundleConfig struct {
- Anonymize bool
+ Anonymize bool
+ // AnonymizeLevel selects how much the anonymizer redacts.
+ // anonymize.LevelStrict implies Anonymize.
+ AnonymizeLevel anonymize.Level
IncludeSystemInfo bool
LogFileCount uint32
}
@@ -276,14 +322,21 @@ type GeneratorDependencies struct {
StatusRecorder *peer.Status
SyncResponse *mgmProto.SyncResponse
LogPath string
- TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
- StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
- CPUProfile []byte
- CapturePath string
- RefreshStatus func()
- ClientMetrics MetricsExporter
- DaemonVersion string
- CliVersion string
+ UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one.
+ // UILogOpener opens the UI log and its rotated siblings. The path comes from
+ // a local IPC caller, so the daemon must not open it with plain os.Open: the
+ // opener is where the caller's right to that file is enforced. Defaults to
+ // os.Open, which is only correct where the path is not caller-supplied
+ // (mobile).
+ UILogOpener LogOpener
+ TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
+ StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
+ CPUProfile []byte
+ CapturePath string
+ RefreshStatus func()
+ ClientMetrics MetricsExporter
+ DaemonVersion string
+ CliVersion string
}
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
@@ -293,13 +346,23 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
logFileCount = 1
}
+ uiLogOpener := deps.UILogOpener
+ if uiLogOpener == nil {
+ uiLogOpener = openLogFile
+ }
+
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ anonymizer.SetLevel(cfg.AnonymizeLevel)
+
return &BundleGenerator{
- anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
+ anonymizer: anonymizer,
internalConfig: deps.InternalConfig,
statusRecorder: deps.StatusRecorder,
syncResponse: deps.SyncResponse,
logPath: deps.LogPath,
+ uiLogPath: deps.UILogPath,
+ uiLogOpener: uiLogOpener,
tempDir: deps.TempDir,
statePath: deps.StatePath,
cpuProfile: deps.CPUProfile,
@@ -309,7 +372,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
daemonVersion: deps.DaemonVersion,
cliVersion: deps.CliVersion,
- anonymize: cfg.Anonymize,
+ anonymize: cfg.Anonymize || cfg.AnonymizeLevel >= anonymize.LevelStrict,
+ anonymizeLevel: cfg.AnonymizeLevel,
includeSystemInfo: cfg.IncludeSystemInfo,
logFileCount: logFileCount,
}
@@ -411,6 +475,10 @@ func (g *BundleGenerator) createArchive() error {
log.Errorf("failed to add logs to debug bundle: %v", err)
}
+ if err := g.addUILog(); err != nil {
+ log.Errorf("failed to add UI log to debug bundle: %v", err)
+ }
+
if err := g.addUpdateLogs(); err != nil {
log.Errorf("failed to add updater logs: %v", err)
}
@@ -445,7 +513,13 @@ func (g *BundleGenerator) addSystemInfo() {
}
func (g *BundleGenerator) addReadme() error {
- readmeReader := strings.NewReader(readmeContent)
+ level := "none (anonymization disabled)"
+ if g.anonymize {
+ level = g.anonymizeLevel.String()
+ }
+ header := fmt.Sprintf("Netbird debug bundle\nAnonymization level applied to this bundle: %s\n", level)
+
+ readmeReader := strings.NewReader(header + readmeContent)
if err := g.addFileToZip(readmeReader, "README.txt"); err != nil {
return fmt.Errorf("add README file to zip: %w", err)
}
@@ -466,11 +540,11 @@ func (g *BundleGenerator) addStatus() error {
fullStatus := g.statusRecorder.GetFullStatus()
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
- protoFullStatus.Events = g.statusRecorder.GetEventHistory()
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
- Anonymize: g.anonymize,
- ProfileName: profName,
- DaemonVersion: g.daemonVersion,
+ Anonymize: g.anonymize,
+ AnonymizeLevel: g.anonymizeLevel,
+ ProfileName: profName,
+ DaemonVersion: g.daemonVersion,
})
overview.CliVersion = g.cliVersion
statusOutput := overview.FullDetailSummary()
@@ -623,7 +697,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString("NetBird Client Configuration:\n\n")
if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil {
- configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", key.PublicKey().String()))
+ configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", g.anonymizer.AnonymizeWGKey(key.PublicKey().String())))
}
configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface))
configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort))
@@ -663,6 +737,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
+ configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
if g.internalConfig.DisableNotifications != nil {
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))
@@ -681,7 +756,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("ClientCertKeyPath: %s\n", g.internalConfig.ClientCertKeyPath))
}
- configContent.WriteString(fmt.Sprintf("LazyConnectionEnabled: %v\n", g.internalConfig.LazyConnectionEnabled))
+ configContent.WriteString(fmt.Sprintf("LazyConnection: %q\n", g.internalConfig.LazyConnection))
configContent.WriteString(fmt.Sprintf("MTU: %d\n", g.internalConfig.MTU))
}
@@ -912,6 +987,11 @@ func (g *BundleGenerator) addUpdateLogs() error {
}
baseName := filepath.Base(logFile)
+ data, err = g.anonymizeBytes(data)
+ if err != nil {
+ log.Warnf("skipping update log file %s: %v", baseName, err)
+ continue
+ }
if err := g.addFileToZip(bytes.NewReader(data), filepath.Join("update-logs", baseName)); err != nil {
return fmt.Errorf("add update log file %s to zip: %w", baseName, err)
}
@@ -939,6 +1019,13 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
}
fileName := filepath.Base(match)
+ // Corrupted state files usually fail structured JSON anonymization,
+ // so run them through the string anonymizer instead.
+ data, err = g.anonymizeBytes(data)
+ if err != nil {
+ log.Warnf("skipping corrupted state file %s: %v", fileName, err)
+ continue
+ }
if err := g.addFileToZip(bytes.NewReader(data), "corrupted_states/"+fileName); err != nil {
log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err)
continue
@@ -950,6 +1037,27 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
return nil
}
+// anonymizeBytes runs raw file content through the string anonymizer line by
+// line when anonymization is enabled. It errors instead of returning partial
+// content, so a caller never adds an unanonymized fallback to the bundle.
+func (g *BundleGenerator) anonymizeBytes(data []byte) ([]byte, error) {
+ if !g.anonymize {
+ return data, nil
+ }
+
+ var buf bytes.Buffer
+ scanner := bufio.NewScanner(bytes.NewReader(data))
+ scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
+ for scanner.Scan() {
+ buf.WriteString(g.anonymizer.AnonymizeString(scanner.Text()))
+ buf.WriteByte('\n')
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("anonymize content: %w", err)
+ }
+ return buf.Bytes(), nil
+}
+
func (g *BundleGenerator) addMetrics() error {
if g.clientMetrics == nil {
log.Debugf("skipping metrics in debug bundle: no metrics collector")
@@ -982,11 +1090,11 @@ func (g *BundleGenerator) addLogfile() error {
logDir := filepath.Dir(g.logPath)
- if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil {
+ if err := g.addSingleLogfile(openLogFile, g.logPath, clientLogFile); err != nil {
return fmt.Errorf("add client log file to zip: %w", err)
}
- g.addRotatedLogFiles(logDir)
+ g.addRotatedLogFiles(openLogFile, logDir, clientLogPrefix)
stdErrLogPath := filepath.Join(logDir, errorLogFile)
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
@@ -995,20 +1103,39 @@ func (g *BundleGenerator) addLogfile() error {
stdoutLogPath = darwinStdoutLogPath
}
- if err := g.addSingleLogfile(stdErrLogPath, errorLogFile); err != nil {
+ if err := g.addSingleLogfile(openLogFile, stdErrLogPath, errorLogFile); err != nil {
log.Warnf("Failed to add %s to zip: %v", errorLogFile, err)
}
- if err := g.addSingleLogfile(stdoutLogPath, stdoutLogFile); err != nil {
+ if err := g.addSingleLogfile(openLogFile, stdoutLogPath, stdoutLogFile); err != nil {
log.Warnf("Failed to add %s to zip: %v", stdoutLogFile, err)
}
return nil
}
+// addUILog adds the desktop UI's gui-client.log (and its rotated siblings) to
+// the bundle. The path is reported by the UI via RegisterUILog; empty when no
+// UI registered one (e.g. headless / server). Missing file is non-fatal — the
+// UI only writes it while the daemon is in debug, so it's often absent.
+func (g *BundleGenerator) addUILog() error {
+ if g.uiLogPath == "" {
+ log.Debugf("no UI log path registered, skipping in debug bundle")
+ return nil
+ }
+
+ if err := g.addSingleLogfile(g.uiLogOpener, g.uiLogPath, configs.UILogFile); err != nil {
+ return fmt.Errorf("add UI log file to zip: %w", err)
+ }
+
+ g.addRotatedLogFiles(g.uiLogOpener, filepath.Dir(g.uiLogPath), uiLogPrefix)
+
+ return nil
+}
+
// addSingleLogfile adds a single log file to the archive
-func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
- logFile, err := os.Open(logPath)
+func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName string) error {
+ logFile, err := open(logPath)
if err != nil {
return fmt.Errorf("open log file %s: %w", targetName, err)
}
@@ -1033,8 +1160,8 @@ func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
}
// addSingleLogFileGz adds a single gzipped log file to the archive
-func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
- f, err := os.Open(logPath)
+func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName string) error {
+ f, err := open(logPath)
if err != nil {
return fmt.Errorf("open gz log file %s: %w", targetName, err)
}
@@ -1078,14 +1205,16 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
return nil
}
-// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount
-func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
+// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount.
+// prefix is the base log name without extension (e.g. "client", "gui-client");
+// the glob matches both files rotated by us and by logrotate on linux.
+func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix string) {
if g.logFileCount == 0 {
return
}
- // This regex will match both logs rotated by us and logrotate on linux
- pattern := filepath.Join(logDir, "client*.log.*")
+ // This pattern matches both logs rotated by us and logrotate on linux
+ pattern := filepath.Join(logDir, prefix+"*.log.*")
files, err := filepath.Glob(pattern)
if err != nil {
log.Warnf("failed to glob rotated logs: %v", err)
@@ -1119,9 +1248,9 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
for i := 0; i < maxFiles; i++ {
name := filepath.Base(files[i])
if strings.HasSuffix(name, ".gz") {
- err = g.addSingleLogFileGz(files[i], name)
+ err = g.addSingleLogFileGz(open, files[i], name)
} else {
- err = g.addSingleLogfile(files[i], name)
+ err = g.addSingleLogfile(open, files[i], name)
}
if err != nil {
log.Warnf("failed to add rotated log %s: %v", name, err)
@@ -1401,6 +1530,7 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize.
}
peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn)
+ peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey)
anonymizeSSHConfig(peer.SshConfig)
}
diff --git a/client/internal/debug/debug_ios.go b/client/internal/debug/debug_ios.go
index a07c23dbd..001d64241 100644
--- a/client/internal/debug/debug_ios.go
+++ b/client/internal/debug/debug_ios.go
@@ -27,7 +27,7 @@ func (g *BundleGenerator) addPlatformLog() error {
}
swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile)
- if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil {
+ if err := g.addSingleLogfile(openLogFile, swiftLogPath, swiftLogFile); err != nil {
// The Swift log is best-effort: the app may not have written it yet.
log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err)
}
diff --git a/client/internal/debug/debug_linux.go b/client/internal/debug/debug_linux.go
index 40d864eda..a36c0c0e7 100644
--- a/client/internal/debug/debug_linux.go
+++ b/client/internal/debug/debug_linux.go
@@ -844,6 +844,10 @@ func collectSysctls() string {
[]string{"net.ipv4.conf.all.src_valid_mark", "net.ipv4.conf.default.src_valid_mark"},
listInterfaceSysctls("ipv4", "src_valid_mark")...,
))
+ writeSysctlGroup(&builder, "accept_ra", append(
+ []string{"net.ipv6.conf.all.accept_ra", "net.ipv6.conf.default.accept_ra"},
+ listInterfaceSysctls("ipv6", "accept_ra")...,
+ ))
writeSysctlGroup(&builder, "conntrack", []string{
"net.netfilter.nf_conntrack_acct",
"net.netfilter.nf_conntrack_tcp_loose",
diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go
index f6473f979..31420711f 100644
--- a/client/internal/debug/debug_logfiles_test.go
+++ b/client/internal/debug/debug_logfiles_test.go
@@ -40,6 +40,25 @@ func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
}
+// TestAddRotatedLogFiles_GUIPrefix asserts the prefix parameter scopes the glob
+// to the GUI log: gui-client.log.* rotated siblings are picked up and the
+// daemon's own client.log.* are not (and vice versa, covered above). This is
+// the load-bearing check for the gui-client.log bundle collection — the old
+// "client*.log.*" glob would have missed gui-client rotations.
+func TestAddRotatedLogFiles_GUIPrefix(t *testing.T) {
+ dir := t.TempDir()
+
+ writeFile(t, filepath.Join(dir, "gui-client.log.1"), "gui rotated\n")
+ writeGzFile(t, filepath.Join(dir, "gui-client.log.2.gz"), "gui rotated gz\n")
+ writeFile(t, filepath.Join(dir, "client.log.1"), "daemon rotated\n")
+
+ names := runAddRotatedLogFilesPrefix(t, dir, "gui-client", 10)
+
+ require.Contains(t, names, "gui-client.log.1", "gui-client rotated file should be in bundle")
+ require.Contains(t, names, "gui-client.log.2.gz", "gui-client gz rotated file should be in bundle")
+ require.NotContains(t, names, "client.log.1", "daemon rotated file must not match the gui-client prefix")
+}
+
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
// logFileCount rotated files are bundled, ordered by mtime.
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
@@ -67,6 +86,10 @@ func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
// zip writer and returns the set of entry names that ended up in the archive.
func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
+ return runAddRotatedLogFilesPrefix(t, dir, "client", logFileCount)
+}
+
+func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount uint32) map[string]struct{} {
t.Helper()
var buf bytes.Buffer
@@ -74,7 +97,7 @@ func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[st
archive: zip.NewWriter(&buf),
logFileCount: logFileCount,
}
- g.addRotatedLogFiles(dir)
+ g.addRotatedLogFiles(openLogFile, dir, prefix)
require.NoError(t, g.archive.Close())
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
diff --git a/client/internal/debug/debug_nonunix.go b/client/internal/debug/debug_nonunix.go
index 18d017050..adc9b9649 100644
--- a/client/internal/debug/debug_nonunix.go
+++ b/client/internal/debug/debug_nonunix.go
@@ -1,4 +1,4 @@
-//go:build !unix
+//go:build !unix && !windows
package debug
diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go
index ca7785d35..7fe93a5c1 100644
--- a/client/internal/debug/debug_test.go
+++ b/client/internal/debug/debug_test.go
@@ -885,8 +885,10 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
DNSRouteInterval: 5 * time.Second,
ClientCertPath: "/tmp/cert",
ClientCertKeyPath: "/tmp/key",
- LazyConnectionEnabled: true,
+ LazyConnection: "on",
MTU: 1280,
+ DisableIPv6: true,
+ SyncMessageVersion: func(v int) *int { return &v }(1),
}
for _, anonymize := range []bool{false, true} {
diff --git a/client/internal/debug/debug_windows.go b/client/internal/debug/debug_windows.go
new file mode 100644
index 000000000..e88940fd3
--- /dev/null
+++ b/client/internal/debug/debug_windows.go
@@ -0,0 +1,443 @@
+//go:build windows
+
+package debug
+
+import (
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/netip"
+ "strings"
+ "unsafe"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows"
+ "golang.org/x/sys/windows/registry"
+
+ nbdns "github.com/netbirdio/netbird/client/internal/dns"
+)
+
+const dnsInfoFileName = "dns_windows.txt"
+
+const (
+ gpoDNSClientRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient`
+ tcpipParamsPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters`
+ dnscacheParams = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters`
+)
+
+// interfaceDNSValues are the per-interface values that decide how a name is
+// resolved and registered. Everything the DNS host manager writes is in here,
+// so a bundle shows both what we set and what it replaced.
+var interfaceDNSValues = []string{
+ "NameServer",
+ "DhcpNameServer",
+ "Domain",
+ "DhcpDomain",
+ "SearchList",
+ "RegistrationEnabled",
+ "DisableDynamicUpdate",
+ "MaxNumberOfAddressesToRegister",
+ "EnableDHCP",
+}
+
+// addDNSInfo collects and adds DNS configuration information to the archive
+func (g *BundleGenerator) addDNSInfo() error {
+ if err := g.addFileToZip(strings.NewReader(g.collectDNSInfo()), dnsInfoFileName); err != nil {
+ return fmt.Errorf("add DNS info to zip: %w", err)
+ }
+
+ return nil
+}
+
+// collectDNSInfo renders the report. Everything below it reaches the platform
+// through COM and through lazily resolved procedures, which panic when a
+// procedure is missing rather than returning an error, and a debug bundle is not
+// allowed to take the daemon down. The panic is contained here, and whatever was
+// collected before it is kept and reported with it.
+func (g *BundleGenerator) collectDNSInfo() (content string) {
+ var sb strings.Builder
+
+ defer func() {
+ if r := recover(); r != nil {
+ log.Errorf("collecting Windows DNS configuration panicked: %v", r)
+ fmt.Fprintf(&sb, "\nerror: collection stopped: %v\n", r)
+ }
+ content = sb.String()
+ }()
+
+ sb.WriteString("Windows DNS configuration\n")
+ sb.WriteString("=========================\n")
+
+ adapters, adaptersErr := adapterAddresses()
+
+ g.writeNRPTRules(&sb, "NRPT rules, local policy store", nbdns.DNSPolicyConfigRoot)
+ g.writeNRPTRules(&sb, "NRPT rules, group policy store", nbdns.GPODNSPolicyConfigRoot)
+ g.writeEffectiveNRPTPolicies(&sb)
+ g.writeRegistryKey(&sb, "DNS client group policy", gpoDNSClientRoot)
+ g.writeRegistryKey(&sb, "Global TCP/IP parameters", tcpipParamsPath)
+ g.writeRegistryKey(&sb, "Dnscache parameters", dnscacheParams)
+ g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv4", nbdns.InterfaceConfigPath, adapterNames(adapters))
+ g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv6", nbdns.InterfaceConfigPathV6, adapterNames(adapters))
+ g.writeAdapterDNS(&sb, adapters, adaptersErr)
+
+ return sb.String()
+}
+
+// writeNRPTRules lists every rule in a policy store, ours and any other
+// product's, since a foreign rule for the same namespace decides resolution
+// just as ours does. Rules the client wrote are marked.
+func (g *BundleGenerator) writeNRPTRules(sb *strings.Builder, title, root string) {
+ writeSection(sb, title, root)
+
+ names, err := subKeyNames(root)
+ if err != nil {
+ fmt.Fprintf(sb, "error: %v\n", err)
+ return
+ }
+
+ if len(names) == 0 {
+ sb.WriteString("no rules\n")
+ return
+ }
+
+ for _, name := range names {
+ owner := ""
+ if strings.HasPrefix(strings.ToLower(name), strings.ToLower(nbdns.NRPTKeyPrefix)) {
+ owner = " (netbird)"
+ }
+ fmt.Fprintf(sb, "%s%s\n", name, owner)
+ g.writeValues(sb, root+`\`+name, nil, " ")
+ }
+}
+
+// writeEffectiveNRPTPolicies reports the table the resolver answers from, which
+// the registry cannot show: a rule is written before it is loaded, and it keeps
+// being enforced after its key is gone until the resolver reloads its policy.
+func (g *BundleGenerator) writeEffectiveNRPTPolicies(sb *strings.Builder) {
+ writeSection(sb, "NRPT policy table in effect", nrptPolicyClass+"."+nrptPolicyMethod+" in "+nrptPolicyNamespace)
+
+ entries, err := effectiveNRPTPolicies()
+ if err != nil {
+ fmt.Fprintf(sb, "error: %v\n", err)
+ return
+ }
+
+ if len(entries) == 0 {
+ sb.WriteString("no policies\n")
+ return
+ }
+
+ for _, entry := range entries {
+ fmt.Fprintf(sb, "%s\n", g.anonymizeValue("Namespace", entry.namespace))
+ for _, value := range entry.values {
+ fmt.Fprintf(sb, " %s: %s\n", value.name, g.anonymizeValue(value.name, value.value))
+ }
+ }
+}
+
+// writeInterfaceDNS reports the DNS values of every interface that has any, so
+// the netbird interface can be compared against the physical ones. The registry
+// keys the values by GUID, so each is named from the adapter list; a GUID with
+// no adapter is a leftover key of an interface that no longer exists.
+func (g *BundleGenerator) writeInterfaceDNS(sb *strings.Builder, title, root string, names map[string]string) {
+ writeSection(sb, title, root)
+
+ guids, err := subKeyNames(root)
+ if err != nil {
+ fmt.Fprintf(sb, "error: %v\n", err)
+ return
+ }
+
+ var reported int
+ for _, guid := range guids {
+ var iface strings.Builder
+ g.writeValues(&iface, root+`\`+guid, interfaceDNSValues, " ")
+ if iface.Len() == 0 {
+ continue
+ }
+
+ name, ok := names[strings.ToLower(guid)]
+ if !ok {
+ name = "no adapter with this GUID"
+ }
+
+ reported++
+ fmt.Fprintf(sb, "%s (%s)\n%s", guid, name, iface.String())
+ }
+
+ if reported == 0 {
+ sb.WriteString("no interface holds DNS values\n")
+ }
+}
+
+// writeRegistryKey reports the values of a single key, without its subkeys.
+func (g *BundleGenerator) writeRegistryKey(sb *strings.Builder, title, path string) {
+ writeSection(sb, title, path)
+
+ var values strings.Builder
+ g.writeValues(&values, path, nil, "")
+ if values.Len() == 0 {
+ sb.WriteString("no values\n")
+ return
+ }
+
+ sb.WriteString(values.String())
+}
+
+// writeValues renders the values of a key. A nil names list reports every
+// value, otherwise only those named and present.
+func (g *BundleGenerator) writeValues(sb *strings.Builder, path string, names []string, indent string) {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
+ switch {
+ case errors.Is(err, registry.ErrNotExist), errors.Is(err, windows.ERROR_PATH_NOT_FOUND):
+ // an absent key is the normal state for the GPO store and for
+ // interfaces without DNS settings
+ log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", path)
+ return
+ case err != nil:
+ fmt.Fprintf(sb, "%serror: open HKEY_LOCAL_MACHINE\\%s: %v\n", indent, path, err)
+ return
+ }
+ defer closeKey(k)
+
+ if names == nil {
+ names, err = k.ReadValueNames(-1)
+ if err != nil {
+ fmt.Fprintf(sb, "%serror: read value names: %v\n", indent, err)
+ return
+ }
+ }
+
+ for _, name := range names {
+ value, err := readRegistryValue(k, name)
+ switch {
+ case errors.Is(err, registry.ErrNotExist):
+ // the caller asks for a fixed set of values, most of which a
+ // given interface does not carry
+ continue
+ case err != nil:
+ // report rather than omit: a value that is there but cannot be
+ // read reads as unset otherwise
+ fmt.Fprintf(sb, "%s%s: error: %v\n", indent, name, err)
+ continue
+ }
+
+ fmt.Fprintf(sb, "%s%s: %s\n", indent, name, g.anonymizeValue(name, value))
+ }
+}
+
+// anonymizeValue redacts a registry value according to what its name says it
+// holds. Domains and addresses are handled per entry rather than by the string
+// pass: the pass only replaces domains something else in the bundle already
+// seeded, and its address regex would eat the digit labels of a reverse zone.
+func (g *BundleGenerator) anonymizeValue(name, value string) string {
+ if !g.anonymize || value == "" {
+ return value
+ }
+
+ switch {
+ case holdsDomains(name):
+ return joinValueEntries(splitValueEntries(value), g.anonymizeDomain)
+ case holdsAddresses(name):
+ return joinValueEntries(splitValueEntries(value), g.anonymizer.AnonymizeIPString)
+ default:
+ return g.anonymizer.AnonymizeString(value)
+ }
+}
+
+// holdsDomains reports whether a value name holds domains: the domain list of
+// an NRPT rule (Name) or of the policy table (Namespace), a search list, the
+// DNS suffix values of the TCP/IP and policy keys, which all end in "Domain"
+// (Domain, DhcpDomain, NV Domain, ICSDomain), and a proxy host name.
+func holdsDomains(name string) bool {
+ lower := strings.ToLower(name)
+ return lower == "name" || lower == "namespace" || lower == "searchlist" ||
+ strings.HasSuffix(lower, "domain") || strings.HasSuffix(lower, "proxyname")
+}
+
+// holdsAddresses reports whether a value name holds DNS server addresses
+// (NameServer, DhcpNameServer, GenericDNSServers, NameServers).
+func holdsAddresses(name string) bool {
+ lower := strings.ToLower(name)
+ return strings.Contains(lower, "nameserver") || strings.Contains(lower, "dnsserver")
+}
+
+// adapterNames maps adapter GUIDs, as the registry keys the interfaces, to the
+// names an operator sees.
+func adapterNames(adapters []*windows.IpAdapterAddresses) map[string]string {
+ names := make(map[string]string, len(adapters))
+ for _, adapter := range adapters {
+ guid := windows.BytePtrToString(adapter.AdapterName)
+ names[strings.ToLower(guid)] = windows.UTF16PtrToString(adapter.FriendlyName)
+ }
+ return names
+}
+
+// writeAdapterDNS reports the resolver configuration in effect per adapter,
+// which is what the resolver uses for a name no NRPT rule matches.
+func (g *BundleGenerator) writeAdapterDNS(sb *strings.Builder, adapters []*windows.IpAdapterAddresses, err error) {
+ writeSection(sb, "Adapter DNS configuration", "GetAdaptersAddresses")
+
+ if err != nil {
+ fmt.Fprintf(sb, "error: %v\n", err)
+ return
+ }
+
+ for _, adapter := range adapters {
+ name := windows.UTF16PtrToString(adapter.FriendlyName)
+ suffix := g.anonymizeDomain(windows.UTF16PtrToString(adapter.DnsSuffix))
+
+ fmt.Fprintf(sb, "%s (index %d, oper status %d)\n", name, adapter.IfIndex, adapter.OperStatus)
+ fmt.Fprintf(sb, " DNS suffix: %s\n", suffix)
+
+ var servers []string
+ for server := adapter.FirstDnsServerAddress; server != nil; server = server.Next {
+ addr, ok := netip.AddrFromSlice(server.Address.IP())
+ if !ok {
+ continue
+ }
+
+ addr = addr.Unmap()
+ if g.anonymize {
+ addr = g.anonymizer.AnonymizeIP(addr)
+ }
+ servers = append(servers, addr.String())
+ }
+
+ fmt.Fprintf(sb, " DNS servers: %s\n", strings.Join(servers, ", "))
+ }
+}
+
+// anonymizeDomain anonymizes a single domain, keeping the leading dot an NRPT
+// match domain carries.
+func (g *BundleGenerator) anonymizeDomain(entry string) string {
+ if !g.anonymize {
+ return entry
+ }
+
+ domain, dot := strings.CutPrefix(entry, ".")
+ if domain == "" {
+ return entry
+ }
+
+ anonymized := g.anonymizer.AnonymizeDomain(domain)
+ if dot {
+ anonymized = "." + anonymized
+ }
+ return anonymized
+}
+
+// splitValueEntries splits a registry value that holds a list. The separator
+// differs per value: a REG_MULTI_SZ arrives joined with ", ", a SearchList is
+// comma separated and a NameServer may use commas or spaces.
+func splitValueEntries(value string) []string {
+ return strings.FieldsFunc(value, func(r rune) bool {
+ return r == ',' || r == ';' || r == ' ' || r == '\t'
+ })
+}
+
+func joinValueEntries(entries []string, anonymize func(string) string) string {
+ for i, entry := range entries {
+ entries[i] = anonymize(entry)
+ }
+ return strings.Join(entries, ", ")
+}
+
+func writeSection(sb *strings.Builder, title, source string) {
+ fmt.Fprintf(sb, "\n%s\n%s\n%s\n", title, strings.Repeat("-", len(title)), source)
+}
+
+func subKeyNames(root string) ([]string, error) {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS)
+ if err != nil {
+ return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err)
+ }
+ defer closeKey(k)
+
+ names, err := k.ReadSubKeyNames(-1)
+ if err != nil {
+ return nil, fmt.Errorf("read subkey names: %w", err)
+ }
+
+ return names, nil
+}
+
+// readRegistryValue renders a value as text regardless of its type, so an
+// unexpected type in a policy key still shows up instead of being dropped.
+func readRegistryValue(k registry.Key, name string) (string, error) {
+ _, valueType, err := k.GetValue(name, nil)
+ if err != nil {
+ return "", fmt.Errorf("get value %s: %w", name, err)
+ }
+
+ switch valueType {
+ case registry.SZ, registry.EXPAND_SZ:
+ value, _, err := k.GetStringValue(name)
+ if err != nil {
+ return "", fmt.Errorf("get string value %s: %w", name, err)
+ }
+ return value, nil
+ case registry.MULTI_SZ:
+ values, _, err := k.GetStringsValue(name)
+ if err != nil {
+ return "", fmt.Errorf("get strings value %s: %w", name, err)
+ }
+ return strings.Join(values, ", "), nil
+ case registry.DWORD, registry.QWORD:
+ value, _, err := k.GetIntegerValue(name)
+ if err != nil {
+ return "", fmt.Errorf("get integer value %s: %w", name, err)
+ }
+ return fmt.Sprintf("%d (0x%x)", value, value), nil
+ case registry.BINARY:
+ value, _, err := k.GetBinaryValue(name)
+ if err != nil {
+ return "", fmt.Errorf("get binary value %s: %w", name, err)
+ }
+ return hex.EncodeToString(value), nil
+ default:
+ return fmt.Sprintf("", valueType), nil
+ }
+}
+
+// adapterAddresses returns the adapter list including DNS servers. The call
+// reports the size it needs, so grow the buffer and retry until it fits.
+func adapterAddresses() (adapters []*windows.IpAdapterAddresses, err error) {
+ // GetAdaptersAddresses is resolved on first use and panics when it is
+ // missing, so this reports it as an error and leaves the rest of the
+ // report intact.
+ defer func() {
+ if r := recover(); r != nil {
+ adapters, err = nil, fmt.Errorf("GetAdaptersAddresses: %v", r)
+ }
+ }()
+
+ const flags = windows.GAA_FLAG_SKIP_ANYCAST | windows.GAA_FLAG_SKIP_MULTICAST
+
+ size := uint32(15000)
+ for range 3 {
+ buf := make([]byte, size)
+ first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0]))
+
+ err := windows.GetAdaptersAddresses(windows.AF_UNSPEC, flags, 0, first, &size)
+ if errors.Is(err, windows.ERROR_BUFFER_OVERFLOW) {
+ continue
+ }
+ if err != nil {
+ return nil, fmt.Errorf("GetAdaptersAddresses: %w", err)
+ }
+
+ for adapter := first; adapter != nil; adapter = adapter.Next {
+ adapters = append(adapters, adapter)
+ }
+ return adapters, nil
+ }
+
+ return nil, fmt.Errorf("GetAdaptersAddresses: buffer kept growing")
+}
+
+func closeKey(k registry.Key) {
+ if err := k.Close(); err != nil {
+ log.Debugf("close registry key: %v", err)
+ }
+}
diff --git a/client/internal/debug/debug_windows_test.go b/client/internal/debug/debug_windows_test.go
new file mode 100644
index 000000000..47df3f6f9
--- /dev/null
+++ b/client/internal/debug/debug_windows_test.go
@@ -0,0 +1,146 @@
+//go:build windows
+
+package debug
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/anonymize"
+)
+
+func newDNSValueGenerator(level anonymize.Level) *BundleGenerator {
+ anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ anonymizer.SetLevel(level)
+
+ return &BundleGenerator{
+ anonymize: true,
+ anonymizeLevel: level,
+ anonymizer: anonymizer,
+ }
+}
+
+// TestAnonymizeValueByName covers the value kinds of the DNS registry keys. The
+// names decide the treatment, because the string pass alone replaces only
+// domains another part of the bundle already seeded.
+func TestAnonymizeValueByName(t *testing.T) {
+ tests := []struct {
+ name string
+ valueName string
+ value string
+ assert func(t *testing.T, got string)
+ }{
+ {
+ name: "NRPT match domains keep the leading dot",
+ valueName: "Name",
+ value: ".internal.example.com, .corp.example.org",
+ assert: func(t *testing.T, got string) {
+ t.Helper()
+ for _, entry := range strings.Split(got, ", ") {
+ assert.True(t, strings.HasPrefix(entry, "."), "entry %q should keep its leading dot", entry)
+ assert.NotContains(t, entry, "example", "entry %q should not keep the original domain", entry)
+ }
+ },
+ },
+ {
+ name: "any value name ending in Domain is treated as a domain",
+ valueName: "ICSDomain",
+ value: "mshome.net",
+ assert: func(t *testing.T, got string) {
+ t.Helper()
+ assert.NotContains(t, got, "mshome", "should anonymize a domain suffix value")
+ },
+ },
+ {
+ name: "search list is a comma separated domain list",
+ valueName: "SearchList",
+ value: "corp.example.com,branch.example.com",
+ assert: func(t *testing.T, got string) {
+ t.Helper()
+ assert.NotContains(t, got, "example", "should anonymize every search domain")
+ assert.Len(t, strings.Split(got, ", "), 2, "should keep both search domains")
+ },
+ },
+ {
+ name: "name servers are anonymized as addresses",
+ valueName: "DhcpNameServer",
+ value: "203.0.113.10 8.8.8.8",
+ assert: func(t *testing.T, got string) {
+ t.Helper()
+ assert.NotContains(t, got, "203.0.113.10", "should anonymize a public resolver address")
+ // well-known resolvers stay readable at every level
+ assert.Contains(t, got, "8.8.8.8", "should keep a well-known resolver address")
+ },
+ },
+ {
+ name: "opaque values are left to the string pass",
+ valueName: "DataBasePath",
+ value: `%SystemRoot%\System32\drivers\etc`,
+ assert: func(t *testing.T, got string) {
+ t.Helper()
+ assert.Equal(t, `%SystemRoot%\System32\drivers\etc`, got, "should not alter a path")
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ g := newDNSValueGenerator(anonymize.LevelDefault)
+ tc.assert(t, g.anonymizeValue(tc.valueName, tc.value))
+ })
+ }
+}
+
+// TestParseNRPTPolicyTable parses the MOF text of the policy table out
+// parameters, as the provider on a client with one NRPT rule renders it.
+func TestParseNRPTPolicyTable(t *testing.T) {
+ const text = `[abstract]
+class __PARAMETERS
+{
+ [Out, EmbeddedInstance("DnsClientPolicyConfiguration"): ToSubClass, ID(2): DisableOverride ToInstance] DnsClientPolicyConfiguration cmdletOutput[] = {
+instance of DnsClientPolicyConfiguration
+{
+ DirectAccessProxyType = "NoProxy";
+ DirectAccessQueryIPsecRequired = FALSE;
+ NameEncoding = "Utf8WithoutMapping";
+ Namespace = ".0.100.in-addr.arpa";
+},
+instance of DnsClientPolicyConfiguration
+{
+ DirectAccessProxyType = "NoProxy";
+ NameEncoding = "Utf8WithoutMapping";
+ NameServers = {"100.0.255.254", "100.0.255.253"};
+ Namespace = ".nb.internal";
+}};
+ [in] boolean Effective;
+ [out] uint32 ReturnValue = 0;
+};
+`
+
+ entries := parseNRPTPolicyTable(text)
+ require.Len(t, entries, 2, "should parse both embedded instances")
+
+ assert.Equal(t, ".0.100.in-addr.arpa", entries[0].namespace, "should read the namespace of the first instance")
+ assert.Equal(t, ".nb.internal", entries[1].namespace, "should read the namespace of the second instance")
+
+ assert.Equal(t, []registryValue{
+ {name: "DirectAccessProxyType", value: "NoProxy"},
+ {name: "DirectAccessQueryIPsecRequired", value: "FALSE"},
+ {name: "NameEncoding", value: "Utf8WithoutMapping"},
+ }, entries[0].values, "should keep the remaining values in order")
+
+ assert.Contains(t, entries[1].values, registryValue{name: "NameServers", value: "100.0.255.254, 100.0.255.253"},
+ "should flatten a MOF array")
+
+ for _, value := range entries[1].values {
+ assert.NotContains(t, value.name, "ReturnValue", "should not read the class level parameters as values")
+ }
+}
+
+func TestParseNRPTPolicyTableEmpty(t *testing.T) {
+ assert.Empty(t, parseNRPTPolicyTable(""), "should parse no entries from empty text")
+ assert.Empty(t, parseNRPTPolicyTable("class __PARAMETERS\n{\n};\n"), "should parse no entries from a table with no instances")
+}
diff --git a/client/internal/debug/nrpt_windows.go b/client/internal/debug/nrpt_windows.go
new file mode 100644
index 000000000..6b6e0e29a
--- /dev/null
+++ b/client/internal/debug/nrpt_windows.go
@@ -0,0 +1,317 @@
+//go:build windows
+
+package debug
+
+import (
+ "errors"
+ "fmt"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/go-ole/go-ole"
+ "github.com/go-ole/go-ole/oleutil"
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ // The NRPT policy table is reachable through the CIM class that backs
+ // Get-DnsClientNrptPolicy. Unlike the rules in the registry, the table is
+ // what the resolver currently has loaded, which is the only way to tell an
+ // applied rule from one that is merely written, in either direction.
+ nrptPolicyNamespace = `root\Microsoft\Windows\DNS`
+ nrptPolicyClass = "PS_DnsClientNrptPolicy"
+ nrptPolicyMethod = "Get"
+
+ // The class has no instances, so the table comes from the out parameters
+ // of a static method call, rendered as MOF text: the embedded instances
+ // arrive as a safe array of objects, which cannot be read back through the
+ // COM bindings, and the text form carries all of them.
+ nrptPolicyInstanceKeyword = "instance of DnsClientPolicyConfiguration"
+
+ nrptPolicyTimeout = 15 * time.Second
+)
+
+// COM initialization results that leave the calling thread usable: S_FALSE for
+// a thread this process already initialized, RPC_E_CHANGED_MODE for one that
+// belongs to another apartment.
+const (
+ sFalse = 0x00000001
+ rpcEChangedMode = 0x80010106
+)
+
+// nrptQueryInFlight admits one read of the policy table at a time. A provider
+// that stops answering keeps its goroutine and the OS thread that goroutine
+// pinned, so a later bundle reports that instead of pinning another one.
+var nrptQueryInFlight = make(chan struct{}, 1)
+
+// nrptPolicyEntry is one namespace of the effective policy table, holding the
+// values of an embedded DnsClientPolicyConfiguration instance in the order the
+// provider reported them.
+type nrptPolicyEntry struct {
+ namespace string
+ values []registryValue
+}
+
+// registryValue is a name and its rendered value, shared by the registry and
+// policy table readers so both anonymize by value name the same way.
+type registryValue struct {
+ name string
+ value string
+}
+
+// effectiveNRPTPolicies reads the effective NRPT table. The call is bounded
+// because a WMI provider can block indefinitely and a debug bundle must not.
+func effectiveNRPTPolicies() ([]nrptPolicyEntry, error) {
+ type result struct {
+ text string
+ err error
+ }
+
+ select {
+ case nrptQueryInFlight <- struct{}{}:
+ default:
+ return nil, errors.New("an earlier read of the policy table has not returned")
+ }
+
+ done := make(chan result, 1)
+ go func() {
+ // the slot is released here rather than by the caller, so a read that
+ // outlives the timeout holds it until the provider answers
+ defer func() { <-nrptQueryInFlight }()
+
+ text, err := nrptPolicyTableText()
+ done <- result{text: text, err: err}
+ }()
+
+ select {
+ case res := <-done:
+ if res.err != nil {
+ return nil, res.err
+ }
+ return parseNRPTPolicyTable(res.text), nil
+ case <-time.After(nrptPolicyTimeout):
+ return nil, errors.New("read of the policy table timed out")
+ }
+}
+
+// nrptPolicyTableText calls the policy table method and returns the MOF text of
+// its out parameters.
+func nrptPolicyTableText() (text string, err error) {
+ // COM is per thread, and the collection is short lived, so the thread is
+ // pinned for the duration rather than initialized for the process.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ defer func() {
+ // The COM call chain is dynamically typed, so a provider that answers
+ // with an unexpected shape must not take the daemon down with it.
+ if r := recover(); r != nil {
+ err = fmt.Errorf("read NRPT policy table: %v", r)
+ }
+ }()
+
+ owns, err := coInitialize()
+ if err != nil {
+ return "", err
+ }
+ if owns {
+ defer ole.CoUninitialize()
+ }
+
+ locator, err := oleutil.CreateObject("WbemScripting.SWbemLocator")
+ if err != nil {
+ return "", fmt.Errorf("create WMI locator: %w", err)
+ }
+ defer locator.Release()
+
+ dispatch, err := locator.QueryInterface(ole.IID_IDispatch)
+ if err != nil {
+ return "", fmt.Errorf("query WMI locator interface: %w", err)
+ }
+ defer dispatch.Release()
+
+ service, err := dispatchCall(dispatch, "ConnectServer", nil, nrptPolicyNamespace)
+ if err != nil {
+ return "", fmt.Errorf("connect to %s: %w", nrptPolicyNamespace, err)
+ }
+ defer service.Release()
+
+ inParams, err := spawnMethodInParams(service)
+ if err != nil {
+ return "", err
+ }
+ defer inParams.Release()
+
+ // The effective table is the merge of the local and the group policy
+ // store, which is what the resolver answers from.
+ if _, err := oleutil.PutProperty(inParams, "Effective", true); err != nil {
+ return "", fmt.Errorf("set Effective parameter: %w", err)
+ }
+
+ outParams, err := dispatchCall(service, "ExecMethod", nrptPolicyClass, nrptPolicyMethod, inParams)
+ if err != nil {
+ return "", fmt.Errorf("call %s.%s: %w", nrptPolicyClass, nrptPolicyMethod, err)
+ }
+ defer outParams.Release()
+
+ textVariant, err := oleutil.CallMethod(outParams, "GetObjectText_")
+ if err != nil {
+ return "", fmt.Errorf("render policy table: %w", err)
+ }
+ defer func() {
+ if err := textVariant.Clear(); err != nil {
+ log.Debugf("clear policy table variant: %v", err)
+ }
+ }()
+
+ return textVariant.ToString(), nil
+}
+
+// spawnMethodInParams builds the in parameters instance the method needs. The
+// provider rejects the call without one, even when every parameter is optional.
+func spawnMethodInParams(service *ole.IDispatch) (*ole.IDispatch, error) {
+ class, err := dispatchCall(service, "Get", nrptPolicyClass)
+ if err != nil {
+ return nil, fmt.Errorf("get class %s: %w", nrptPolicyClass, err)
+ }
+ defer class.Release()
+
+ methods, err := dispatchProperty(class, "Methods_")
+ if err != nil {
+ return nil, fmt.Errorf("get class methods: %w", err)
+ }
+ defer methods.Release()
+
+ method, err := dispatchCall(methods, "Item", nrptPolicyMethod)
+ if err != nil {
+ return nil, fmt.Errorf("get method %s: %w", nrptPolicyMethod, err)
+ }
+ defer method.Release()
+
+ params, err := dispatchProperty(method, "InParameters")
+ if err != nil {
+ return nil, fmt.Errorf("get method parameters: %w", err)
+ }
+ defer params.Release()
+
+ inParams, err := dispatchCall(params, "SpawnInstance_")
+ if err != nil {
+ return nil, fmt.Errorf("spawn parameter instance: %w", err)
+ }
+
+ return inParams, nil
+}
+
+// parseNRPTPolicyTable pulls the embedded instances out of the MOF text. Each
+// instance is a namespace of the table, with one name and value per line.
+func parseNRPTPolicyTable(text string) []nrptPolicyEntry {
+ var entries []nrptPolicyEntry
+ var current *nrptPolicyEntry
+
+ for _, line := range strings.Split(text, "\n") {
+ line = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(line), ";"))
+
+ switch {
+ case strings.HasPrefix(line, nrptPolicyInstanceKeyword):
+ entries = append(entries, nrptPolicyEntry{})
+ current = &entries[len(entries)-1]
+ continue
+ case strings.HasPrefix(line, "}"):
+ // closes an instance, and the array with the last one, so the
+ // class level parameters that follow are not read as values
+ current = nil
+ continue
+ case current == nil, line == "{":
+ continue
+ }
+
+ name, value, ok := strings.Cut(line, " = ")
+ if !ok {
+ continue
+ }
+
+ value = unquoteMOFValue(value)
+ if name == "Namespace" {
+ current.namespace = value
+ continue
+ }
+
+ current.values = append(current.values, registryValue{name: name, value: value})
+ }
+
+ return entries
+}
+
+// unquoteMOFValue renders a MOF scalar or array as plain text: "a" becomes a,
+// and {"a", "b"} becomes a, b.
+func unquoteMOFValue(value string) string {
+ value = strings.TrimSpace(value)
+
+ if inner, ok := strings.CutPrefix(value, "{"); ok {
+ value = strings.TrimSuffix(inner, "}")
+
+ entries := strings.Split(value, ",")
+ for i, entry := range entries {
+ entries[i] = strings.Trim(strings.TrimSpace(entry), `"`)
+ }
+ return strings.Join(entries, ", ")
+ }
+
+ return strings.Trim(value, `"`)
+}
+
+// coInitialize prepares the calling thread for COM and reports whether this
+// call owns the initialization, which decides whether it may be balanced with
+// CoUninitialize. S_FALSE took a reference on a thread this process had already
+// initialized and so has to be released, while RPC_E_CHANGED_MODE took none:
+// the thread belongs to another apartment, which is usable but is not ours to
+// uninitialize.
+func coInitialize() (bool, error) {
+ err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED)
+ if err == nil {
+ return true, nil
+ }
+
+ var oleErr *ole.OleError
+ if errors.As(err, &oleErr) {
+ switch oleErr.Code() {
+ case sFalse:
+ return true, nil
+ case rpcEChangedMode:
+ return false, nil
+ }
+ }
+
+ return false, fmt.Errorf("initialize COM: %w", err)
+}
+
+// dispatchCall calls a COM method that returns an object.
+func dispatchCall(dispatch *ole.IDispatch, method string, params ...any) (*ole.IDispatch, error) {
+ variant, err := oleutil.CallMethod(dispatch, method, params...)
+ if err != nil {
+ return nil, err
+ }
+
+ object := variant.ToIDispatch()
+ if object == nil {
+ return nil, fmt.Errorf("%s returned no object", method)
+ }
+
+ return object, nil
+}
+
+// dispatchProperty reads a COM property that holds an object.
+func dispatchProperty(dispatch *ole.IDispatch, property string) (*ole.IDispatch, error) {
+ variant, err := oleutil.GetProperty(dispatch, property)
+ if err != nil {
+ return nil, err
+ }
+
+ object := variant.ToIDispatch()
+ if object == nil {
+ return nil, fmt.Errorf("property %s holds no object", property)
+ }
+
+ return object, nil
+}
diff --git a/client/internal/debug/uilog_test.go b/client/internal/debug/uilog_test.go
new file mode 100644
index 000000000..103e98c6f
--- /dev/null
+++ b/client/internal/debug/uilog_test.go
@@ -0,0 +1,64 @@
+package debug
+
+import (
+ "archive/zip"
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/configs"
+)
+
+// bundleEntries generates a bundle with the given generator and returns the
+// set of entry names in the resulting archive.
+func bundleEntries(t *testing.T, g *BundleGenerator) map[string]struct{} {
+ t.Helper()
+
+ path, err := g.Generate()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = os.Remove(path) })
+
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+ require.NoError(t, err)
+
+ names := make(map[string]struct{}, len(zr.File))
+ for _, f := range zr.File {
+ names[f.Name] = struct{}{}
+ }
+ return names
+}
+
+func TestBundleIncludesUILogWhenOpenerAllows(t *testing.T) {
+ path := filepath.Join(t.TempDir(), configs.UILogFile)
+ require.NoError(t, os.WriteFile(path, []byte("gui log"), 0600))
+
+ g := NewBundleGenerator(GeneratorDependencies{
+ UILogPath: path,
+ UILogOpener: openLogFile,
+ }, BundleConfig{})
+
+ require.Contains(t, bundleEntries(t, g), configs.UILogFile)
+}
+
+// A UILogOpener that refuses (as the ownership check does for a foreign file)
+// keeps the UI log out of the bundle without failing bundle generation.
+func TestBundleExcludesUILogWhenOpenerRefuses(t *testing.T) {
+ path := filepath.Join(t.TempDir(), configs.UILogFile)
+ require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
+
+ g := NewBundleGenerator(GeneratorDependencies{
+ UILogPath: path,
+ UILogOpener: func(string) (*os.File, error) {
+ return nil, fmt.Errorf("not owned by the caller")
+ },
+ }, BundleConfig{})
+
+ require.NotContains(t, bundleEntries(t, g), configs.UILogFile)
+}
diff --git a/client/internal/debug/upload.go b/client/internal/debug/upload.go
index cdf52409d..88fde6d6f 100644
--- a/client/internal/debug/upload.go
+++ b/client/internal/debug/upload.go
@@ -3,10 +3,12 @@ package debug
import (
"context"
"crypto/sha256"
+ "crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
+ neturl "net/url"
"os"
"github.com/netbirdio/netbird/upload-server/types"
@@ -14,20 +16,80 @@ import (
const maxBundleUploadSize = 50 * 1024 * 1024
-func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) {
- response, err := getUploadURL(ctx, url, managementURL)
+// requireHTTPS refuses any URL the daemon would fetch or upload to that is not
+// https. The daemon runs as root and the bundle carries its logs and state, so a
+// plaintext hop is a place to intercept the bundle or the presigned redirect.
+// The server-side gate already enforces this for the desktop path; this also
+// covers the mobile and job-runner callers that reach this package directly.
+// Skipped when the caller opted into an insecure upload (self-hosted server).
+func requireHTTPS(what, rawURL string) error {
+ parsed, err := neturl.Parse(rawURL)
+ if err != nil {
+ return fmt.Errorf("parse %s: %w", what, err)
+ }
+ if parsed.Scheme != "https" {
+ return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme)
+ }
+ return nil
+}
+
+// uploadClient returns the HTTP client for the upload requests. The default
+// client verifies TLS and refuses a redirect that would downgrade to a non-https
+// hop, so a bundle can never leave over http after an https start. The insecure
+// variant accepts http and untrusted certificates, and is only reachable for a
+// privileged caller that passed --upload-bundle-insecure (see
+// requirePrivilegeForUploadURL).
+func uploadClient(insecure bool) *http.Client {
+ if !insecure {
+ return &http.Client{CheckRedirect: rejectInsecureRedirect}
+ }
+ return &http.Client{
+ Transport: &http.Transport{
+ //nolint:gosec // opt-in, privileged, self-hosted upload servers
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},
+ },
+ }
+}
+
+// rejectInsecureRedirect refuses a redirect to a non-https target and keeps the
+// standard library's 10-hop limit that a custom CheckRedirect would otherwise
+// disable.
+func rejectInsecureRedirect(req *http.Request, via []*http.Request) error {
+ if req.URL.Scheme != "https" {
+ return fmt.Errorf("refusing redirect to non-https URL %s", req.URL.Redacted())
+ }
+ if len(via) >= 10 {
+ return fmt.Errorf("stopped after 10 redirects")
+ }
+ return nil
+}
+
+func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) {
+ if !insecure {
+ if err := requireHTTPS("upload service URL", url); err != nil {
+ return "", err
+ }
+ }
+
+ response, err := getUploadURL(ctx, url, managementURL, insecure)
if err != nil {
return "", err
}
- err = upload(ctx, filePath, response)
+ if !insecure {
+ if err := requireHTTPS("upload URL from service", response.URL); err != nil {
+ return "", err
+ }
+ }
+
+ err = upload(ctx, filePath, response, insecure)
if err != nil {
return "", err
}
return response.Key, nil
}
-func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error {
+func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error {
fileData, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file: %w", err)
@@ -52,7 +114,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
req.ContentLength = stat.Size()
req.Header.Set("Content-Type", "application/octet-stream")
- putResp, err := http.DefaultClient.Do(req)
+ putResp, err := uploadClient(insecure).Do(req)
if err != nil {
return fmt.Errorf("upload failed: %v", err)
}
@@ -65,16 +127,23 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
return nil
}
-func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) {
- id := getURLHash(managementURL)
- getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil)
+func getUploadURL(ctx context.Context, serviceURL string, managementURL string, insecure bool) (*types.GetURLResponse, error) {
+ parsed, err := neturl.Parse(serviceURL)
+ if err != nil {
+ return nil, fmt.Errorf("parse upload service URL: %w", err)
+ }
+ q := parsed.Query()
+ q.Set("id", getURLHash(managementURL))
+ parsed.RawQuery = q.Encode()
+
+ getReq, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil)
if err != nil {
return nil, fmt.Errorf("create GET request: %w", err)
}
getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue)
- resp, err := http.DefaultClient.Do(getReq)
+ resp, err := uploadClient(insecure).Do(getReq)
if err != nil {
return nil, fmt.Errorf("get presigned URL: %w", err)
}
diff --git a/client/internal/debug/upload_test.go b/client/internal/debug/upload_test.go
index f224b8d3f..f3927cb81 100644
--- a/client/internal/debug/upload_test.go
+++ b/client/internal/debug/upload_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"net"
"net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -43,7 +44,7 @@ func TestUpload(t *testing.T) {
fileContent := []byte("test file content")
err := os.WriteFile(file, fileContent, 0640)
require.NoError(t, err)
- key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file)
+ key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file, true)
require.NoError(t, err)
id := getURLHash(testURL)
require.Contains(t, key, id+"/")
@@ -79,3 +80,47 @@ func waitForServer(t *testing.T, addr string) {
}
t.Fatalf("server did not start listening on %s in time", addr)
}
+
+func TestRequireHTTPS(t *testing.T) {
+ require.NoError(t, requireHTTPS("upload URL", "https://upload.example/path"))
+ require.Error(t, requireHTTPS("upload URL", "http://upload.example/path"))
+ require.Error(t, requireHTTPS("upload URL", "ftp://upload.example/path"))
+ require.Error(t, requireHTTPS("upload URL", "://malformed"))
+}
+
+func TestRejectInsecureRedirect(t *testing.T) {
+ httpsReq, err := http.NewRequest(http.MethodGet, "https://a.example/", nil)
+ require.NoError(t, err)
+ require.NoError(t, rejectInsecureRedirect(httpsReq, nil), "https redirect target must be allowed")
+
+ httpReq, err := http.NewRequest(http.MethodGet, "http://a.example/", nil)
+ require.NoError(t, err)
+ require.Error(t, rejectInsecureRedirect(httpReq, nil), "http redirect target must be refused")
+
+ require.Error(t, rejectInsecureRedirect(httpsReq, make([]*http.Request, 10)), "the 10-redirect limit must be enforced")
+}
+
+// The secure client refuses to follow an https response that redirects to http,
+// so a bundle can't be downgraded onto plaintext mid-flight.
+func TestUploadClientRefusesHTTPSToHTTPRedirect(t *testing.T) {
+ plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(plain.Close)
+
+ secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, plain.URL, http.StatusFound)
+ }))
+ t.Cleanup(secure.Close)
+
+ client := uploadClient(false)
+ // Trust the test server's cert without disabling verification globally.
+ client.Transport = secure.Client().Transport
+
+ resp, err := client.Get(secure.URL)
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+ require.Error(t, err, "redirect from https to http must be refused")
+ require.Contains(t, err.Error(), "non-https")
+}
diff --git a/client/internal/debug/wgshow.go b/client/internal/debug/wgshow.go
index 1e8a8a6cc..ee24902e6 100644
--- a/client/internal/debug/wgshow.go
+++ b/client/internal/debug/wgshow.go
@@ -35,14 +35,14 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName))
- sb.WriteString(fmt.Sprintf(" public key: %s\n", s.PublicKey))
+ sb.WriteString(fmt.Sprintf(" public key: %s\n", g.anonymizer.AnonymizeWGKey(s.PublicKey)))
sb.WriteString(fmt.Sprintf(" listen port: %d\n", s.ListenPort))
if s.FWMark != 0 {
sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark))
}
for _, peer := range s.Peers {
- sb.WriteString(fmt.Sprintf("\npeer: %s\n", peer.PublicKey))
+ sb.WriteString(fmt.Sprintf("\npeer: %s\n", g.anonymizer.AnonymizeWGKey(peer.PublicKey)))
if peer.Endpoint.IP != nil {
if g.anonymize {
anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint)
@@ -54,7 +54,11 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
if len(peer.AllowedIPs) > 0 {
var ipStrings []string
for _, ipnet := range peer.AllowedIPs {
- ipStrings = append(ipStrings, ipnet.String())
+ ipStr := ipnet.String()
+ if g.anonymize {
+ ipStr = g.anonymizer.AnonymizeIPString(ipStr)
+ }
+ ipStrings = append(ipStrings, ipStr)
}
sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", ")))
}
diff --git a/client/internal/dns/host_darwin.go b/client/internal/dns/host_darwin.go
index 0f4eb6bf8..81029752e 100644
--- a/client/internal/dns/host_darwin.go
+++ b/client/internal/dns/host_darwin.go
@@ -267,18 +267,38 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) {
return SystemDNSSettings{}, fmt.Errorf("sending the command: %w", err)
}
- var dnsSettings SystemDNSSettings
+ dnsSettings, serverAddresses, err := parseSystemDNSSettings(b)
+ if err != nil {
+ return dnsSettings, err
+ }
+
+ s.mu.Lock()
+ s.origNameservers = serverAddresses
+ s.mu.Unlock()
+
+ return dnsSettings, nil
+}
+
+// parseSystemDNSSettings parses the output of `scutil show State:/Network/Service//DNS`.
+// Lines that don't match the expected "index : value" shape are skipped: hosts with unusual
+// network services (e.g. orphaned hardware ports) can produce entries without a value.
+func parseSystemDNSSettings(out []byte) (SystemDNSSettings, []netip.Addr, error) {
+ // port is not exposed by scutil, default to 53
+ dnsSettings := SystemDNSSettings{ServerPort: DefaultPort}
var serverAddresses []netip.Addr
inSearchDomainsArray := false
inServerAddressesArray := false
- scanner := bufio.NewScanner(bytes.NewReader(b))
+ scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
switch {
case strings.HasPrefix(line, "DomainName :"):
- domainName := strings.TrimSpace(strings.Split(line, ":")[1])
- dnsSettings.Domains = append(dnsSettings.Domains, domainName)
+ domainName := strings.TrimSpace(strings.TrimPrefix(line, "DomainName :"))
+ if domainName != "" {
+ dnsSettings.Domains = append(dnsSettings.Domains, domainName)
+ }
+ continue
case line == "SearchDomains : {":
inSearchDomainsArray = true
continue
@@ -288,36 +308,45 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) {
case line == "}":
inSearchDomainsArray = false
inServerAddressesArray = false
+ continue
+ }
+
+ if !inSearchDomainsArray && !inServerAddressesArray {
+ continue
+ }
+
+ parts := strings.SplitN(line, " : ", 2)
+ if len(parts) != 2 {
+ log.Debugf("skipping unexpected scutil DNS line %q", line)
+ continue
+ }
+ value := strings.TrimSpace(parts[1])
+ if value == "" {
+ continue
}
if inSearchDomainsArray {
- searchDomain := strings.Split(line, " : ")[1]
- dnsSettings.Domains = append(dnsSettings.Domains, searchDomain)
- } else if inServerAddressesArray {
- address := strings.Split(line, " : ")[1]
- if ip, err := netip.ParseAddr(address); err == nil && !ip.IsUnspecified() {
- ip = ip.Unmap()
- serverAddresses = append(serverAddresses, ip)
- // Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4.
- if !dnsSettings.ServerIP.IsValid() && ip.Is4() {
- dnsSettings.ServerIP = ip
- }
- }
+ dnsSettings.Domains = append(dnsSettings.Domains, value)
+ continue
+ }
+
+ ip, err := netip.ParseAddr(value)
+ if err != nil || ip.IsUnspecified() {
+ continue
+ }
+ ip = ip.Unmap()
+ serverAddresses = append(serverAddresses, ip)
+ // Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4.
+ if !dnsSettings.ServerIP.IsValid() && ip.Is4() {
+ dnsSettings.ServerIP = ip
}
}
if err := scanner.Err(); err != nil {
- return dnsSettings, err
+ return dnsSettings, serverAddresses, err
}
- // default to 53 port
- dnsSettings.ServerPort = DefaultPort
-
- s.mu.Lock()
- s.origNameservers = serverAddresses
- s.mu.Unlock()
-
- return dnsSettings, nil
+ return dnsSettings, serverAddresses, nil
}
func (s *systemConfigurator) getOriginalNameservers() []netip.Addr {
@@ -435,11 +464,15 @@ func (s *systemConfigurator) getPrimaryService() (string, string, error) {
router := ""
for scanner.Scan() {
text := scanner.Text()
+ parts := strings.SplitN(text, ":", 2)
+ if len(parts) != 2 {
+ continue
+ }
if strings.Contains(text, "PrimaryService") {
- primaryService = strings.TrimSpace(strings.Split(text, ":")[1])
+ primaryService = strings.TrimSpace(parts[1])
}
if strings.Contains(text, "Router") {
- router = strings.TrimSpace(strings.Split(text, ":")[1])
+ router = strings.TrimSpace(parts[1])
}
}
if err := scanner.Err(); err != nil && err != io.EOF {
diff --git a/client/internal/dns/host_darwin_test.go b/client/internal/dns/host_darwin_test.go
index 94d020c39..bee691c71 100644
--- a/client/internal/dns/host_darwin_test.go
+++ b/client/internal/dns/host_darwin_test.go
@@ -328,6 +328,120 @@ func removeTestDNSKey(key string) error {
return err
}
+func TestParseSystemDNSSettings(t *testing.T) {
+ tests := []struct {
+ name string
+ output string
+ expectedDomains []string
+ expectedServers []netip.Addr
+ expectedIP netip.Addr
+ }{
+ {
+ name: "well_formed",
+ output: ` {
+ DomainName : example.com
+ SearchDomains : {
+ 0 : example.com
+ 1 : corp.example.com
+ }
+ ServerAddresses : {
+ 0 : 192.168.1.1
+ 1 : fd00::53
+ }
+}
+`,
+ expectedDomains: []string{"example.com", "example.com", "corp.example.com"},
+ expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1"), netip.MustParseAddr("fd00::53")},
+ expectedIP: netip.MustParseAddr("192.168.1.1"),
+ },
+ {
+ // entries without a value after the separator used to panic with
+ // "index out of range [1] with length 1"
+ name: "malformed_array_entries_skipped",
+ output: ` {
+ SearchDomains : {
+ 0 :
+ (null)
+
+ 1 : corp.example.com
+ }
+ ServerAddresses : {
+ 0 :
+ 1 : 192.168.1.1
+ }
+}
+`,
+ expectedDomains: []string{"corp.example.com"},
+ expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
+ expectedIP: netip.MustParseAddr("192.168.1.1"),
+ },
+ {
+ name: "domain_name_without_value_skipped",
+ output: ` {
+ DomainName :
+ ServerAddresses : {
+ 0 : 192.168.1.1
+ }
+}
+`,
+ expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
+ expectedIP: netip.MustParseAddr("192.168.1.1"),
+ },
+ {
+ name: "ipv6_first_prefers_ipv4_server_ip",
+ output: ` {
+ ServerAddresses : {
+ 0 : fd00::53
+ 1 : 192.168.1.1
+ }
+}
+`,
+ expectedServers: []netip.Addr{netip.MustParseAddr("fd00::53"), netip.MustParseAddr("192.168.1.1")},
+ expectedIP: netip.MustParseAddr("192.168.1.1"),
+ },
+ {
+ name: "invalid_and_unspecified_addresses_skipped",
+ output: ` {
+ ServerAddresses : {
+ 0 : (null)
+ 1 : 0.0.0.0
+ 2 : 192.168.1.1
+ }
+}
+`,
+ expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
+ expectedIP: netip.MustParseAddr("192.168.1.1"),
+ },
+ {
+ name: "v4_mapped_address_unmapped",
+ output: ` {
+ ServerAddresses : {
+ 0 : ::ffff:192.168.1.1
+ }
+}
+`,
+ expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
+ expectedIP: netip.MustParseAddr("192.168.1.1"),
+ },
+ {
+ name: "empty_output",
+ output: "",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ settings, servers, err := parseSystemDNSSettings([]byte(tc.output))
+ require.NoError(t, err, "parsing should not fail")
+
+ assert.Equal(t, tc.expectedDomains, settings.Domains, "domains should match")
+ assert.Equal(t, tc.expectedServers, servers, "server addresses should match")
+ assert.Equal(t, tc.expectedIP, settings.ServerIP, "server IP should match")
+ assert.Equal(t, DefaultPort, settings.ServerPort, "server port should default to 53")
+ })
+ }
+}
+
func TestGetOriginalNameservers(t *testing.T) {
configurator := &systemConfigurator{
createdKeys: make(map[string]struct{}),
diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go
index 4f6ece532..53380b2aa 100644
--- a/client/internal/dns/host_windows.go
+++ b/client/internal/dns/host_windows.go
@@ -31,10 +31,30 @@ var (
dnsFlushResolverCacheFn = dnsapi.NewProc("DnsFlushResolverCache")
)
+// Registry locations of the host DNS configuration this package programs,
+// exported so a diagnostic reader reports the same locations that are written.
const (
- dnsPolicyConfigMatchPath = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig\NetBird-Match`
- gpoDnsPolicyRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
- gpoDnsPolicyConfigMatchPath = gpoDnsPolicyRoot + `\NetBird-Match`
+ // NRPTKeyPrefix starts the name of every NRPT rule key this client creates.
+ // Older versions used different layouts under the same prefix: a single
+ // unsuffixed key, then one key per domain, now one key per batch of domains.
+ NRPTKeyPrefix = "NetBird-Match"
+
+ // DNSPolicyConfigRoot holds the NRPT rules of the local policy store.
+ DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
+
+ // GPODNSPolicyConfigRoot holds the NRPT rules of the group policy store,
+ // which takes precedence over the local one when it is present.
+ GPODNSPolicyConfigRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
+
+ // InterfaceConfigPath and InterfaceConfigPathV6 hold the per-interface DNS
+ // settings, keyed by interface GUID, in separate hives per address family.
+ InterfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
+ InterfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces`
+)
+
+const (
+ dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix
+ gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix
dnsPolicyConfigVersionKey = "Version"
dnsPolicyConfigVersionValue = 2
@@ -45,8 +65,6 @@ const (
nrptMaxDomainsPerRule = 50
- interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
- interfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces`
interfaceConfigNameServerKey = "NameServer"
interfaceConfigDhcpNameSrvKey = "DhcpNameServer"
interfaceConfigSearchListKey = "SearchList"
@@ -73,7 +91,6 @@ type registryConfigurator struct {
guid string
routingAll bool
gpo bool
- nrptEntryCount int
origNameservers []netip.Addr
}
@@ -84,7 +101,7 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
}
var useGPO bool
- k, err := registry.OpenKey(registry.LOCAL_MACHINE, gpoDnsPolicyRoot, registry.QUERY_VALUE)
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
if err != nil {
log.Debugf("failed to open GPO DNS policy root: %v", err)
} else {
@@ -123,7 +140,7 @@ func (r *registryConfigurator) captureOriginalNameservers() ([]netip.Addr, error
seen := make(map[netip.Addr]struct{})
var out []netip.Addr
var merr *multierror.Error
- for _, root := range []string{interfaceConfigPath, interfaceConfigPathV6} {
+ for _, root := range []string{InterfaceConfigPath, InterfaceConfigPathV6} {
addrs, err := r.captureFromTcpipRoot(root)
if err != nil {
merr = multierror.Append(merr, fmt.Errorf("%s: %w", root, err))
@@ -306,14 +323,9 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
}
if len(matchDomains) != 0 {
- count, err := r.addDNSMatchPolicy(matchDomains, config.ServerIP)
- // Update count even on error to ensure cleanup covers partially created rules
- r.nrptEntryCount = count
- if err != nil {
+ if err := r.addDNSMatchPolicy(matchDomains, config.ServerIP); err != nil {
return fmt.Errorf("add dns match policy: %w", err)
}
- } else {
- r.nrptEntryCount = 0
}
r.updateState(stateManager)
@@ -329,9 +341,8 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
func (r *registryConfigurator) updateState(stateManager *statemanager.Manager) {
if err := stateManager.UpdateState(&ShutdownState{
- Guid: r.guid,
- GPO: r.gpo,
- NRPTEntryCount: r.nrptEntryCount,
+ Guid: r.guid,
+ GPO: r.gpo,
}); err != nil {
log.Errorf("failed to update shutdown state: %s", err)
}
@@ -346,7 +357,7 @@ func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error {
return nil
}
-func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) (int, error) {
+func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) error {
// if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored
// see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745
@@ -363,19 +374,17 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, ruleIndex)
if err := r.configureDNSPolicy(localPath, batchDomains, ip); err != nil {
- return ruleIndex, fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err)
+ return fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err)
}
- // Increment immediately so the caller's cleanup path knows about this rule
- ruleIndex++
-
if r.gpo {
if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil {
- return ruleIndex, fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex-1, err)
+ return fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex, err)
}
}
- log.Debugf("added NRPT rule %d with %d domains", ruleIndex-1, len(batchDomains))
+ log.Debugf("added NRPT rule %d with %d domains", ruleIndex, len(batchDomains))
+ ruleIndex++
}
if r.gpo {
@@ -385,7 +394,7 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
}
log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains))
- return ruleIndex, nil
+ return nil
}
func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error {
@@ -450,7 +459,7 @@ func (r *registryConfigurator) flushDNSCache() {
ret, _, err := dnsFlushResolverCacheFn.Call()
if ret == 0 {
- if err != nil && !errors.Is(err, syscall.Errno(0)) {
+ if !errors.Is(err, syscall.Errno(0)) {
log.Errorf("DnsFlushResolverCache failed: %v", err)
return
}
@@ -496,7 +505,7 @@ func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey st
}
func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) {
- regKeyPath := interfaceConfigPath + "\\" + r.guid
+ regKeyPath := InterfaceConfigPath + "\\" + r.guid
regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.SET_VALUE)
if err != nil {
return regKey, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err)
@@ -518,28 +527,28 @@ func (r *registryConfigurator) restoreHostDNS() error {
return nil
}
+// removeDNSMatchPolicies deletes every NRPT rule this client may have created,
+// from the local and the GPO policy store. The rules are found by enumerating
+// the registry, the only authoritative record of what was written. Cleanup must
+// not depend on a rule count: the in-memory one is scoped to a single
+// registryConfigurator and the persisted one is deleted on every clean
+// disconnect, and a rule left behind keeps resolving names over an interface
+// that is gone, until reboot discards the volatile key.
func (r *registryConfigurator) removeDNSMatchPolicies() error {
var merr *multierror.Error
- // Try to remove the base entries (for backward compatibility)
- if err := removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath); err != nil {
- merr = multierror.Append(merr, fmt.Errorf("remove local base entry: %w", err))
- }
-
- if err := removeRegistryKeyFromDNSPolicyConfig(gpoDnsPolicyConfigMatchPath); err != nil {
- merr = multierror.Append(merr, fmt.Errorf("remove GPO base entry: %w", err))
- }
-
- for i := 0; i < r.nrptEntryCount; i++ {
- localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)
- gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, i)
-
- if err := removeRegistryKeyFromDNSPolicyConfig(localPath); err != nil {
- merr = multierror.Append(merr, fmt.Errorf("remove local entry %d: %w", i, err))
+ for _, root := range []string{DNSPolicyConfigRoot, GPODNSPolicyConfigRoot} {
+ names, err := listNRPTRuleKeys(root)
+ if err != nil {
+ merr = multierror.Append(merr, fmt.Errorf("list rule keys under %s: %w", root, err))
+ continue
}
- if err := removeRegistryKeyFromDNSPolicyConfig(gpoPath); err != nil {
- merr = multierror.Append(merr, fmt.Errorf("remove GPO entry %d: %w", i, err))
+ for _, name := range names {
+ path := root + `\` + name
+ if err := removeRegistryKeyFromDNSPolicyConfig(path); err != nil {
+ merr = multierror.Append(merr, fmt.Errorf("remove entry %s: %w", path, err))
+ }
}
}
@@ -554,6 +563,39 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error {
return r.restoreHostDNS()
}
+// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store
+// root. An absent root holds nothing to clean up, which is the normal state of
+// the GPO store on a machine without DNS Client policy.
+func listNRPTRuleKeys(root string) ([]string, error) {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS)
+ switch {
+ case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
+ // the GPO store is absent on a machine without DNS client policy
+ log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", root)
+ return nil, nil
+ case err != nil:
+ // any other failure has to reach the caller: reporting no rules would
+ // report a successful cleanup while leaving the rules in place
+ return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err)
+ }
+ defer closer(k)
+
+ names, err := k.ReadSubKeyNames(-1)
+ if err != nil {
+ return nil, fmt.Errorf("read subkey names: %w", err)
+ }
+
+ var ruleKeys []string
+ for _, name := range names {
+ // registry key names are case insensitive
+ if strings.HasPrefix(strings.ToLower(name), strings.ToLower(NRPTKeyPrefix)) {
+ ruleKeys = append(ruleKeys, name)
+ }
+ }
+
+ return ruleKeys, nil
+}
+
func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE)
if err != nil {
@@ -585,7 +627,7 @@ func refreshGroupPolicy() error {
)
if ret == 0 {
- if err != nil && !errors.Is(err, syscall.Errno(0)) {
+ if !errors.Is(err, syscall.Errno(0)) {
return fmt.Errorf("RefreshPolicyEx failed: %w", err)
}
return fmt.Errorf("RefreshPolicyEx failed")
diff --git a/client/internal/dns/host_windows_test.go b/client/internal/dns/host_windows_test.go
index 3cd2b1bd5..861613c95 100644
--- a/client/internal/dns/host_windows_test.go
+++ b/client/internal/dns/host_windows_test.go
@@ -25,7 +25,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
// Create a test interface registry key so updateSearchDomains doesn't fail
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
- interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID
+ interfacePath := InterfaceConfigPath + `\` + testGUID
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
require.NoError(t, err, "Should create test interface registry key")
testKey.Close()
@@ -56,7 +56,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
require.NoError(t, err)
// Verify 3 NRPT rules exist
- assert.Equal(t, 3, cfg.nrptEntryCount, "Should create 3 NRPT rules for 125 domains")
+ assert.Equal(t, 3, countNRPTRuleKeys(t), "Should create 3 NRPT rules for 125 domains")
for i := 0; i < 3; i++ {
exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i))
require.NoError(t, err)
@@ -81,7 +81,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
require.NoError(t, err)
// Verify first 2 NRPT rules exist
- assert.Equal(t, 2, cfg.nrptEntryCount, "Should create 2 NRPT rules for 75 domains")
+ assert.Equal(t, 2, countNRPTRuleKeys(t), "Should create 2 NRPT rules for 75 domains")
for i := 0; i < 2; i++ {
exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i))
require.NoError(t, err)
@@ -106,9 +106,65 @@ func registryKeyExists(path string) (bool, error) {
return true, nil
}
+// TestNRPTCleanupWithoutRuleCount verifies that rules written by a previous run
+// are removed by a configurator that has no record of how many there are: an
+// unclean exit loses the in-memory count and a clean disconnect deletes the
+// persisted one, so cleanup cannot depend on either.
+func TestNRPTCleanupWithoutRuleCount(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping registry integration test in short mode")
+ }
+
+ defer cleanupRegistryKeys(t)
+ cleanupRegistryKeys(t)
+
+ testIP := netip.MustParseAddr("100.64.0.1")
+
+ // 75 domains produce two indexed rules, as the current layout does
+ domains := make([]string, 75)
+ for i := range domains {
+ domains[i] = fmt.Sprintf(".domain%d.com", i+1)
+ }
+
+ previousRun := ®istryConfigurator{}
+ require.NoError(t, previousRun.addDNSMatchPolicy(domains, testIP))
+
+ // the unsuffixed key an older version would have written
+ require.NoError(t, previousRun.configureDNSPolicy(dnsPolicyConfigMatchPath, []string{".legacy.example.com"}, testIP))
+
+ // a policy owned by someone else, which cleanup must not touch
+ foreignPath := DNSPolicyConfigRoot + `\DnsPolicyConfigTestForeign`
+ foreignKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, foreignPath, registry.SET_VALUE)
+ require.NoError(t, err, "Should create foreign policy key")
+ foreignKey.Close()
+ defer func() {
+ _ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignPath)
+ }()
+
+ require.Equal(t, 3, countNRPTRuleKeys(t), "Should have two indexed rules and the legacy one")
+
+ // a configurator that never applied a DNS config, as one built after a
+ // restart or from a shutdown state without a count is
+ freshRun := ®istryConfigurator{}
+ require.NoError(t, freshRun.removeDNSMatchPolicies())
+
+ assert.Equal(t, 0, countNRPTRuleKeys(t), "Should remove every rule left by the previous run")
+
+ exists, err := registryKeyExists(foreignPath)
+ require.NoError(t, err)
+ assert.True(t, exists, "Should not remove a policy that is not ours")
+}
+
+func countNRPTRuleKeys(t *testing.T) int {
+ t.Helper()
+
+ names, err := listNRPTRuleKeys(DNSPolicyConfigRoot)
+ require.NoError(t, err, "Should list NRPT rule keys")
+ return len(names)
+}
+
func cleanupRegistryKeys(*testing.T) {
- // Clean up more entries to account for batching tests with many domains
- cfg := ®istryConfigurator{nrptEntryCount: 20}
+ cfg := ®istryConfigurator{}
_ = cfg.removeDNSMatchPolicies()
}
@@ -125,7 +181,7 @@ func TestNRPTDomainBatching(t *testing.T) {
// Create a test interface registry key so updateSearchDomains doesn't fail
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
- interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID
+ interfacePath := InterfaceConfigPath + `\` + testGUID
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
require.NoError(t, err, "Should create test interface registry key")
testKey.Close()
@@ -193,7 +249,7 @@ func TestNRPTDomainBatching(t *testing.T) {
require.NoError(t, err)
// Verify that exactly expectedRuleCount rules were created
- assert.Equal(t, tc.expectedRuleCount, cfg.nrptEntryCount,
+ assert.Equal(t, tc.expectedRuleCount, countNRPTRuleKeys(t),
"Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount)
// Verify all expected rules exist
diff --git a/client/internal/dns/interface_index.go b/client/internal/dns/interface_index.go
new file mode 100644
index 000000000..9e7dca080
--- /dev/null
+++ b/client/internal/dns/interface_index.go
@@ -0,0 +1,15 @@
+package dns
+
+import (
+ "fmt"
+ "net"
+)
+
+func getInterfaceIndex(interfaceName string) (int, error) {
+ iface, err := net.InterfaceByName(interfaceName)
+ if err != nil {
+ return 0, fmt.Errorf("lookup interface %q: %w", interfaceName, err)
+ }
+
+ return iface.Index, nil
+}
diff --git a/client/internal/dns/interface_index_test.go b/client/internal/dns/interface_index_test.go
new file mode 100644
index 000000000..9b146398a
--- /dev/null
+++ b/client/internal/dns/interface_index_test.go
@@ -0,0 +1,35 @@
+package dns
+
+import (
+ "net"
+ "testing"
+)
+
+func TestGetInterfaceIndexExisting(t *testing.T) {
+ interfaces, err := net.Interfaces()
+ if err != nil {
+ t.Fatalf("list network interfaces: %v", err)
+ }
+ if len(interfaces) == 0 {
+ t.Fatal("expected at least one network interface")
+ }
+
+ iface := interfaces[0]
+ index, err := getInterfaceIndex(iface.Name)
+ if err != nil {
+ t.Fatalf("look up existing interface %q: %v", iface.Name, err)
+ }
+ if index != iface.Index {
+ t.Fatalf("expected interface index %d, got %d", iface.Index, index)
+ }
+}
+
+func TestGetInterfaceIndexMissing(t *testing.T) {
+ index, err := getInterfaceIndex("netbird-interface-that-does-not-exist")
+ if index != 0 {
+ t.Fatalf("expected missing interface index to be 0, got %d", index)
+ }
+ if err == nil {
+ t.Fatal("expected missing interface lookup to return an error")
+ }
+}
diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go
index d0268186c..fef35fd41 100644
--- a/client/internal/dns/local/local.go
+++ b/client/internal/dns/local/local.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/netip"
+ "os"
"slices"
"strings"
"sync"
@@ -36,7 +37,43 @@ type resolver interface {
// record is left alone (it points at something outside our mesh, e.g.
// a non-peer upstream).
type PeerConnectivity interface {
- IsConnectedByIP(ip string) (known, connected bool)
+ IsConnectedByIP(ip netip.Addr) (known, connected bool)
+}
+
+// PeerActivator wakes lazy-connection peers on demand. The local resolver calls
+// it with the tunnel IPs an answer points at, so a peer that is idle (lazily
+// disconnected) starts connecting at DNS-resolution time rather than racing the
+// client's first request packet. nil disables warm-up.
+type PeerActivator interface {
+ // ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks
+ // until one is connected or ctx (a short per-query budget) expires. It is a
+ // fast no-op for unknown or already-connected addresses.
+ ActivatePeersByIP(ctx context.Context, addrs []netip.Addr)
+}
+
+const (
+ defaultLazyWarmupTimeout = 2 * time.Second
+ envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT"
+)
+
+// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a
+// lazy-connection peer a DNS answer points at. Tunable via
+// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time.
+func lazyWarmupTimeoutFromEnv() time.Duration {
+ v := os.Getenv(envLazyWarmupTimeout)
+ if v == "" {
+ return defaultLazyWarmupTimeout
+ }
+ d, err := time.ParseDuration(v)
+ if err != nil {
+ log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err)
+ return defaultLazyWarmupTimeout
+ }
+ if d <= 0 {
+ log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout)
+ return defaultLazyWarmupTimeout
+ }
+ return d
}
type Resolver struct {
@@ -51,6 +88,12 @@ type Resolver struct {
// filter and preserves the legacy "return whatever is registered"
// behaviour for callers that never wire a status source.
peerConn PeerConnectivity
+ // peerActivator, when non-nil, is called at resolution time to warm the
+ // lazy connection to the peer(s) an answer points at. nil disables warm-up.
+ peerActivator PeerActivator
+ // warmupTimeout is the per-query budget for the lazy-connection warm-up
+ // wait, resolved from the environment once at construction time.
+ warmupTimeout time.Duration
ctx context.Context
cancel context.CancelFunc
@@ -59,11 +102,12 @@ type Resolver struct {
func NewResolver() *Resolver {
ctx, cancel := context.WithCancel(context.Background())
return &Resolver{
- records: make(map[dns.Question][]dns.RR),
- domains: make(map[domain.Domain]struct{}),
- zones: make(map[domain.Domain]bool),
- ctx: ctx,
- cancel: cancel,
+ records: make(map[dns.Question][]dns.RR),
+ domains: make(map[domain.Domain]struct{}),
+ zones: make(map[domain.Domain]bool),
+ warmupTimeout: lazyWarmupTimeoutFromEnv(),
+ ctx: ctx,
+ cancel: cancel,
}
}
@@ -76,6 +120,14 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
d.peerConn = p
}
+// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to
+// disable. Safe to call multiple times; the latest value wins.
+func (d *Resolver) SetPeerActivator(a PeerActivator) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.peerActivator = a
+}
+
func (d *Resolver) MatchSubdomains() bool {
return true
}
@@ -122,6 +174,9 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
replyMessage.RecursionAvailable = true
result := d.lookupRecords(logger, question)
+ // Warm before filtering: activation flips a lazily-idle target to connected,
+ // which then lets it survive the disconnected-peer filter below.
+ d.warmLazyPeers(question, result.records)
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
replyMessage.Authoritative = !result.hasExternalData
replyMessage.Answer = result.records
@@ -495,8 +550,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
kept := make([]dns.RR, 0, len(records))
var dropped int
for _, rr := range records {
- ip := extractRecordIP(rr)
- if ip == "" {
+ ip, ok := extractRecordAddr(rr)
+ if !ok {
kept = append(kept, rr)
continue
}
@@ -518,22 +573,57 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
return kept
}
-// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
-// an A or AAAA record, or "" for any other record type.
-func extractRecordIP(rr dns.RR) string {
+// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved
+// answer points at and waits briefly for one to connect, so the caller's first
+// request doesn't race the connection establishment. Warm-up is scoped to
+// match-only (non-authoritative) zones — the synthesized private-service zones
+// and user-created zones whose records point at specific peers. The account's
+// peer zone is authoritative, so plain peer-name lookups never trigger warm-up;
+// otherwise resolving any peer's name would wake its idle connection, defeating
+// laziness mesh-wide. No-op when no activator is wired (lazy connections
+// disabled) or the answer carries no peer IPs.
+func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) {
+ if len(records) < 2 {
+ return
+ }
+ d.mu.RLock()
+ activator := d.peerActivator
+ var nonAuth, found bool
+ if activator != nil {
+ nonAuth, found = d.findZone(question.Name)
+ }
+ d.mu.RUnlock()
+ if activator == nil || !found || !nonAuth {
+ return
+ }
+
+ var addrs []netip.Addr
+ for _, rr := range records {
+ if addr, ok := extractRecordAddr(rr); ok {
+ addrs = append(addrs, addr)
+ }
+ }
+ if len(addrs) == 0 {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout)
+ defer cancel()
+ activator.ActivatePeersByIP(ctx, addrs)
+}
+
+// extractRecordAddr returns the IP address carried by an A or AAAA record.
+// ok is false for any other record type or a record with no address.
+func extractRecordAddr(rr dns.RR) (netip.Addr, bool) {
switch r := rr.(type) {
case *dns.A:
- if r.A == nil {
- return ""
- }
- return r.A.String()
+ addr, ok := netip.AddrFromSlice(r.A)
+ return addr.Unmap(), ok
case *dns.AAAA:
- if r.AAAA == nil {
- return ""
- }
- return r.AAAA.String()
+ addr, ok := netip.AddrFromSlice(r.AAAA)
+ return addr.Unmap(), ok
}
- return ""
+ return netip.Addr{}, false
}
// Update replaces all zones and their records
diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go
index 9b7dac231..89e896c0a 100644
--- a/client/internal/dns/local/local_test.go
+++ b/client/internal/dns/local/local_test.go
@@ -37,8 +37,8 @@ type mockPeerConnectivity struct {
byIP map[string]struct{ known, connected bool }
}
-func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
- v, ok := m.byIP[ip]
+func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
+ v, ok := m.byIP[ip.String()]
if !ok {
return false, false
}
diff --git a/client/internal/dns/local/warmup_test.go b/client/internal/dns/local/warmup_test.go
new file mode 100644
index 000000000..0e77aa963
--- /dev/null
+++ b/client/internal/dns/local/warmup_test.go
@@ -0,0 +1,204 @@
+package local
+
+import (
+ "context"
+ "net"
+ "net/netip"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/dns/test"
+ nbdns "github.com/netbirdio/netbird/dns"
+)
+
+// recordingActivator records the addresses it was asked to warm and returns
+// immediately, so ServeDNS is not blocked by the test.
+type recordingActivator struct {
+ mu sync.Mutex
+ called bool
+ addrs []netip.Addr
+}
+
+func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.called = true
+ r.addrs = append(r.addrs, addrs...)
+}
+
+func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg {
+ t.Helper()
+ var resp *dns.Msg
+ w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}
+ resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA))
+ return resp
+}
+
+// serviceZone registers rec in a match-only (non-authoritative) zone, the shape
+// the synthesized private-service zones arrive in.
+func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) {
+ t.Helper()
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: zone,
+ Records: records,
+ NonAuthoritative: true,
+ }})
+}
+
+func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) {
+ // Warm-up fires only for multi-record answers (the HA / round-robin shape of
+ // the synthesized private-service zones), so register two peer targets.
+ const name = "svc.proxy.netbird.cloud."
+ recs := []nbdns.SimpleRecord{
+ {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"},
+ {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"},
+ }
+ resolver := NewResolver()
+ serviceZone(t, resolver, "proxy.netbird.cloud", recs...)
+
+ act := &recordingActivator{}
+ resolver.SetPeerActivator(act)
+
+ resp := serveA(t, resolver, name)
+ require.NotNil(t, resp, "resolver must answer")
+ require.NotEmpty(t, resp.Answer, "answer must carry the A records")
+
+ act.mu.Lock()
+ defer act.mu.Unlock()
+ assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer")
+ assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP")
+ assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP")
+}
+
+func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) {
+ // A single-record answer does not trigger warm-up; the resolver only warms
+ // multi-record answers.
+ rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
+ resolver := NewResolver()
+ serviceZone(t, resolver, "proxy.netbird.cloud", rec)
+
+ act := &recordingActivator{}
+ resolver.SetPeerActivator(act)
+
+ resp := serveA(t, resolver, rec.Name)
+ require.NotNil(t, resp, "resolver must answer")
+ require.NotEmpty(t, resp.Answer, "answer must carry the A record")
+
+ act.mu.Lock()
+ defer act.mu.Unlock()
+ assert.False(t, act.called, "activator must not be invoked for a single-record answer")
+}
+
+func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) {
+ // With no activator wired the resolver behaves exactly as before.
+ rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
+ resolver := NewResolver()
+ serviceZone(t, resolver, "proxy.netbird.cloud", rec)
+
+ resp := serveA(t, resolver, rec.Name)
+ require.NotNil(t, resp, "resolver must still answer without an activator")
+ require.NotEmpty(t, resp.Answer, "answer must carry the A record")
+}
+
+func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) {
+ // A query that resolves to nothing must not invoke the activator (no IPs).
+ resolver := NewResolver()
+ serviceZone(t, resolver, "proxy.netbird.cloud",
+ nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"})
+
+ act := &recordingActivator{}
+ resolver.SetPeerActivator(act)
+
+ serveA(t, resolver, "absent.proxy.netbird.cloud.")
+
+ act.mu.Lock()
+ defer act.mu.Unlock()
+ assert.False(t, act.called, "activator must not be invoked when there is no answer")
+}
+
+func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) {
+ // The account's peer zone is authoritative; resolving a peer's name there
+ // must not wake its lazy connection — warm-up is scoped to match-only
+ // (non-authoritative) zones such as the synthesized private-service zones.
+ // Use a multi-record answer so the authoritative-zone scoping is the only
+ // reason warm-up is skipped, not the single-record guard.
+ const name = "peer.netbird.cloud."
+ recs := []nbdns.SimpleRecord{
+ {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"},
+ {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"},
+ }
+ resolver := NewResolver()
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: "netbird.cloud",
+ Records: recs,
+ }})
+
+ act := &recordingActivator{}
+ resolver.SetPeerActivator(act)
+
+ resp := serveA(t, resolver, name)
+ require.NotNil(t, resp, "resolver must answer")
+ require.NotEmpty(t, resp.Answer, "answer must carry the A records")
+
+ act.mu.Lock()
+ defer act.mu.Unlock()
+ assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers")
+}
+
+func TestLazyWarmupTimeoutFromEnv(t *testing.T) {
+ tests := []struct {
+ name string
+ value string
+ envSet bool
+ want time.Duration
+ }{
+ {name: "unset uses default", want: defaultLazyWarmupTimeout},
+ {name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second},
+ {name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout},
+ {name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout},
+ {name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.envSet {
+ t.Setenv(envLazyWarmupTimeout, tt.value)
+ }
+ assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv())
+ assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once")
+ })
+ }
+}
+
+func TestExtractRecordAddr(t *testing.T) {
+ t.Run("A record yields unmapped v4", func(t *testing.T) {
+ // net.ParseIP returns the 16-byte v4-in-v6 form, the same shape
+ // miekg/dns stores after parsing an A record; the extracted address
+ // must compare equal to a plain v4 netip.Addr.
+ addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")})
+ require.True(t, ok)
+ assert.True(t, addr.Is4())
+ assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr)
+ })
+
+ t.Run("AAAA record yields v6", func(t *testing.T) {
+ addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")})
+ require.True(t, ok)
+ assert.Equal(t, netip.MustParseAddr("fd00::1"), addr)
+ })
+
+ t.Run("A record without address", func(t *testing.T) {
+ _, ok := extractRecordAddr(&dns.A{})
+ assert.False(t, ok)
+ })
+
+ t.Run("non-address record", func(t *testing.T) {
+ _, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."})
+ assert.False(t, ok)
+ })
+}
diff --git a/client/internal/dns/mgmt/mgmt.go b/client/internal/dns/mgmt/mgmt.go
index 988e427fb..ddc8cf585 100644
--- a/client/internal/dns/mgmt/mgmt.go
+++ b/client/internal/dns/mgmt/mgmt.go
@@ -51,13 +51,20 @@ type cachedRecord struct {
}
// Resolver caches critical NetBird infrastructure domains.
-// records, refreshing, mgmtDomain and serverDomains are all guarded by mutex.
+// records, refreshing, failedResolves, mgmtDomain and serverDomains are all
+// guarded by mutex.
type Resolver struct {
records map[dns.Question]*cachedRecord
mgmtDomain *domain.Domain
serverDomains *dnsconfig.ServerDomains
mutex sync.RWMutex
+ // failedResolves records the last failed initial resolve per domain so a
+ // domain that never resolves isn't retried on every server-domains update
+ // until refreshBackoff elapses. Entries are cleared on success and pruned
+ // to the current server-domains set.
+ failedResolves map[domain.Domain]time.Time
+
chain ChainResolver
chainMaxPriority int
refreshGroup singleflight.Group
@@ -76,9 +83,10 @@ type Resolver struct {
// NewResolver creates a new management domains cache resolver.
func NewResolver() *Resolver {
return &Resolver{
- records: make(map[dns.Question]*cachedRecord),
- refreshing: make(map[dns.Question]*atomic.Bool),
- cacheTTL: resolveCacheTTL(),
+ records: make(map[dns.Question]*cachedRecord),
+ refreshing: make(map[dns.Question]*atomic.Bool),
+ failedResolves: make(map[domain.Domain]time.Time),
+ cacheTTL: resolveCacheTTL(),
}
}
@@ -173,7 +181,9 @@ func (m *Resolver) continueToNext(w dns.ResponseWriter, r *dns.Msg) {
// AddDomain resolves a domain and stores its A/AAAA records in the cache.
// A family that resolves NODATA (nil err, zero records) evicts any stale
-// entry for that qtype.
+// entry for that qtype. When one family hard-errors while the other succeeds,
+// the resolved family is still cached but AddDomain returns an error so the
+// caller retries the incomplete resolve rather than treating it as complete.
func (m *Resolver) AddDomain(ctx context.Context, d domain.Domain) error {
dnsName := strings.ToLower(dns.Fqdn(d.PunycodeString()))
@@ -203,6 +213,10 @@ func (m *Resolver) AddDomain(ctx context.Context, d domain.Domain) error {
log.Debugf("added/updated domain=%s with %d A records and %d AAAA records",
d.SafeString(), len(aRecords), len(aaaaRecords))
+ if errA != nil || errAAAA != nil {
+ return fmt.Errorf("resolve %s: incomplete, a family failed: %w", d.SafeString(), errors.Join(errA, errAAAA))
+ }
+
return nil
}
@@ -462,6 +476,7 @@ func (m *Resolver) RemoveDomain(d domain.Domain) error {
delete(m.records, qAAAA)
delete(m.refreshing, qA)
delete(m.refreshing, qAAAA)
+ delete(m.failedResolves, d)
log.Debugf("removed domain=%s from cache", d.SafeString())
return nil
@@ -505,6 +520,7 @@ func (m *Resolver) UpdateFromServerDomains(ctx context.Context, serverDomains dn
allDomains := m.extractDomainsFromServerDomains(updatedServerDomains)
currentDomains := m.GetCachedDomains()
removedDomains = m.removeStaleDomains(currentDomains, allDomains)
+ m.pruneFailedResolves(allDomains)
}
m.addNewDomains(ctx, newDomains)
@@ -577,13 +593,85 @@ func (m *Resolver) isManagementDomain(domain domain.Domain) bool {
return m.mgmtDomain != nil && domain == *m.mgmtDomain
}
-// addNewDomains resolves and caches all domains from the update
+// addNewDomains resolves and caches domains that are not yet in the cache,
+// running the lookups concurrently. Domains already cached are skipped and left
+// to the stale-while-revalidate refresh path, so a sync never re-resolves them
+// synchronously: once NetBird owns the OS resolver the resolve runs through the
+// handler chain and would otherwise dial the managed upstreams under the engine
+// sync lock on every update.
func (m *Resolver) addNewDomains(ctx context.Context, newDomains domain.List) {
+ var wg sync.WaitGroup
+ seen := make(map[domain.Domain]struct{}, len(newDomains))
for _, newDomain := range newDomains {
- if err := m.AddDomain(ctx, newDomain); err != nil {
- log.Warnf("failed to add/update domain=%s: %v", newDomain.SafeString(), err)
- } else {
- log.Debugf("added/updated management cache domain=%s", newDomain.SafeString())
+ if _, dup := seen[newDomain]; dup {
+ continue
+ }
+ seen[newDomain] = struct{}{}
+
+ if !m.needsResolve(newDomain) {
+ continue
+ }
+
+ wg.Add(1)
+ go func(d domain.Domain) {
+ defer wg.Done()
+ if err := m.AddDomain(ctx, d); err != nil {
+ m.markResolveFailed(d)
+ log.Warnf("failed to add/update domain=%s: %v", d.SafeString(), err)
+ return
+ }
+ m.clearResolveFailed(d)
+ log.Debugf("added/updated management cache domain=%s", d.SafeString())
+ }(newDomain)
+ }
+ wg.Wait()
+}
+
+// needsResolve reports whether d should be resolved now. A recent failed or
+// incomplete resolve gates retries on the backoff even when one family is
+// already cached, so a transiently-failed family is retried instead of being
+// treated as fully resolved. Otherwise a domain with any cached record is left
+// to the stale-while-revalidate refresh path.
+func (m *Resolver) needsResolve(d domain.Domain) bool {
+ dnsName := strings.ToLower(dns.Fqdn(d.PunycodeString()))
+
+ m.mutex.RLock()
+ defer m.mutex.RUnlock()
+
+ if failedAt, ok := m.failedResolves[d]; ok {
+ return time.Since(failedAt) >= refreshBackoff
+ }
+
+ for _, qtype := range []uint16{dns.TypeA, dns.TypeAAAA} {
+ q := dns.Question{Name: dnsName, Qtype: qtype, Qclass: dns.ClassINET}
+ if _, ok := m.records[q]; ok {
+ return false
+ }
+ }
+ return true
+}
+
+func (m *Resolver) markResolveFailed(d domain.Domain) {
+ m.mutex.Lock()
+ m.failedResolves[d] = time.Now()
+ m.mutex.Unlock()
+}
+
+func (m *Resolver) clearResolveFailed(d domain.Domain) {
+ m.mutex.Lock()
+ delete(m.failedResolves, d)
+ m.mutex.Unlock()
+}
+
+// pruneFailedResolves drops failure markers for domains no longer present in
+// the server-domains set, keeping the map bounded to the current set (a
+// failed-only domain has no cached record, so RemoveDomain never sees it).
+func (m *Resolver) pruneFailedResolves(domains domain.List) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ for d := range m.failedResolves {
+ if !slices.Contains(domains, d) {
+ delete(m.failedResolves, d)
}
}
}
diff --git a/client/internal/dns/mgmt/mgmt_refresh_test.go b/client/internal/dns/mgmt/mgmt_refresh_test.go
index 9faa5a0b8..64a5342e2 100644
--- a/client/internal/dns/mgmt/mgmt_refresh_test.go
+++ b/client/internal/dns/mgmt/mgmt_refresh_test.go
@@ -21,6 +21,7 @@ type fakeChain struct {
mu sync.Mutex
calls map[string]int
answers map[string][]dns.RR
+ qErr map[string]error
err error
hasRoot bool
onLookup func()
@@ -30,6 +31,7 @@ func newFakeChain() *fakeChain {
return &fakeChain{
calls: map[string]int{},
answers: map[string][]dns.RR{},
+ qErr: map[string]error{},
hasRoot: true,
}
}
@@ -47,6 +49,9 @@ func (f *fakeChain) ResolveInternal(ctx context.Context, msg *dns.Msg, maxPriori
f.calls[key]++
answers := f.answers[key]
err := f.err
+ if err == nil {
+ err = f.qErr[key]
+ }
onLookup := f.onLookup
f.mu.Unlock()
@@ -75,6 +80,12 @@ func (f *fakeChain) setAnswer(name string, qtype uint16, ip string) {
}
}
+func (f *fakeChain) setErr(name string, qtype uint16, err error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.qErr[name+"|"+dns.TypeToString[qtype]] = err
+}
+
func (f *fakeChain) callCount(name string, qtype uint16) int {
f.mu.Lock()
defer f.mu.Unlock()
diff --git a/client/internal/dns/mgmt/mgmt_resolve_test.go b/client/internal/dns/mgmt/mgmt_resolve_test.go
new file mode 100644
index 000000000..5cfbac8f0
--- /dev/null
+++ b/client/internal/dns/mgmt/mgmt_resolve_test.go
@@ -0,0 +1,183 @@
+package mgmt
+
+import (
+ "context"
+ "errors"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
+ "github.com/netbirdio/netbird/shared/management/domain"
+)
+
+// A domain already in the cache must not be re-resolved on a subsequent server
+// domains update; it is left to the stale-while-revalidate refresh path.
+func TestResolver_UpdateFromServerDomains_SkipsCached(t *testing.T) {
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.setAnswer("signal.example.com.", dns.TypeA, "10.0.0.2")
+ r.SetChainResolver(chain, 50)
+
+ sd := dnsconfig.ServerDomains{Signal: domain.Domain("signal.example.com")}
+
+ _, err := r.UpdateFromServerDomains(context.Background(), sd)
+ require.NoError(t, err)
+ require.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA),
+ "first update must resolve the domain")
+
+ _, err = r.UpdateFromServerDomains(context.Background(), sd)
+ require.NoError(t, err)
+ assert.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA),
+ "cached domain must not be re-resolved on a subsequent update")
+}
+
+// New domains in a single update must resolve concurrently rather than serially.
+func TestResolver_AddNewDomains_ResolvesConcurrently(t *testing.T) {
+ r := NewResolver()
+ chain := newFakeChain()
+
+ var inflight, maxInflight atomic.Int32
+ chain.onLookup = func() {
+ n := inflight.Add(1)
+ for {
+ old := maxInflight.Load()
+ if n <= old || maxInflight.CompareAndSwap(old, n) {
+ break
+ }
+ }
+ time.Sleep(50 * time.Millisecond)
+ inflight.Add(-1)
+ }
+
+ relays := []domain.Domain{"a.example.com", "b.example.com", "c.example.com", "d.example.com"}
+ for _, d := range relays {
+ chain.setAnswer(dns.Fqdn(string(d)), dns.TypeA, "10.0.0.2")
+ }
+ r.SetChainResolver(chain, 50)
+
+ start := time.Now()
+ _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: relays})
+ require.NoError(t, err)
+ elapsed := time.Since(start)
+
+ assert.GreaterOrEqual(t, int(maxInflight.Load()), 2, "domains must resolve concurrently")
+ // Serial resolution of 4 domains would take at least 4*50ms; concurrent is far less.
+ assert.Less(t, elapsed, 300*time.Millisecond, "resolution should not be serial")
+}
+
+// A domain that fails to resolve must not be retried on every update; the
+// failure backoff suppresses re-resolution until it expires.
+func TestResolver_UpdateFromServerDomains_BacksOffFailures(t *testing.T) {
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.err = errors.New("resolve boom")
+ r.SetChainResolver(chain, 50)
+
+ sd := dnsconfig.ServerDomains{Signal: domain.Domain("signal.example.com")}
+
+ _, err := r.UpdateFromServerDomains(context.Background(), sd)
+ require.NoError(t, err)
+ require.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA),
+ "first update must attempt the resolve")
+
+ _, err = r.UpdateFromServerDomains(context.Background(), sd)
+ require.NoError(t, err)
+ assert.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA),
+ "failed resolve must back off and not retry on the next update")
+}
+
+// A domain listed under more than one server-domain type (e.g. STUN and TURN on
+// the same host) must be resolved once per update, not once per occurrence.
+func TestResolver_AddNewDomains_DedupesDuplicateDomains(t *testing.T) {
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.setAnswer("dup.example.com.", dns.TypeA, "10.0.0.9")
+ r.SetChainResolver(chain, 50)
+
+ sd := dnsconfig.ServerDomains{
+ Stuns: []domain.Domain{"dup.example.com"},
+ Turns: []domain.Domain{"dup.example.com"},
+ }
+
+ _, err := r.UpdateFromServerDomains(context.Background(), sd)
+ require.NoError(t, err)
+ assert.Equal(t, 1, chain.callCount("dup.example.com.", dns.TypeA),
+ "a domain appearing under multiple server-domain types must resolve once")
+}
+
+// A failure marker must be dropped once its domain leaves the server-domains set
+// so the map stays bounded to the current set.
+func TestResolver_UpdateFromServerDomains_PrunesFailedResolves(t *testing.T) {
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.err = errors.New("resolve boom")
+ r.SetChainResolver(chain, 50)
+
+ _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Signal: domain.Domain("gone.example.com")})
+ require.NoError(t, err)
+ r.mutex.RLock()
+ _, marked := r.failedResolves[domain.Domain("gone.example.com")]
+ r.mutex.RUnlock()
+ require.True(t, marked, "failed resolve must be recorded")
+
+ _, err = r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Signal: domain.Domain("other.example.com")})
+ require.NoError(t, err)
+ r.mutex.RLock()
+ _, stillMarked := r.failedResolves[domain.Domain("gone.example.com")]
+ r.mutex.RUnlock()
+ assert.False(t, stillMarked, "failure marker for a domain no longer in the set must be pruned")
+}
+
+// When one family hard-errors while the other resolves, the domain is cached
+// for the working family but recorded as incomplete so the failed family is
+// retried under backoff instead of being treated as fully resolved forever.
+func TestResolver_AddNewDomains_RetriesPartialFamilyFailure(t *testing.T) {
+ d := domain.Domain("relay.example.com")
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.setAnswer("relay.example.com.", dns.TypeA, "10.0.0.2")
+ chain.setErr("relay.example.com.", dns.TypeAAAA, errors.New("servfail"))
+ r.SetChainResolver(chain, 50)
+
+ _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: []domain.Domain{d}})
+ require.NoError(t, err)
+
+ r.mutex.RLock()
+ _, aCached := r.records[dns.Question{Name: "relay.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}]
+ _, marked := r.failedResolves[d]
+ r.mutex.RUnlock()
+ require.True(t, aCached, "the working family must still be cached")
+ require.True(t, marked, "a partial failure must be recorded so the failed family is retried")
+
+ assert.False(t, r.needsResolve(d), "within the backoff window the domain is not retried")
+
+ r.mutex.Lock()
+ r.failedResolves[d] = time.Now().Add(-2 * refreshBackoff)
+ r.mutex.Unlock()
+ assert.True(t, r.needsResolve(d), "after the backoff elapses the domain is retried to pick up the missing family")
+}
+
+// A family that returns NODATA (legitimately absent, e.g. an IPv4-only host) is
+// not a failure: the domain must not be marked for retry, otherwise it would be
+// re-resolved on every sync.
+func TestResolver_AddNewDomains_NodataIsNotFailure(t *testing.T) {
+ d := domain.Domain("v4only.example.com")
+ r := NewResolver()
+ chain := newFakeChain()
+ chain.setAnswer("v4only.example.com.", dns.TypeA, "10.0.0.2")
+ r.SetChainResolver(chain, 50)
+
+ _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: []domain.Domain{d}})
+ require.NoError(t, err)
+
+ r.mutex.RLock()
+ _, marked := r.failedResolves[d]
+ r.mutex.RUnlock()
+ assert.False(t, marked, "a NODATA family must not be recorded as a failure")
+ assert.False(t, r.needsResolve(d), "an IPv4-only host must not be re-resolved on later syncs")
+}
diff --git a/client/internal/dns/mock_server.go b/client/internal/dns/mock_server.go
index 31fedd9e5..b19862c2f 100644
--- a/client/internal/dns/mock_server.go
+++ b/client/internal/dns/mock_server.go
@@ -8,6 +8,7 @@ import (
"github.com/miekg/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
+ "github.com/netbirdio/netbird/client/internal/dns/local"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -92,6 +93,11 @@ func (m *MockServer) SetFirewall(Firewall) {
// Mock implementation - no-op
}
+// SetPeerActivator mock implementation of SetPeerActivator from Server interface
+func (m *MockServer) SetPeerActivator(local.PeerActivator) {
+ // Mock implementation - no-op
+}
+
// BeginBatch mock implementation of BeginBatch from Server interface
func (m *MockServer) BeginBatch() {
// Mock implementation - no-op
diff --git a/client/internal/dns/notifier.go b/client/internal/dns/notifier.go
index 35cb6ff82..79d924a78 100644
--- a/client/internal/dns/notifier.go
+++ b/client/internal/dns/notifier.go
@@ -51,7 +51,5 @@ func (n *notifier) notify() {
return
}
- go func(l listener.NetworkChangeListener) {
- l.OnNetworkChanged("")
- }(n.listener)
+ n.listener.OnNetworkChanged("")
}
diff --git a/client/internal/dns/response_writer_test.go b/client/internal/dns/response_writer_test.go
index 857964406..bc8416029 100644
--- a/client/internal/dns/response_writer_test.go
+++ b/client/internal/dns/response_writer_test.go
@@ -4,7 +4,7 @@ import (
"net"
"testing"
- "github.com/golang/mock/gomock"
+ "go.uber.org/mock/gomock"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/miekg/dns"
diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go
index 07a70d6d1..931938755 100644
--- a/client/internal/dns/resutil/resolve.go
+++ b/client/internal/dns/resutil/resolve.go
@@ -8,6 +8,7 @@ import (
"errors"
"net"
"net/netip"
+ "slices"
"strings"
"github.com/miekg/dns"
@@ -167,7 +168,10 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina
case dns.TypeA:
alternativeNetwork = "ip6"
default:
- return dns.RcodeNameError
+ // Non-address types reach LookupIP only unexpectedly; without an
+ // address pair to probe we cannot prove the name is absent, so answer
+ // NODATA rather than a poisoning NXDOMAIN.
+ return dns.RcodeSuccess
}
if _, err := r.LookupNetIP(ctx, alternativeNetwork, domain); err != nil {
@@ -184,6 +188,230 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina
return dns.RcodeSuccess
}
+// RecordResolver is the host resolver surface used to forward non-address
+// record queries. net.DefaultResolver satisfies it.
+type RecordResolver interface {
+ LookupMX(ctx context.Context, name string) ([]*net.MX, error)
+ LookupTXT(ctx context.Context, name string) ([]string, error)
+ LookupNS(ctx context.Context, name string) ([]*net.NS, error)
+ LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error)
+ LookupCNAME(ctx context.Context, host string) (string, error)
+ LookupAddr(ctx context.Context, addr string) ([]string, error)
+}
+
+// LookupRecords resolves a non-address DNS record type through the host
+// resolver and returns the resource records and the DNS rcode. Types the host
+// resolver cannot answer (anything not covered by the net.Resolver Lookup*
+// methods) yield NODATA so that a routed name is never poisoned with NXDOMAIN
+// for an unsupported type.
+func LookupRecords(ctx context.Context, r RecordResolver, name string, qtype uint16, ttl uint32) ([]dns.RR, int) {
+ fqdn := dns.Fqdn(name)
+
+ switch qtype {
+ case dns.TypeMX:
+ return lookupMX(ctx, r, name, fqdn, ttl)
+ case dns.TypeTXT:
+ return lookupTXT(ctx, r, name, fqdn, ttl)
+ case dns.TypeNS:
+ return lookupNS(ctx, r, name, fqdn, ttl)
+ case dns.TypeSRV:
+ return lookupSRV(ctx, r, name, fqdn, ttl)
+ case dns.TypeCNAME:
+ return lookupCNAME(ctx, r, name, fqdn, ttl)
+ case dns.TypePTR:
+ return lookupPTR(ctx, r, name, fqdn, ttl)
+ default:
+ return nil, dns.RcodeSuccess
+ }
+}
+
+func recordHeader(fqdn string, rrtype uint16, ttl uint32) dns.RR_Header {
+ return dns.RR_Header{Name: fqdn, Rrtype: rrtype, Class: dns.ClassINET, Ttl: ttl}
+}
+
+func lookupMX(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ recs, err := r.LookupMX(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, mx := range recs {
+ rrs = append(rrs, &dns.MX{
+ Hdr: recordHeader(fqdn, dns.TypeMX, ttl),
+ Preference: mx.Pref,
+ Mx: dns.Fqdn(mx.Host),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupTXT(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ recs, err := r.LookupTXT(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, txt := range recs {
+ rrs = append(rrs, &dns.TXT{
+ Hdr: recordHeader(fqdn, dns.TypeTXT, ttl),
+ Txt: chunkTXT(txt),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupNS(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ recs, err := r.LookupNS(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, ns := range recs {
+ rrs = append(rrs, &dns.NS{
+ Hdr: recordHeader(fqdn, dns.TypeNS, ttl),
+ Ns: dns.Fqdn(ns.Host),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupSRV(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ _, recs, err := r.LookupSRV(ctx, "", "", name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, srv := range recs {
+ rrs = append(rrs, &dns.SRV{
+ Hdr: recordHeader(fqdn, dns.TypeSRV, ttl),
+ Priority: srv.Priority,
+ Weight: srv.Weight,
+ Port: srv.Port,
+ Target: dns.Fqdn(srv.Target),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupCNAME(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ cname, err := r.LookupCNAME(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ // LookupCNAME returns the queried name itself when the name resolves but
+ // has no CNAME record; that is a NODATA result, not a CNAME.
+ if strings.EqualFold(dns.Fqdn(cname), fqdn) {
+ return nil, dns.RcodeSuccess
+ }
+ return []dns.RR{&dns.CNAME{
+ Hdr: recordHeader(fqdn, dns.TypeCNAME, ttl),
+ Target: dns.Fqdn(cname),
+ }}, dns.RcodeSuccess
+}
+
+func lookupPTR(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ addr, ok := ptrQueryAddr(name)
+ if !ok {
+ return nil, dns.RcodeSuccess
+ }
+ names, err := r.LookupAddr(ctx, addr)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(names))
+ for _, n := range names {
+ rrs = append(rrs, &dns.PTR{
+ Hdr: recordHeader(fqdn, dns.TypePTR, ttl),
+ Ptr: dns.Fqdn(n),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+// ptrQueryAddr converts a reverse-DNS query name (in-addr.arpa or ip6.arpa)
+// into the address string expected by net.Resolver.LookupAddr. It reports false
+// when the name is not a well-formed reverse name.
+func ptrQueryAddr(qname string) (string, bool) {
+ name := strings.TrimSuffix(strings.ToLower(dns.Fqdn(qname)), ".")
+
+ switch {
+ case strings.HasSuffix(name, ".in-addr.arpa"):
+ return parseInAddrArpa(strings.TrimSuffix(name, ".in-addr.arpa"))
+ case strings.HasSuffix(name, ".ip6.arpa"):
+ return parseIP6Arpa(strings.TrimSuffix(name, ".ip6.arpa"))
+ default:
+ return "", false
+ }
+}
+
+// parseInAddrArpa turns the label portion of an in-addr.arpa name into an IPv4
+// address string, reporting false when it is not a well-formed reverse name.
+func parseInAddrArpa(labelPart string) (string, bool) {
+ labels := strings.Split(labelPart, ".")
+ if len(labels) != 4 {
+ return "", false
+ }
+ slices.Reverse(labels)
+ addr, err := netip.ParseAddr(strings.Join(labels, "."))
+ if err != nil || !addr.Is4() {
+ return "", false
+ }
+ return addr.String(), true
+}
+
+// parseIP6Arpa turns the nibble portion of an ip6.arpa name into an IPv6
+// address string, reporting false when it is not a well-formed reverse name.
+func parseIP6Arpa(nibblePart string) (string, bool) {
+ nibbles := strings.Split(nibblePart, ".")
+ if len(nibbles) != 32 {
+ return "", false
+ }
+ slices.Reverse(nibbles)
+ var sb strings.Builder
+ for i, n := range nibbles {
+ if i > 0 && i%4 == 0 {
+ sb.WriteByte(':')
+ }
+ sb.WriteString(n)
+ }
+ addr, err := netip.ParseAddr(sb.String())
+ if err != nil || !addr.Is6() {
+ return "", false
+ }
+ return addr.String(), true
+}
+
+// rcodeForRecordError maps a non-address lookup error to a DNS rcode. A
+// not-found result becomes NODATA rather than NXDOMAIN: net.DNSError.IsNotFound
+// does not distinguish a missing name from a name that exists only with records
+// of other types, so the name cannot be proven absent and must not be poisoned.
+func rcodeForRecordError(err error) int {
+ var dnsErr *net.DNSError
+ if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
+ return dns.RcodeSuccess
+ }
+ return dns.RcodeServerFailure
+}
+
+// chunkTXT splits a TXT string into character-strings no longer than 255 bytes
+// so the record can be packed. The chunks form one TXT resource record.
+func chunkTXT(s string) []string {
+ const maxLen = 255
+ if len(s) <= maxLen {
+ return []string{s}
+ }
+
+ var chunks []string
+ for len(s) > maxLen {
+ chunks = append(chunks, s[:maxLen])
+ s = s[maxLen:]
+ }
+ if len(s) > 0 {
+ chunks = append(chunks, s)
+ }
+ return chunks
+}
+
// FormatAnswers formats DNS resource records for logging.
func FormatAnswers(answers []dns.RR) string {
if len(answers) == 0 {
@@ -207,3 +435,35 @@ func FormatAnswers(answers []dns.RR) string {
}
return "[" + strings.Join(parts, ", ") + "]"
}
+
+// StripOPT removes any OPT pseudo-RRs from the message's Extra section. Per
+// RFC 6891 a responder must not include an OPT RR toward a client that did not
+// advertise EDNS0.
+func StripOPT(msg *dns.Msg) {
+ if len(msg.Extra) == 0 {
+ return
+ }
+ out := msg.Extra[:0]
+ for _, rr := range msg.Extra {
+ if _, ok := rr.(*dns.OPT); ok {
+ continue
+ }
+ out = append(out, rr)
+ }
+ msg.Extra = out
+}
+
+// ExtractEDE returns the first Extended DNS Error (RFC 8914) option carried in
+// the message, if present.
+func ExtractEDE(msg *dns.Msg) (*dns.EDNS0_EDE, bool) {
+ opt := msg.IsEdns0()
+ if opt == nil {
+ return nil, false
+ }
+ for _, o := range opt.Option {
+ if ede, ok := o.(*dns.EDNS0_EDE); ok {
+ return ede, true
+ }
+ }
+ return nil, false
+}
diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go
index 432367c22..f51092a83 100644
--- a/client/internal/dns/resutil/resolve_test.go
+++ b/client/internal/dns/resutil/resolve_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"net"
"net/netip"
+ "strings"
"testing"
"github.com/miekg/dns"
@@ -120,3 +121,200 @@ func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) {
assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL")
}
+
+func TestPtrQueryAddr(t *testing.T) {
+ tests := []struct {
+ name string
+ qname string
+ want string
+ wantOK bool
+ }{
+ {name: "ipv4", qname: "4.3.2.1.in-addr.arpa.", want: "1.2.3.4", wantOK: true},
+ {name: "ipv4 no trailing dot", qname: "1.0.0.127.in-addr.arpa", want: "127.0.0.1", wantOK: true},
+ {
+ name: "ipv6",
+ qname: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
+ want: "2001:db8::1",
+ wantOK: true,
+ },
+ {name: "ipv4 wrong label count", qname: "2.1.in-addr.arpa.", wantOK: false},
+ {name: "ipv6 wrong nibble count", qname: "1.0.ip6.arpa.", wantOK: false},
+ {name: "not a reverse name", qname: "example.com.", wantOK: false},
+ {name: "ipv4 bad octet", qname: "4.3.2.999.in-addr.arpa.", wantOK: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, ok := ptrQueryAddr(tt.qname)
+ assert.Equal(t, tt.wantOK, ok, "parse success mismatch")
+ if tt.wantOK {
+ assert.Equal(t, tt.want, got, "parsed address mismatch")
+ }
+ })
+ }
+}
+
+type mockRecordResolver struct {
+ mx []*net.MX
+ txt []string
+ ns []*net.NS
+ srv []*net.SRV
+ cname string
+ ptr []string
+ err error
+}
+
+func (m *mockRecordResolver) LookupMX(context.Context, string) ([]*net.MX, error) {
+ return m.mx, m.err
+}
+func (m *mockRecordResolver) LookupTXT(context.Context, string) ([]string, error) {
+ return m.txt, m.err
+}
+func (m *mockRecordResolver) LookupNS(context.Context, string) ([]*net.NS, error) {
+ return m.ns, m.err
+}
+func (m *mockRecordResolver) LookupSRV(context.Context, string, string, string) (string, []*net.SRV, error) {
+ return "", m.srv, m.err
+}
+func (m *mockRecordResolver) LookupCNAME(context.Context, string) (string, error) {
+ return m.cname, m.err
+}
+func (m *mockRecordResolver) LookupAddr(context.Context, string) ([]string, error) {
+ return m.ptr, m.err
+}
+
+func TestLookupRecords(t *testing.T) {
+ notFound := &net.DNSError{IsNotFound: true, Name: "example.com."}
+
+ t.Run("MX success", func(t *testing.T) {
+ r := &mockRecordResolver{mx: []*net.MX{{Host: "mail.example.com.", Pref: 10}}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "mail.example.com.", rrs[0].(*dns.MX).Mx)
+ })
+
+ t.Run("TXT short string is one character-string", func(t *testing.T) {
+ r := &mockRecordResolver{txt: []string{"v=spf1 -all"}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, []string{"v=spf1 -all"}, rrs[0].(*dns.TXT).Txt)
+ })
+
+ t.Run("TXT chunks long strings", func(t *testing.T) {
+ long := strings.Repeat("a", 300)
+ r := &mockRecordResolver{txt: []string{long}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ txt := rrs[0].(*dns.TXT).Txt
+ require.Len(t, txt, 2, "300-byte string should split into two character-strings")
+ assert.Equal(t, 255, len(txt[0]))
+ assert.Equal(t, 45, len(txt[1]))
+ })
+
+ t.Run("NS success", func(t *testing.T) {
+ r := &mockRecordResolver{ns: []*net.NS{{Host: "ns1.example.com."}}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeNS, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "ns1.example.com.", rrs[0].(*dns.NS).Ns)
+ })
+
+ t.Run("SRV success", func(t *testing.T) {
+ r := &mockRecordResolver{srv: []*net.SRV{{Target: "sip.example.com.", Port: 5060}}}
+ rrs, rcode := LookupRecords(context.Background(), r, "_sip._tcp.example.com.", dns.TypeSRV, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, uint16(5060), rrs[0].(*dns.SRV).Port)
+ })
+
+ t.Run("CNAME success", func(t *testing.T) {
+ r := &mockRecordResolver{cname: "target.example.com."}
+ rrs, rcode := LookupRecords(context.Background(), r, "www.example.com.", dns.TypeCNAME, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "target.example.com.", rrs[0].(*dns.CNAME).Target)
+ })
+
+ t.Run("CNAME equal to name is NODATA", func(t *testing.T) {
+ r := &mockRecordResolver{cname: "example.com."}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCNAME, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ assert.Empty(t, rrs, "self-referential CNAME is NODATA")
+ })
+
+ t.Run("PTR success", func(t *testing.T) {
+ r := &mockRecordResolver{ptr: []string{"host.example.com."}}
+ rrs, rcode := LookupRecords(context.Background(), r, "4.3.2.1.in-addr.arpa.", dns.TypePTR, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "host.example.com.", rrs[0].(*dns.PTR).Ptr)
+ })
+
+ t.Run("PTR malformed name is NODATA", func(t *testing.T) {
+ r := &mockRecordResolver{}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypePTR, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ assert.Empty(t, rrs)
+ })
+
+ t.Run("not found is NODATA never NXDOMAIN", func(t *testing.T) {
+ r := &mockRecordResolver{err: notFound}
+ _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode, "missing record must not poison the name")
+ })
+
+ t.Run("server failure maps to SERVFAIL", func(t *testing.T) {
+ r := &mockRecordResolver{err: &net.DNSError{Err: "server misbehaving", IsTemporary: true}}
+ _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300)
+ assert.Equal(t, dns.RcodeServerFailure, rcode)
+ })
+
+ t.Run("unsupported type is NODATA", func(t *testing.T) {
+ r := &mockRecordResolver{}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCAA, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ assert.Empty(t, rrs)
+ })
+}
+
+func TestStripOPT(t *testing.T) {
+ rm := &dns.Msg{
+ Extra: []dns.RR{
+ &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}},
+ &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)},
+ },
+ }
+ StripOPT(rm)
+ assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept")
+ _, isOPT := rm.Extra[0].(*dns.OPT)
+ assert.False(t, isOPT, "remaining record must not be OPT")
+}
+
+func TestExtractEDE(t *testing.T) {
+ t.Run("no edns", func(t *testing.T) {
+ _, ok := ExtractEDE(&dns.Msg{})
+ assert.False(t, ok, "message without OPT has no EDE")
+ })
+
+ t.Run("edns without ede", func(t *testing.T) {
+ rm := &dns.Msg{}
+ rm.SetEdns0(4096, false)
+ _, ok := ExtractEDE(rm)
+ assert.False(t, ok, "OPT without EDE option returns false")
+ })
+
+ t.Run("with ede", func(t *testing.T) {
+ rm := &dns.Msg{}
+ opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
+ opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: 49152, ExtraText: "upstream timeout"})
+ rm.Extra = append(rm.Extra, opt)
+
+ ede, ok := ExtractEDE(rm)
+ assert.True(t, ok, "EDE option should be found")
+ assert.Equal(t, uint16(49152), ede.InfoCode)
+ assert.Equal(t, "upstream timeout", ede.ExtraText)
+ })
+}
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index 7556c66cc..3af912792 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -82,6 +82,7 @@ type Server interface {
PopulateManagementDomain(mgmtURL *url.URL) error
SetRouteSources(selected, active func() route.HAMap)
SetFirewall(Firewall)
+ SetPeerActivator(local.PeerActivator)
}
type nsGroupsByDomain struct {
@@ -251,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
ds.hostsDNSHolder.set(hostsDnsList)
ds.permanent = true
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
- ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
+ ds.searchDomainNotifier = newNotifier(ds.searchDomains())
ds.searchDomainNotifier.setListener(listener)
setServerDns(ds)
return ds
@@ -491,6 +492,13 @@ func (s *DefaultServer) SetFirewall(fw Firewall) {
}
}
+// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local
+// resolver. Injected after the connection manager exists (it does not at
+// DNS-server construction time). Pass nil to disable.
+func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) {
+ s.localResolver.SetPeerActivator(a)
+}
+
// Stop stops the server
func (s *DefaultServer) Stop() {
s.ctxCancel()
@@ -594,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
}
func (s *DefaultServer) SearchDomains() []string {
+ s.mux.Lock()
+ defer s.mux.Unlock()
+ return s.searchDomains()
+}
+
+func (s *DefaultServer) searchDomains() []string {
var searchDomains []string
for _, dConf := range s.currentConfig.Domains {
@@ -678,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
}()
if s.searchDomainNotifier != nil {
- s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
+ s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
}
s.updateNSGroupStates(update.NameServerGroups)
@@ -1435,11 +1449,11 @@ type localPeerConnectivity struct {
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
-func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
+func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
if l.status == nil {
return false, false
}
- state, ok := l.status.PeerStateByIP(ip)
+ state, ok := l.status.PeerStateByIP(ip.String())
if !ok {
return false, false
}
diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go
new file mode 100644
index 000000000..a17044cf5
--- /dev/null
+++ b/client/internal/dns/server_privileged_test.go
@@ -0,0 +1,485 @@
+//go:build privileged
+
+package dns
+
+import (
+ "context"
+ "fmt"
+ "net/netip"
+ "os"
+ "testing"
+
+ "go.uber.org/mock/gomock"
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+ "github.com/netbirdio/netbird/client/iface"
+ pfmock "github.com/netbirdio/netbird/client/iface/mocks"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/dns/local"
+ "github.com/netbirdio/netbird/client/internal/dns/test"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/stdnet"
+ nbdns "github.com/netbirdio/netbird/dns"
+)
+
+func TestUpdateDNSServer(t *testing.T) {
+
+ nameServers := []nbdns.NameServer{
+ {
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ {
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ }
+
+ testCases := []struct {
+ name string
+ initUpstreamMap []handlerWrapper
+ initLocalZones []nbdns.CustomZone
+ initSerial uint64
+ inputSerial uint64
+ inputUpdate nbdns.Config
+ shouldFail bool
+ expectedUpstreamMap []handlerWrapper
+ expectedLocalQs []dns.Question
+ }{
+ {
+ name: "Initial Config Should Succeed",
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.io",
+ priority: PriorityUpstream,
+ },
+ {
+ domain: "netbird.cloud",
+ priority: PriorityLocal,
+ },
+ {
+ domain: nbdns.RootZone,
+ priority: PriorityDefault,
+ },
+ },
+ expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
+ },
+ {
+ name: "New Config Should Succeed",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.cloud",
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.io",
+ priority: PriorityUpstream,
+ },
+ {
+ domain: "netbird.cloud",
+ priority: PriorityLocal,
+ },
+ },
+ expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
+ },
+ {
+ name: "Smaller Config Serial Should Be Skipped",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 2,
+ inputSerial: 1,
+ shouldFail: true,
+ },
+ {
+ name: "Empty NS Group Domain Or Not Primary Element Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ },
+ },
+ },
+ shouldFail: true,
+ },
+ {
+ name: "Invalid NS Group Nameservers list Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ },
+ },
+ },
+ shouldFail: true,
+ },
+ {
+ name: "Invalid Custom Zone Records list Should Skip",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{{
+ domain: ".",
+ priority: PriorityDefault,
+ }},
+ },
+ {
+ name: "Empty Config Should Succeed and Clean Maps",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{ServiceEnable: true},
+ expectedUpstreamMap: nil,
+ expectedLocalQs: []dns.Question{},
+ },
+ {
+ name: "Disabled Service Should clean map",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{ServiceEnable: false},
+ expectedUpstreamMap: nil,
+ expectedLocalQs: []dns.Question{},
+ },
+ }
+
+ for n, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ privKey, _ := wgtypes.GenerateKey()
+ newNet, err := stdnet.NewNet(context.Background(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ opts := iface.WGIFaceOpts{
+ IFaceName: fmt.Sprintf("utun230%d", n),
+ Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)),
+ WGPort: 33100,
+ WGPrivKey: privKey.String(),
+ MTU: iface.DefaultMTU,
+ TransportNet: newNet,
+ }
+
+ wgIface, err := iface.NewWGIFace(opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = wgIface.Create()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ err = wgIface.Close()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+ dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
+ WgInterface: wgIface,
+ CustomAddress: "",
+ StatusRecorder: peer.NewRecorder("mgm"),
+ StateManager: nil,
+ DisableSys: false,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = dnsServer.Initialize()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ err = dnsServer.hostManager.restoreHostDNS()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+
+ dnsServer.dnsMuxHandlers = testCase.initUpstreamMap
+ dnsServer.localResolver.Update(testCase.initLocalZones)
+ dnsServer.updateSerial = testCase.initSerial
+
+ err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
+ if err != nil {
+ if testCase.shouldFail {
+ return
+ }
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ }
+
+ if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) {
+ t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers))
+ }
+
+ for _, expected := range testCase.expectedUpstreamMap {
+ found := false
+ for _, got := range dnsServer.dnsMuxHandlers {
+ if got.domain == expected.domain && got.priority == expected.priority {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers)
+ }
+ }
+
+ var responseMSG *dns.Msg
+ responseWriter := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ responseMSG = m
+ return nil
+ },
+ }
+ for _, q := range testCase.expectedLocalQs {
+ dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{
+ Question: []dns.Question{q},
+ })
+ }
+
+ if len(testCase.expectedLocalQs) > 0 {
+ assert.NotNil(t, responseMSG, "response message should not be nil")
+ assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success")
+ assert.NotEmpty(t, responseMSG.Answer, "response message should have answers")
+ }
+ })
+ }
+}
+
+func TestDNSFakeResolverHandleUpdates(t *testing.T) {
+ ov := os.Getenv("NB_WG_KERNEL_DISABLED")
+ defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
+
+ t.Setenv("NB_WG_KERNEL_DISABLED", "true")
+ newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
+ if err != nil {
+ t.Errorf("create stdnet: %v", err)
+ return
+ }
+
+ privKey, _ := wgtypes.GeneratePrivateKey()
+ opts := iface.WGIFaceOpts{
+ IFaceName: "utun2301",
+ Address: wgaddr.MustParseWGAddress("100.66.100.1/32"),
+ WGPort: 33100,
+ WGPrivKey: privKey.String(),
+ MTU: iface.DefaultMTU,
+ TransportNet: newNet,
+ }
+ wgIface, err := iface.NewWGIFace(opts)
+ if err != nil {
+ t.Errorf("build interface wireguard: %v", err)
+ return
+ }
+
+ err = wgIface.Create()
+ if err != nil {
+ t.Errorf("create and init wireguard interface: %v", err)
+ return
+ }
+ defer func() {
+ if err = wgIface.Close(); err != nil {
+ t.Logf("close wireguard interface: %v", err)
+ }
+ }()
+
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ packetfilter := pfmock.NewMockPacketFilter(ctrl)
+ packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes()
+ packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+ packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+
+ if err := wgIface.SetFilter(packetfilter); err != nil {
+ t.Errorf("set packet filter: %v", err)
+ return
+ }
+
+ dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
+ WgInterface: wgIface,
+ CustomAddress: "",
+ StatusRecorder: peer.NewRecorder("mgm"),
+ StateManager: nil,
+ DisableSys: false,
+ })
+ if err != nil {
+ t.Errorf("create DNS server: %v", err)
+ return
+ }
+
+ err = dnsServer.Initialize()
+ if err != nil {
+ t.Errorf("run DNS server: %v", err)
+ return
+ }
+ defer func() {
+ if err = dnsServer.hostManager.restoreHostDNS(); err != nil {
+ t.Logf("restore DNS settings on the host: %v", err)
+ return
+ }
+ }()
+
+ dnsServer.dnsMuxHandlers = []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &local.Resolver{},
+ priority: PriorityUpstream,
+ },
+ }
+ dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
+ dnsServer.updateSerial = 0
+
+ nameServers := []nbdns.NameServer{
+ {
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ {
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ }
+
+ update := nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ }
+
+ // Start the server with regular configuration
+ if err := dnsServer.UpdateDNSServer(1, update); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+
+ update2 := update
+ update2.ServiceEnable = false
+ // Disable the server, stop the listener
+ if err := dnsServer.UpdateDNSServer(2, update2); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+
+ update3 := update2
+ update3.NameServerGroups = update3.NameServerGroups[:1]
+ // But service still get updates and we checking that we handle
+ // internal state in the right way
+ if err := dnsServer.UpdateDNSServer(3, update3); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+}
diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go
index 4ef790412..96e55a354 100644
--- a/client/internal/dns/server_test.go
+++ b/client/internal/dns/server_test.go
@@ -10,7 +10,6 @@ import (
"testing"
"time"
- "github.com/golang/mock/gomock"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -23,7 +22,6 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/iface/device"
- pfmock "github.com/netbirdio/netbird/client/iface/mocks"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/dns/local"
"github.com/netbirdio/netbird/client/internal/dns/test"
@@ -104,466 +102,6 @@ func init() {
formatter.SetTextFormatter(log.StandardLogger())
}
-func TestUpdateDNSServer(t *testing.T) {
-
- nameServers := []nbdns.NameServer{
- {
- IP: netip.MustParseAddr("8.8.8.8"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- {
- IP: netip.MustParseAddr("8.8.4.4"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- }
-
- testCases := []struct {
- name string
- initUpstreamMap []handlerWrapper
- initLocalZones []nbdns.CustomZone
- initSerial uint64
- inputSerial uint64
- inputUpdate nbdns.Config
- shouldFail bool
- expectedUpstreamMap []handlerWrapper
- expectedLocalQs []dns.Question
- }{
- {
- name: "Initial Config Should Succeed",
- initUpstreamMap: nil,
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- },
- expectedUpstreamMap: []handlerWrapper{
- {
- domain: "netbird.io",
- priority: PriorityUpstream,
- },
- {
- domain: "netbird.cloud",
- priority: PriorityLocal,
- },
- {
- domain: nbdns.RootZone,
- priority: PriorityDefault,
- },
- },
- expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
- },
- {
- name: "New Config Should Succeed",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: []handlerWrapper{
- {
- domain: "netbird.cloud",
- handler: &mockHandler{},
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- },
- },
- expectedUpstreamMap: []handlerWrapper{
- {
- domain: "netbird.io",
- priority: PriorityUpstream,
- },
- {
- domain: "netbird.cloud",
- priority: PriorityLocal,
- },
- },
- expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
- },
- {
- name: "Smaller Config Serial Should Be Skipped",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: nil,
- initSerial: 2,
- inputSerial: 1,
- shouldFail: true,
- },
- {
- name: "Empty NS Group Domain Or Not Primary Element Should Fail",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: nil,
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- },
- },
- },
- shouldFail: true,
- },
- {
- name: "Invalid NS Group Nameservers list Should Fail",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: nil,
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- },
- },
- },
- shouldFail: true,
- },
- {
- name: "Invalid Custom Zone Records list Should Skip",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: nil,
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- },
- expectedUpstreamMap: []handlerWrapper{{
- domain: ".",
- priority: PriorityDefault,
- }},
- },
- {
- name: "Empty Config Should Succeed and Clean Maps",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: []handlerWrapper{
- {
- domain: zoneRecords[0].Name,
- handler: &mockHandler{},
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{ServiceEnable: true},
- expectedUpstreamMap: nil,
- expectedLocalQs: []dns.Question{},
- },
- {
- name: "Disabled Service Should clean map",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: []handlerWrapper{
- {
- domain: zoneRecords[0].Name,
- handler: &mockHandler{},
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{ServiceEnable: false},
- expectedUpstreamMap: nil,
- expectedLocalQs: []dns.Question{},
- },
- }
-
- for n, testCase := range testCases {
- t.Run(testCase.name, func(t *testing.T) {
- privKey, _ := wgtypes.GenerateKey()
- newNet, err := stdnet.NewNet(context.Background(), nil)
- if err != nil {
- t.Fatal(err)
- }
-
- opts := iface.WGIFaceOpts{
- IFaceName: fmt.Sprintf("utun230%d", n),
- Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)),
- WGPort: 33100,
- WGPrivKey: privKey.String(),
- MTU: iface.DefaultMTU,
- TransportNet: newNet,
- }
-
- wgIface, err := iface.NewWGIFace(opts)
- if err != nil {
- t.Fatal(err)
- }
- err = wgIface.Create()
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- err = wgIface.Close()
- if err != nil {
- t.Log(err)
- }
- }()
- dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
- WgInterface: wgIface,
- CustomAddress: "",
- StatusRecorder: peer.NewRecorder("mgm"),
- StateManager: nil,
- DisableSys: false,
- })
- if err != nil {
- t.Fatal(err)
- }
- err = dnsServer.Initialize()
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- err = dnsServer.hostManager.restoreHostDNS()
- if err != nil {
- t.Log(err)
- }
- }()
-
- dnsServer.dnsMuxHandlers = testCase.initUpstreamMap
- dnsServer.localResolver.Update(testCase.initLocalZones)
- dnsServer.updateSerial = testCase.initSerial
-
- err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
- if err != nil {
- if testCase.shouldFail {
- return
- }
- t.Fatalf("update dns server should not fail, got error: %v", err)
- }
-
- if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) {
- t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers))
- }
-
- for _, expected := range testCase.expectedUpstreamMap {
- found := false
- for _, got := range dnsServer.dnsMuxHandlers {
- if got.domain == expected.domain && got.priority == expected.priority {
- found = true
- break
- }
- }
- if !found {
- t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers)
- }
- }
-
- var responseMSG *dns.Msg
- responseWriter := &test.MockResponseWriter{
- WriteMsgFunc: func(m *dns.Msg) error {
- responseMSG = m
- return nil
- },
- }
- for _, q := range testCase.expectedLocalQs {
- dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{
- Question: []dns.Question{q},
- })
- }
-
- if len(testCase.expectedLocalQs) > 0 {
- assert.NotNil(t, responseMSG, "response message should not be nil")
- assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success")
- assert.NotEmpty(t, responseMSG.Answer, "response message should have answers")
- }
- })
- }
-}
-
-func TestDNSFakeResolverHandleUpdates(t *testing.T) {
- ov := os.Getenv("NB_WG_KERNEL_DISABLED")
- defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
-
- t.Setenv("NB_WG_KERNEL_DISABLED", "true")
- newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
- if err != nil {
- t.Errorf("create stdnet: %v", err)
- return
- }
-
- privKey, _ := wgtypes.GeneratePrivateKey()
- opts := iface.WGIFaceOpts{
- IFaceName: "utun2301",
- Address: wgaddr.MustParseWGAddress("100.66.100.1/32"),
- WGPort: 33100,
- WGPrivKey: privKey.String(),
- MTU: iface.DefaultMTU,
- TransportNet: newNet,
- }
- wgIface, err := iface.NewWGIFace(opts)
- if err != nil {
- t.Errorf("build interface wireguard: %v", err)
- return
- }
-
- err = wgIface.Create()
- if err != nil {
- t.Errorf("create and init wireguard interface: %v", err)
- return
- }
- defer func() {
- if err = wgIface.Close(); err != nil {
- t.Logf("close wireguard interface: %v", err)
- }
- }()
-
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
-
- packetfilter := pfmock.NewMockPacketFilter(ctrl)
- packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes()
- packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
- packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
-
- if err := wgIface.SetFilter(packetfilter); err != nil {
- t.Errorf("set packet filter: %v", err)
- return
- }
-
- dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
- WgInterface: wgIface,
- CustomAddress: "",
- StatusRecorder: peer.NewRecorder("mgm"),
- StateManager: nil,
- DisableSys: false,
- })
- if err != nil {
- t.Errorf("create DNS server: %v", err)
- return
- }
-
- err = dnsServer.Initialize()
- if err != nil {
- t.Errorf("run DNS server: %v", err)
- return
- }
- defer func() {
- if err = dnsServer.hostManager.restoreHostDNS(); err != nil {
- t.Logf("restore DNS settings on the host: %v", err)
- return
- }
- }()
-
- dnsServer.dnsMuxHandlers = []handlerWrapper{
- {
- domain: zoneRecords[0].Name,
- handler: &local.Resolver{},
- priority: PriorityUpstream,
- },
- }
- dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
- dnsServer.updateSerial = 0
-
- nameServers := []nbdns.NameServer{
- {
- IP: netip.MustParseAddr("8.8.8.8"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- {
- IP: netip.MustParseAddr("8.8.4.4"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- }
-
- update := nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- }
-
- // Start the server with regular configuration
- if err := dnsServer.UpdateDNSServer(1, update); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-
- update2 := update
- update2.ServiceEnable = false
- // Disable the server, stop the listener
- if err := dnsServer.UpdateDNSServer(2, update2); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-
- update3 := update2
- update3.NameServerGroups = update3.NameServerGroups[:1]
- // But service still get updates and we checking that we handle
- // internal state in the right way
- if err := dnsServer.UpdateDNSServer(3, update3); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-}
-
func TestDNSServerStartStop(t *testing.T) {
testCases := []struct {
name string
diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go
index 9c0e52af8..3dc29c4dc 100644
--- a/client/internal/dns/service_listener.go
+++ b/client/internal/dns/service_listener.go
@@ -292,18 +292,16 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) {
return customPort, nil
}
- udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0"))
- probeListener, err := net.ListenUDP("udp", udpAddr)
+ probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil {
log.Debugf("failed to bind random port for DNS: %s", err)
return 0, err
}
- addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect
- err = probeListener.Close()
- if err != nil {
+ port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
+ if err = probeListener.Close(); err != nil {
log.Debugf("failed to free up DNS port: %s", err)
return 0, err
}
- return addrPort.Port(), nil
+ return port, nil
}
diff --git a/client/internal/dns/unclean_shutdown_windows.go b/client/internal/dns/unclean_shutdown_windows.go
index 24a9eca50..ab0b2cc63 100644
--- a/client/internal/dns/unclean_shutdown_windows.go
+++ b/client/internal/dns/unclean_shutdown_windows.go
@@ -5,9 +5,8 @@ import (
)
type ShutdownState struct {
- Guid string
- GPO bool
- NRPTEntryCount int
+ Guid string
+ GPO bool
}
func (s *ShutdownState) Name() string {
@@ -16,9 +15,8 @@ func (s *ShutdownState) Name() string {
func (s *ShutdownState) Cleanup() error {
manager := ®istryConfigurator{
- guid: s.Guid,
- gpo: s.GPO,
- nrptEntryCount: s.NRPTEntryCount,
+ guid: s.Guid,
+ gpo: s.GPO,
}
if err := manager.restoreUncleanShutdownDNS(); err != nil {
diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go
index 9c0d00212..72fc0450c 100644
--- a/client/internal/dns/upstream.go
+++ b/client/internal/dns/upstream.go
@@ -457,7 +457,7 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M
// problems: fail over for a better answer but keep the upstream healthy.
if code, ok := nonRetryableEDE(rm); ok {
if !hadEdns {
- stripOPT(rm)
+ resutil.StripOPT(rm)
}
return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil
}
@@ -466,7 +466,7 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M
}
if !hadEdns {
- stripOPT(rm)
+ resutil.StripOPT(rm)
}
return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil
@@ -523,22 +523,6 @@ func upstreamUDPSize() uint16 {
return dns.MinMsgSize
}
-// stripOPT removes any OPT pseudo-RRs from the response's Extra section so
-// the response complies with RFC 6891 when the client did not advertise EDNS0.
-func stripOPT(rm *dns.Msg) {
- if len(rm.Extra) == 0 {
- return
- }
- out := rm.Extra[:0]
- for _, rr := range rm.Extra {
- if _, ok := rr.(*dns.OPT); ok {
- continue
- }
- out = append(out, rr)
- }
- rm.Extra = out
-}
-
func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure {
if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) {
return &upstreamFailure{upstream: upstream, reason: err.Error()}
diff --git a/client/internal/dns/upstream_ios.go b/client/internal/dns/upstream_ios.go
index b989bf0f9..793d87fca 100644
--- a/client/internal/dns/upstream_ios.go
+++ b/client/internal/dns/upstream_ios.go
@@ -130,8 +130,3 @@ func GetClientPrivate(iface privateClientIface, upstreamIP netip.Addr, dialTimeo
}
return client, nil
}
-
-func getInterfaceIndex(interfaceName string) (int, error) {
- iface, err := net.InterfaceByName(interfaceName)
- return iface.Index, err
-}
diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go
index afd2053cc..4c2784545 100644
--- a/client/internal/dns/upstream_test.go
+++ b/client/internal/dns/upstream_test.go
@@ -985,19 +985,6 @@ func TestEDEName(t *testing.T) {
assert.Equal(t, "EDE 9999", edeName(9999), "unknown code falls back to numeric")
}
-func TestStripOPT(t *testing.T) {
- rm := &dns.Msg{
- Extra: []dns.RR{
- &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}},
- &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)},
- },
- }
- stripOPT(rm)
- assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept")
- _, isOPT := rm.Extra[0].(*dns.OPT)
- assert.False(t, isOPT, "remaining record must not be OPT")
-}
-
func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) {
upstream1 := netip.MustParseAddrPort("192.0.2.1:53")
upstream2 := netip.MustParseAddrPort("192.0.2.2:53")
diff --git a/client/internal/dns_peer_activator.go b/client/internal/dns_peer_activator.go
new file mode 100644
index 000000000..c283d6251
--- /dev/null
+++ b/client/internal/dns_peer_activator.go
@@ -0,0 +1,76 @@
+package internal
+
+import (
+ "context"
+ "net/netip"
+ "time"
+
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/peerstore"
+)
+
+const dnsActivationPollInterval = 50 * time.Millisecond
+
+// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It
+// implements dns/local.PeerActivator. DNS queries run on their own goroutines,
+// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer,
+// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux,
+// keeping DNS resolution from contending with network-map processing.
+type dnsPeerActivator struct {
+ connMgr *ConnMgr
+ peerStore *peerstore.Store
+ status *peer.Status
+ // ctx is the engine's long-lived context. The connection dial is tied to it
+ // (not the per-query DNS wait budget) so a handshake that outlasts the wait
+ // still completes in the background rather than being cancelled at the deadline.
+ ctx context.Context
+}
+
+// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits
+// until one is connected or ctx (the per-query DNS wait budget) expires.
+// Activation itself is tied to the engine's long-lived context so the dial
+// survives a wait that times out. Unknown or already-connected addresses are
+// skipped, so the steady-state (warm) path adds no latency.
+func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) {
+ if a == nil || a.connMgr == nil {
+ return
+ }
+
+ var pending []string
+ for _, addr := range addrs {
+ ip := addr.String()
+ st, ok := a.status.PeerStateByIP(ip)
+ if !ok || st.ConnStatus == peer.StatusConnected {
+ continue
+ }
+ conn, ok := a.peerStore.PeerConn(st.PubKey)
+ if !ok {
+ continue
+ }
+ a.connMgr.ActivatePeer(a.ctx, conn)
+ pending = append(pending, ip)
+ }
+
+ if len(pending) == 0 {
+ return
+ }
+ a.waitConnected(ctx, pending)
+}
+
+// waitConnected blocks until any of ips reports a connected peer or ctx expires.
+func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) {
+ ticker := time.NewTicker(dnsActivationPollInterval)
+ defer ticker.Stop()
+ for {
+ for _, ip := range ips {
+ if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected {
+ return
+ }
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
+ }
+}
diff --git a/client/internal/dns_peer_activator_test.go b/client/internal/dns_peer_activator_test.go
new file mode 100644
index 000000000..8c3b75e59
--- /dev/null
+++ b/client/internal/dns_peer_activator_test.go
@@ -0,0 +1,129 @@
+package internal
+
+import (
+ "context"
+ "net/netip"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/peerstore"
+)
+
+func newTestPeerConn(t *testing.T, key string) *peer.Conn {
+ t.Helper()
+ conn, err := peer.NewConn(peer.ConnConfig{
+ Key: key,
+ LocalKey: "local",
+ WgConfig: peer.WgConfig{
+ AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
+ },
+ }, peer.ServiceDependencies{})
+ require.NoError(t, err)
+ return conn
+}
+
+func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) {
+ t.Helper()
+ status := peer.NewRecorder("https://mgm")
+ store := peerstore.NewConnStore()
+ // ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a
+ // no-op — these tests exercise the activator's skip/wait logic.
+ connMgr := NewConnMgr(&EngineConfig{}, status, store, nil)
+ return &dnsPeerActivator{
+ connMgr: connMgr,
+ peerStore: store,
+ status: status,
+ ctx: context.Background(),
+ }, status, store
+}
+
+func TestDNSPeerActivator_NilSafe(t *testing.T) {
+ var a *dnsPeerActivator
+ a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")})
+}
+
+// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state
+// (warm) path adds no latency: already-connected and unknown addresses never
+// enter the wait loop.
+func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) {
+ a, status, store := newTestDNSPeerActivator(t)
+
+ require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1"))
+ require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}))
+ store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ start := time.Now()
+ a.ActivatePeersByIP(ctx, []netip.Addr{
+ netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped
+ netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped
+ netip.MustParseAddr("100.64.0.99"), // unknown -> skipped
+ })
+ require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait")
+}
+
+// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop
+// returns as soon as a pending peer reports connected, well before the
+// per-query budget expires.
+func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) {
+ a, status, store := newTestDNSPeerActivator(t)
+
+ require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
+ store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
+
+ go func() {
+ time.Sleep(150 * time.Millisecond)
+ _ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ start := time.Now()
+ a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
+ elapsed := time.Since(start)
+
+ require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer")
+ require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline")
+}
+
+// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that
+// never connects releases the DNS response at the per-query budget instead of
+// blocking it indefinitely.
+func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) {
+ a, status, store := newTestDNSPeerActivator(t)
+
+ require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
+ store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
+
+ ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
+ defer cancel()
+
+ start := time.Now()
+ a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
+ elapsed := time.Since(start)
+
+ require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer")
+ require.Less(t, elapsed, 5*time.Second, "must not block past the budget")
+}
+
+// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer
+// with no connection object in the store is not waited on: there is nothing to
+// activate, so waiting could only ever time out.
+func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) {
+ a, status, _ := newTestDNSPeerActivator(t)
+
+ require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ start := time.Now()
+ a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
+ require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on")
+}
diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go
index 2e8ef84ab..b7e5a10e3 100644
--- a/client/internal/dnsfwd/forwarder.go
+++ b/client/internal/dnsfwd/forwarder.go
@@ -26,8 +26,23 @@ import (
const errResolveFailed = "failed to resolve query for domain=%s: %v"
const upstreamTimeout = 15 * time.Second
+// EDE info codes the forwarder emits on upstream failures so the querying
+// client can see the reason without inspecting this peer's logs. They live in
+// the RFC 8914 Private Use range (49152-65535); the Go resolver never exposes a
+// real upstream EDE here, so these cannot collide with a genuine code.
+const (
+ edeNetbirdUpstreamTimeout uint16 = 49152
+ edeNetbirdUpstreamFailure uint16 = 49153
+)
+
type resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
+ LookupMX(ctx context.Context, name string) ([]*net.MX, error)
+ LookupTXT(ctx context.Context, name string) ([]string, error)
+ LookupNS(ctx context.Context, name string) ([]*net.NS, error)
+ LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error)
+ LookupCNAME(ctx context.Context, host string) (string, error)
+ LookupAddr(ctx context.Context, addr string) ([]string, error)
}
type firewaller interface {
@@ -201,12 +216,6 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
resp := query.SetReply(query)
- network := resutil.NetworkForQtype(question.Qtype)
- if network == "" {
- resp.Rcode = dns.RcodeNotImplemented
- f.writeResponse(logger, w, resp, qname, startTime)
- return
- }
mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, "."))
if mostSpecificResId == "" {
@@ -218,9 +227,46 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout)
defer cancel()
+ reqHasEdns := query.IsEdns0() != nil
+
+ switch question.Qtype {
+ case dns.TypeA, dns.TypeAAAA:
+ f.handleAddressQuery(ctx, logger, w, resp, mostSpecificResId, matchingEntries, reqHasEdns, startTime)
+ case dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCNAME, dns.TypePTR:
+ f.handleRecordQuery(ctx, logger, w, resp, startTime)
+ default:
+ // The domain is routed here, so any other type is answered NODATA
+ // (NOERROR, empty answer) rather than falling back to a resolver that
+ // would poison the name with NXDOMAIN. The Extended DNS Error lets a
+ // client tell this capability-driven NODATA apart from an
+ // authoritative one. The OPT pseudo-record must not appear unless the
+ // query advertised EDNS0.
+ if reqHasEdns {
+ attachEDE(resp, dns.ExtendedErrorCodeNotSupported, "netbird forwarder: unsupported query type")
+ }
+ f.writeResponse(logger, w, resp, qname, startTime)
+ }
+}
+
+// handleAddressQuery resolves A/AAAA queries, programs the firewall sets and
+// resolved-IP state, and caches the answer for resilience on upstream failure.
+func (f *DNSForwarder) handleAddressQuery(
+ ctx context.Context,
+ logger *log.Entry,
+ w dns.ResponseWriter,
+ resp *dns.Msg,
+ mostSpecificResId route.ResID,
+ matchingEntries []*ForwarderEntry,
+ reqHasEdns bool,
+ startTime time.Time,
+) {
+ question := resp.Question[0]
+ qname := strings.ToLower(question.Name)
+
+ network := resutil.NetworkForQtype(question.Qtype)
result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype)
if result.Err != nil {
- f.handleDNSError(ctx, logger, w, question, resp, qname, result, startTime)
+ f.handleDNSError(ctx, logger, w, question, resp, qname, result, reqHasEdns, startTime)
return
}
@@ -231,6 +277,25 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
f.writeResponse(logger, w, resp, qname, startTime)
}
+// handleRecordQuery resolves non-address record types (MX, TXT, NS, SRV,
+// CNAME, PTR) through the host resolver. Missing records are answered NODATA so
+// the routed name is never poisoned with NXDOMAIN.
+func (f *DNSForwarder) handleRecordQuery(
+ ctx context.Context,
+ logger *log.Entry,
+ w dns.ResponseWriter,
+ resp *dns.Msg,
+ startTime time.Time,
+) {
+ question := resp.Question[0]
+ qname := strings.ToLower(question.Name)
+
+ records, rcode := resutil.LookupRecords(ctx, f.resolver, qname, question.Qtype, f.ttl)
+ resp.Rcode = rcode
+ resp.Answer = append(resp.Answer, records...)
+ f.writeResponse(logger, w, resp, qname, startTime)
+}
+
func (f *DNSForwarder) writeResponse(logger *log.Entry, w dns.ResponseWriter, resp *dns.Msg, qname string, startTime time.Time) {
if err := w.WriteMsg(resp); err != nil {
logger.Errorf("failed to write DNS response: %v", err)
@@ -333,6 +398,7 @@ func (f *DNSForwarder) handleDNSError(
resp *dns.Msg,
domain string,
result resutil.LookupResult,
+ reqHasEdns bool,
startTime time.Time,
) {
qType := question.Qtype
@@ -374,6 +440,10 @@ func (f *DNSForwarder) handleDNSError(
logger.Warnf(errResolveFailed, domain, result.Err)
}
+ if reqHasEdns {
+ attachEDE(resp, edeCodeFor(dnsErr), edeText(dnsErr))
+ }
+
f.writeResponse(logger, w, resp, domain, startTime)
}
@@ -414,3 +484,33 @@ func (f *DNSForwarder) getMatchingEntries(domain string) (route.ResID, []*Forwar
return selectedResId, matches
}
+
+// edeCodeFor maps an upstream lookup error to the NetBird EDE info code.
+func edeCodeFor(dnsErr *net.DNSError) uint16 {
+ if dnsErr != nil && dnsErr.IsTimeout {
+ return edeNetbirdUpstreamTimeout
+ }
+ return edeNetbirdUpstreamFailure
+}
+
+// edeText builds the EDE extra-text describing the class of upstream failure.
+// It deliberately omits the upstream server address, which may be an internal
+// resolver and is exposed to any client permitted to use the route; the full
+// detail stays in the forwarder's local log.
+func edeText(dnsErr *net.DNSError) string {
+ if dnsErr != nil && dnsErr.IsTimeout {
+ return "netbird forwarder: upstream timeout"
+ }
+ return "netbird forwarder: upstream failure"
+}
+
+// attachEDE adds an Extended DNS Error (RFC 8914) option to the response,
+// creating the OPT pseudo-record if the response does not already carry one.
+func attachEDE(resp *dns.Msg, code uint16, text string) {
+ opt := resp.IsEdns0()
+ if opt == nil {
+ resp.SetEdns0(dns.DefaultMsgSize, false)
+ opt = resp.IsEdns0()
+ }
+ opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
+}
diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go
index 7325ef8a7..c69a9166e 100644
--- a/client/internal/dnsfwd/forwarder_test.go
+++ b/client/internal/dnsfwd/forwarder_test.go
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/require"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
+ "github.com/netbirdio/netbird/client/internal/dns/resutil"
"github.com/netbirdio/netbird/client/internal/dns/test"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/route"
@@ -132,6 +133,41 @@ func (m *MockResolver) LookupNetIP(ctx context.Context, network, host string) ([
return args.Get(0).([]netip.Addr), args.Error(1)
}
+func (m *MockResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
+ args := m.Called(ctx, name)
+ recs, _ := args.Get(0).([]*net.MX)
+ return recs, args.Error(1)
+}
+
+func (m *MockResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
+ args := m.Called(ctx, name)
+ recs, _ := args.Get(0).([]string)
+ return recs, args.Error(1)
+}
+
+func (m *MockResolver) LookupNS(ctx context.Context, name string) ([]*net.NS, error) {
+ args := m.Called(ctx, name)
+ recs, _ := args.Get(0).([]*net.NS)
+ return recs, args.Error(1)
+}
+
+func (m *MockResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) {
+ args := m.Called(ctx, service, proto, name)
+ recs, _ := args.Get(1).([]*net.SRV)
+ return args.String(0), recs, args.Error(2)
+}
+
+func (m *MockResolver) LookupCNAME(ctx context.Context, host string) (string, error) {
+ args := m.Called(ctx, host)
+ return args.String(0), args.Error(1)
+}
+
+func (m *MockResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
+ args := m.Called(ctx, addr)
+ recs, _ := args.Get(0).([]string)
+ return recs, args.Error(1)
+}
+
func TestDNSForwarder_SubdomainAccessLogic(t *testing.T) {
tests := []struct {
name string
@@ -544,12 +580,15 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) {
}
func TestDNSForwarder_ResponseCodes(t *testing.T) {
+ // A type with no net.Resolver Lookup method (CAA) must answer NODATA
+ // (NOERROR, empty) rather than NXDOMAIN/NOTIMP to avoid poisoning the name.
tests := []struct {
name string
queryType uint16
queryDomain string
configured string
expectedCode int
+ expectEDE bool
description string
}{
{
@@ -561,28 +600,13 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
description: "RFC compliant REFUSED for unauthorized queries",
},
{
- name: "unsupported query type returns NOTIMP",
- queryType: dns.TypeMX,
+ name: "unsupported query type returns NODATA",
+ queryType: dns.TypeCAA,
queryDomain: "example.com",
configured: "example.com",
- expectedCode: dns.RcodeNotImplemented,
- description: "RFC compliant NOTIMP for unsupported types",
- },
- {
- name: "CNAME query returns NOTIMP",
- queryType: dns.TypeCNAME,
- queryDomain: "example.com",
- configured: "example.com",
- expectedCode: dns.RcodeNotImplemented,
- description: "CNAME queries not supported",
- },
- {
- name: "TXT query returns NOTIMP",
- queryType: dns.TypeTXT,
- queryDomain: "example.com",
- configured: "example.com",
- expectedCode: dns.RcodeNotImplemented,
- description: "TXT queries not supported",
+ expectedCode: dns.RcodeSuccess,
+ expectEDE: true,
+ description: "Unsupported types answer NODATA, not NXDOMAIN/NOTIMP",
},
}
@@ -598,6 +622,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
query := &dns.Msg{}
query.SetQuestion(dns.Fqdn(tt.queryDomain), tt.queryType)
+ query.SetEdns0(dns.DefaultMsgSize, false)
// Capture the written response
var writtenResp *dns.Msg
@@ -613,6 +638,288 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
// Check the response written to the writer
require.NotNil(t, writtenResp, "Expected response to be written")
assert.Equal(t, tt.expectedCode, writtenResp.Rcode, tt.description)
+ assert.Empty(t, writtenResp.Answer, "Non-address response should carry no answers")
+
+ if tt.expectEDE {
+ require.NotNil(t, writtenResp.IsEdns0(), "EDNS0 client should get an OPT in the reply")
+ assert.True(t, hasEDE(writtenResp, dns.ExtendedErrorCodeNotSupported),
+ "unsupported type NODATA should carry EDE Not Supported")
+ }
+ })
+ }
+}
+
+func hasEDE(m *dns.Msg, code uint16) bool {
+ opt := m.IsEdns0()
+ if opt == nil {
+ return false
+ }
+ for _, o := range opt.Option {
+ if ede, ok := o.(*dns.EDNS0_EDE); ok && ede.InfoCode == code {
+ return true
+ }
+ }
+ return false
+}
+
+func TestDNSForwarder_RecordQueries(t *testing.T) {
+ notFound := &net.DNSError{IsNotFound: true, Name: "example.com"}
+
+ t.Run("MX records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupMX", mock.Anything, "example.com.").
+ Return([]*net.MX{{Host: "mail.example.com.", Pref: 10}}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ mx, ok := resp.Answer[0].(*dns.MX)
+ require.True(t, ok, "answer should be an MX record")
+ assert.Equal(t, uint16(10), mx.Preference)
+ assert.Equal(t, "mail.example.com.", mx.Mx)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("missing MX is NODATA not NXDOMAIN", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ // A not-found cannot prove the name is absent (it may exist with only
+ // other record types), so it must answer NODATA, never NXDOMAIN.
+ mockResolver.On("LookupMX", mock.Anything, "example.com.").
+ Return(nil, notFound).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "missing record must be NODATA")
+ assert.Empty(t, resp.Answer)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("NS records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupNS", mock.Anything, "example.com.").
+ Return([]*net.NS{{Host: "ns1.example.com."}}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ ns, ok := resp.Answer[0].(*dns.NS)
+ require.True(t, ok, "answer should be an NS record")
+ assert.Equal(t, "ns1.example.com.", ns.Ns)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("missing NS is NODATA", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupNS", mock.Anything, "example.com.").
+ Return(nil, notFound).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ assert.Empty(t, resp.Answer)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("SRV records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com")
+
+ mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com.").
+ Return("", []*net.SRV{{Target: "sip.example.com.", Port: 5060, Priority: 10, Weight: 5}}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ srv, ok := resp.Answer[0].(*dns.SRV)
+ require.True(t, ok, "answer should be an SRV record")
+ assert.Equal(t, "sip.example.com.", srv.Target)
+ assert.Equal(t, uint16(5060), srv.Port)
+ assert.Equal(t, uint16(10), srv.Priority)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("missing SRV is NODATA", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com")
+
+ mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com.").
+ Return("", nil, notFound).Once()
+
+ resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ assert.Empty(t, resp.Answer)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("TXT records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupTXT", mock.Anything, "example.com.").
+ Return([]string{"v=spf1 -all"}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeTXT)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ txt, ok := resp.Answer[0].(*dns.TXT)
+ require.True(t, ok, "answer should be a TXT record")
+ assert.Equal(t, []string{"v=spf1 -all"}, txt.Txt)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("CNAME record is forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "www.example.com")
+
+ mockResolver.On("LookupCNAME", mock.Anything, "www.example.com.").
+ Return("target.example.com.", nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "www.example.com", dns.TypeCNAME)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok, "answer should be a CNAME record")
+ assert.Equal(t, "target.example.com.", cname.Target)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("CNAME equal to the name is NODATA", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ // No CNAME exists: LookupCNAME echoes the queried name back.
+ mockResolver.On("LookupCNAME", mock.Anything, "example.com.").
+ Return("example.com.", nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeCNAME)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ assert.Empty(t, resp.Answer, "self-referential CNAME means no CNAME record")
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("PTR record is forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "*.in-addr.arpa")
+
+ // The reverse name is parsed back to the address LookupAddr expects.
+ mockResolver.On("LookupAddr", mock.Anything, "1.2.3.4").
+ Return([]string{"host.example.com."}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "4.3.2.1.in-addr.arpa", dns.TypePTR)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ ptr, ok := resp.Answer[0].(*dns.PTR)
+ require.True(t, ok, "answer should be a PTR record")
+ assert.Equal(t, "host.example.com.", ptr.Ptr)
+ mockResolver.AssertExpectations(t)
+ })
+}
+
+func newRecordTestForwarder(t *testing.T, r resolver, configured string) *DNSForwarder {
+ t.Helper()
+ forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil)
+ forwarder.resolver = r
+
+ d, err := domain.FromString(configured)
+ require.NoError(t, err)
+ forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}})
+ return forwarder
+}
+
+func runRecordQuery(t *testing.T, forwarder *DNSForwarder, qname string, qtype uint16) *dns.Msg {
+ t.Helper()
+ query := &dns.Msg{}
+ query.SetQuestion(dns.Fqdn(qname), qtype)
+
+ mockWriter := &test.MockResponseWriter{}
+ forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
+
+ resp := mockWriter.GetLastResponse()
+ require.NotNil(t, resp, "expected response to be written")
+ return resp
+}
+
+func TestDNSForwarder_UpstreamFailureEDE(t *testing.T) {
+ tests := []struct {
+ name string
+ lookupErr error
+ reqEdns bool
+ wantEDE bool
+ wantCode uint16
+ wantTextHas string
+ }{
+ {
+ name: "timeout with edns0",
+ lookupErr: &net.DNSError{Err: "i/o timeout", Server: "10.0.0.53:53", IsTimeout: true},
+ reqEdns: true,
+ wantEDE: true,
+ wantCode: edeNetbirdUpstreamTimeout,
+ wantTextHas: "netbird forwarder: upstream timeout",
+ },
+ {
+ name: "server failure with edns0",
+ lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"},
+ reqEdns: true,
+ wantEDE: true,
+ wantCode: edeNetbirdUpstreamFailure,
+ wantTextHas: "netbird forwarder: upstream failure",
+ },
+ {
+ name: "no edns0 in request omits ede",
+ lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"},
+ reqEdns: false,
+ wantEDE: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil)
+ forwarder.resolver = mockResolver
+
+ d, err := domain.FromString("example.com")
+ require.NoError(t, err)
+ forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}})
+
+ mockResolver.On("LookupNetIP", mock.Anything, "ip4", "example.com.").
+ Return([]netip.Addr(nil), tt.lookupErr).Once()
+
+ query := &dns.Msg{}
+ query.SetQuestion("example.com.", dns.TypeA)
+ if tt.reqEdns {
+ query.SetEdns0(dns.DefaultMsgSize, false)
+ }
+
+ var writtenResp *dns.Msg
+ mockWriter := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ writtenResp = m
+ return nil
+ },
+ }
+
+ forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
+ mockResolver.AssertExpectations(t)
+
+ require.NotNil(t, writtenResp, "expected a response")
+ assert.Equal(t, dns.RcodeServerFailure, writtenResp.Rcode, "upstream failure must be SERVFAIL")
+
+ ede, ok := resutil.ExtractEDE(writtenResp)
+ if !tt.wantEDE {
+ assert.False(t, ok, "response must not carry EDE")
+ return
+ }
+ require.True(t, ok, "response must carry EDE")
+ assert.Equal(t, tt.wantCode, ede.InfoCode, "EDE info code")
+ assert.Contains(t, ede.ExtraText, tt.wantTextHas, "EDE extra-text")
+ assert.NotContains(t, ede.ExtraText, "10.0.0.53", "must not leak upstream server address")
})
}
}
diff --git a/client/internal/dnsfwd/manager.go b/client/internal/dnsfwd/manager.go
index c4c16cd3f..29ca0d247 100644
--- a/client/internal/dnsfwd/manager.go
+++ b/client/internal/dnsfwd/manager.go
@@ -101,7 +101,7 @@ func (m *Manager) Start(fwdEntries []*ForwarderEntry) error {
m.dnsForwarder = NewDNSForwarder(listenAddress, dnsTTL, m.firewall, m.statusRecorder, m.wgIface)
go func() {
- if err := m.dnsForwarder.Listen(fwdEntries); err != nil {
+ if err := m.dnsForwarder.Listen(fwdEntries); err != nil { //nolint:staticcheck
// todo handle close error if it is exists
log.Errorf("failed to start DNS forwarder, err: %v", err)
}
diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o
index 6e9cda44a..7433ad740 100644
Binary files a/client/internal/ebpf/ebpf/bpf_bpfeb.o and b/client/internal/ebpf/ebpf/bpf_bpfeb.o differ
diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.o b/client/internal/ebpf/ebpf/bpf_bpfel.o
index 6338f4774..779f43a00 100644
Binary files a/client/internal/ebpf/ebpf/bpf_bpfel.o and b/client/internal/ebpf/ebpf/bpf_bpfel.o differ
diff --git a/client/internal/ebpf/ebpf/manager_linux.go b/client/internal/ebpf/ebpf/manager_linux.go
index 7520a6387..64a3e5b54 100644
--- a/client/internal/ebpf/ebpf/manager_linux.go
+++ b/client/internal/ebpf/ebpf/manager_linux.go
@@ -2,17 +2,21 @@ package ebpf
import (
_ "embed"
+ "fmt"
"net"
"sync"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/unix"
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
const (
+ xdpProgName = "nb_xdp_prog"
+
mapKeyFeatures uint32 = 0
featureFlagWGProxy = 0b00000001
@@ -68,21 +72,50 @@ func (tf *GeneralManager) loadXdp() error {
return err
}
- // load pre-compiled programs into the kernel.
- err = loadBpfObjects(&tf.bpfObjs, nil)
+ // lo has no native XDP, so the program runs in generic mode. Unless it
+ // declares multi-buffer support the kernel must linearize every non-linear
+ // skb before running it. Loopback packets are up to 64 KB, so that is a
+ // contiguous GFP_ATOMIC allocation per packet, and when it fails the packet
+ // is dropped before the program runs, stalling local TCP connections.
+ // Multi-buffer XDP in generic mode requires kernel 6.3, so fall back to a
+ // plain attach when the kernel rejects it.
+ err = tf.attachXdp(iFace.Index, true)
+ if err == nil {
+ return nil
+ }
+ log.Debugf("failed to attach multi-buffer xdp program, retrying without it: %s", err)
+
+ return tf.attachXdp(iFace.Index, false)
+}
+
+func (tf *GeneralManager) attachXdp(iFaceIndex int, multiBuffer bool) error {
+ spec, err := loadBpf()
if err != nil {
- return err
+ return fmt.Errorf("load bpf spec: %w", err)
+ }
+
+ if multiBuffer {
+ prog, ok := spec.Programs[xdpProgName]
+ if !ok {
+ return fmt.Errorf("program %s not found in bpf spec", xdpProgName)
+ }
+ prog.Flags |= unix.BPF_F_XDP_HAS_FRAGS
+ }
+
+ if err := spec.LoadAndAssign(&tf.bpfObjs, nil); err != nil {
+ return fmt.Errorf("load bpf objects: %w", err)
}
tf.link, err = link.AttachXDP(link.XDPOptions{
Program: tf.bpfObjs.NbXdpProg,
- Interface: iFace.Index,
+ Interface: iFaceIndex,
})
-
if err != nil {
- _ = tf.bpfObjs.Close()
+ if closeErr := tf.bpfObjs.Close(); closeErr != nil {
+ log.Debugf("failed to close bpf objects after xdp attach error: %s", closeErr)
+ }
tf.link = nil
- return err
+ return fmt.Errorf("attach xdp: %w", err)
}
return nil
}
diff --git a/client/internal/ebpf/ebpf/src/dns_fwd.c b/client/internal/ebpf/ebpf/src/dns_fwd.c
index 5f3fbcc32..9f8de2001 100644
--- a/client/internal/ebpf/ebpf/src/dns_fwd.c
+++ b/client/internal/ebpf/ebpf/src/dns_fwd.c
@@ -52,11 +52,14 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
udp->dest = dns_port;
+ // Clear the now-stale checksum; zero means "not computed" for IPv4.
+ udp->check = 0;
return XDP_PASS;
}
if (udp->source == dns_port && ip->saddr == dns_ip) {
udp->source = GENERAL_DNS_PORT;
+ udp->check = 0;
return XDP_PASS;
}
diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c
index 88fea65cf..5e7474928 100644
--- a/client/internal/ebpf/ebpf/src/wg_proxy.c
+++ b/client/internal/ebpf/ebpf/src/wg_proxy.c
@@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
__be16 new_dst_port = htons(proxy_port);
udp->dest = new_dst_port;
udp->source = new_src_port;
+
+ // The ports are covered by the UDP checksum. This is an IPv4 loopback hop
+ // and the payload is already integrity-protected, so clear the checksum (a
+ // zero UDP checksum means "not computed" for IPv4) rather than leave a
+ // stale value the kernel would drop as UDP_CSUM.
+ udp->check = 0;
return XDP_PASS;
}
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 42712da92..7f3f8185f 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -23,6 +23,7 @@ import (
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+ "github.com/netbirdio/netbird/client/anonymize"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall"
"github.com/netbirdio/netbird/client/firewall/firewalld"
@@ -40,6 +41,7 @@ import (
"github.com/netbirdio/netbird/client/internal/dnsfwd"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/ingressgw"
+ "github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/metrics"
"github.com/netbirdio/netbird/client/internal/netflow"
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
@@ -57,13 +59,17 @@ import (
"github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec"
+ "github.com/netbirdio/netbird/client/netstate"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/route"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
+ sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
+ nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+ types "github.com/netbirdio/netbird/shared/management/types"
"github.com/netbirdio/netbird/shared/netiputil"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
@@ -82,10 +88,18 @@ const (
PeerConnectionTimeoutMax = 45000 // ms
PeerConnectionTimeoutMin = 30000 // ms
disableAutoUpdate = "disabled"
+
+ // systemInfoTimeout bounds how long the sync loop waits for system info / posture
+ // check gathering. The gathering runs uncancellable system calls (process scan,
+ // exec, os.Stat); without this bound a single stuck call freezes handleSync, and
+ // thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes).
+ systemInfoTimeout = 15 * time.Second
)
var ErrResetConnection = fmt.Errorf("reset connection")
+var ErrEngineAlreadyStarted = errors.New("engine already started")
+
type EngineConfig struct {
WgPort int
WgIfaceName string
@@ -138,8 +152,11 @@ type EngineConfig struct {
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
+ SyncMessageVersion *int
- LazyConnectionEnabled bool
+ // LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to
+ // the env var and management feature flag.
+ LazyConnection lazyconn.State
MTU uint16
@@ -164,6 +181,10 @@ type EngineServices struct {
StateManager *statemanager.Manager
UpdateManager *updater.Manager
ClientMetrics *metrics.ClientMetrics
+ MetricsCtx context.Context
+ // NetState gates the reconnection loops on OS-reported network
+ // availability; nil disables gating.
+ NetState *netstate.State
}
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
@@ -187,6 +208,10 @@ type Engine struct {
config *EngineConfig
mobileDep MobileDependency
+ // netState gates the peer reconnection guards on OS-reported network
+ // availability; nil disables gating.
+ netState *netstate.State
+
// STUNs is a list of STUN servers used by ICE
STUNs []*stun.URI
// TURNs is a list of STUN servers used by ICE
@@ -199,6 +224,8 @@ type Engine struct {
ctx context.Context
cancel context.CancelFunc
+ started bool
+
wgInterface WGIface
udpMux *udpmux.UniversalUDPMuxDefault
@@ -206,6 +233,13 @@ type Engine struct {
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
networkSerial uint64
+ // latestComponents is the most-recent NetworkMapComponents decoded from
+ // a NetworkMapEnvelope (capability=3 peers only). Held alongside the
+ // NetworkMap that Calculate() produced from it so future incremental
+ // updates have a base to apply changes against. nil for legacy-format
+ // peers. Guarded by syncMsgMux.
+ latestComponents *types.NetworkMapComponents
+
networkMonitor *networkmonitor.NetworkMonitor
sshServer sshServer
@@ -254,11 +288,26 @@ type Engine struct {
// clientMetrics collects and pushes metrics
clientMetrics *metrics.ClientMetrics
+ metricsCtx context.Context
jobExecutor *jobexec.Executor
jobExecutorWG sync.WaitGroup
exposeManager *expose.Manager
+
+ sessionWatcher sessionDeadlineWatcher
+}
+
+// sessionDeadlineWatcher is the engine-facing surface of the SSO session
+// expiry watcher. The concrete implementation (sessionwatch.Watcher) is wired
+// in via newSessionWatcher, which is build-tagged so the js/wasm build links a
+// no-op stub instead of pulling the full sessionwatch package (and its timer
+// machinery) into the binary — the wasm client never runs the engine's
+// session-warning flow.
+type sessionDeadlineWatcher interface {
+ Update(deadline time.Time) error
+ Dismiss()
+ Close()
}
// Peer is an instance of the Connection Peer
@@ -279,9 +328,15 @@ func NewEngine(
services EngineServices,
mobileDep MobileDependency,
) *Engine {
+ // The engine is single-use: a fresh instance is built per connection
+ // cycle (see Client.run), so the run context is created once here rather
+ // than in Start.
+ ctx, cancel := context.WithCancel(clientCtx)
engine := &Engine{
clientCtx: clientCtx,
clientCancel: clientCancel,
+ ctx: ctx,
+ cancel: cancel,
signal: services.SignalClient,
signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey),
mgmClient: services.MgmClient,
@@ -290,6 +345,7 @@ func NewEngine(
syncMsgMux: &sync.Mutex{},
config: config,
mobileDep: mobileDep,
+ netState: services.NetState,
STUNs: []*stun.URI{},
TURNs: []*stun.URI{},
networkSerial: 0,
@@ -300,9 +356,21 @@ func NewEngine(
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
jobExecutor: jobexec.NewExecutor(),
clientMetrics: services.ClientMetrics,
+ metricsCtx: services.MetricsCtx,
updateManager: services.UpdateManager,
syncStoreDir: config.StateDir,
}
+ // sessionWatcher keeps the SubscribeStatus consumers in sync with the
+ // session expiry deadline. Deadline-change ticks come for free via
+ // Status.SetSessionExpiresAt; the watcher exists to push a wake-up at
+ // T-WarningLead and T-FinalWarningLead so the UI repaints the remaining
+ // time / warning state even when nothing else changed, and to publish
+ // two SystemEvents (the warning composition lives in sessionwatch so
+ // the wire format stays owned by one package):
+ // - T-WarningLead → interactive "Extend now / Dismiss" notification
+ // - T-FinalWarningLead → auto-opened SessionAboutToExpire dialog,
+ // suppressed when the user dismissed the earlier warning
+ engine.sessionWatcher = newSessionWatcher(engine.statusRecorder)
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
return engine
@@ -314,8 +382,34 @@ func (e *Engine) Stop() error {
log.Debugf("tried stopping engine that is nil")
return nil
}
+ e.cancel()
e.syncMsgMux.Lock()
+ e.stopLocked()
+
+ e.syncMsgMux.Unlock()
+
+ timeout := e.calculateShutdownTimeout()
+ log.Debugf("waiting for goroutines to finish with timeout: %v", timeout)
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil {
+ log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout)
+ }
+
+ log.Infof("stopped Netbird Engine")
+
+ return nil
+}
+
+// stopLocked tears down everything Start may have brought up, in the order
+// teardown requires (DNS before the interface goes down, flow manager after).
+// The caller must hold syncMsgMux. It is shared by Stop and by Start's failure
+// path, so a partially-initialized engine is cleaned up the same way; every
+// step is nil-guarded. It does not wait on shutdownWg — the caller does that
+// after releasing the lock, since the goroutines also take syncMsgMux.
+func (e *Engine) stopLocked() {
if e.connMgr != nil {
e.connMgr.Close()
}
@@ -343,6 +437,10 @@ func (e *Engine) Stop() error {
e.srWatcher.Close()
}
+ if e.sessionWatcher != nil {
+ e.sessionWatcher.Close()
+ }
+
if e.updateManager != nil {
e.updateManager.SetDownloadOnly()
}
@@ -366,10 +464,6 @@ func (e *Engine) Stop() error {
// so dbus and friends don't complain because of a missing interface
e.stopDNSServer()
- if e.cancel != nil {
- e.cancel()
- }
-
e.jobExecutorWG.Wait() // block until job goroutines finish
e.close()
@@ -388,21 +482,6 @@ func (e *Engine) Stop() error {
if err := e.stateManager.PersistState(context.Background()); err != nil {
log.Errorf("failed to persist state: %v", err)
}
-
- e.syncMsgMux.Unlock()
-
- timeout := e.calculateShutdownTimeout()
- log.Debugf("waiting for goroutines to finish with timeout: %v", timeout)
- shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
- defer cancel()
-
- if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil {
- log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout)
- }
-
- log.Infof("stopped Netbird Engine")
-
- return nil
}
// calculateShutdownTimeout returns shutdown timeout: 10s base + 100ms per peer, capped at 30s.
@@ -440,18 +519,38 @@ func waitWithContext(ctx context.Context, wg *sync.WaitGroup) error {
// Start creates a new WireGuard tunnel interface and listens to events from Signal and Management services
// Connections to remote peers are not established here.
// However, they will be established once an event with a list of peers to connect to will be received from Management Service
-func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) error {
+func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) (err error) {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
- if err := iface.ValidateMTU(e.config.MTU); err != nil {
+ // The engine is single-use. Reject a duplicate start and a start on an
+ // already-stopped engine (run context cancelled).
+ if e.started {
+ return ErrEngineAlreadyStarted
+ }
+
+ if ctxErr := e.ctx.Err(); ctxErr != nil {
+ return fmt.Errorf("engine already stopped: %w", ctxErr)
+ }
+
+ e.started = true
+
+ // Tear down any partially-initialized state on a failed start. Cancel the
+ // run context first so goroutines started before the failure (connMgr,
+ // srWatcher, monitors) unwind, then stopLocked mirrors Stop's teardown (we
+ // already hold syncMsgMux), cleaning up route/DNS/flow/state managers too,
+ // not just what close() covers.
+ defer func() {
+ if err != nil {
+ e.cancel()
+ e.stopLocked()
+ }
+ }()
+
+ if err = iface.ValidateMTU(e.config.MTU); err != nil {
return fmt.Errorf("invalid MTU configuration: %w", err)
}
- if e.cancel != nil {
- e.cancel()
- }
- e.ctx, e.cancel = context.WithCancel(e.clientCtx)
e.exposeManager = expose.NewManager(e.ctx, e.mgmClient)
wgIface, err := e.newWgIface()
@@ -473,7 +572,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
} else {
log.Infof("running rosenpass in strict mode")
}
- e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName)
+ e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey)
if err != nil {
return fmt.Errorf("create rosenpass manager: %w", err)
}
@@ -483,15 +582,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
}
e.stateManager.Start()
- initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
+ dnsServer, err := e.newDnsServer()
if err != nil {
- e.close()
- return fmt.Errorf("read initial settings: %w", err)
- }
-
- dnsServer, err := e.newDnsServer(dnsConfig)
- if err != nil {
- e.close()
return fmt.Errorf("create dns server: %w", err)
}
e.dnsServer = dnsServer
@@ -508,10 +600,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
WGInterface: e.wgInterface,
StatusRecorder: e.statusRecorder,
RelayManager: e.relayManager,
- InitialRoutes: initialRoutes,
StateManager: e.stateManager,
DNSServer: dnsServer,
- DNSFeatureFlag: dnsFeatureFlag,
PeerStore: e.peerStore,
DisableClientRoutes: e.config.DisableClientRoutes,
DisableServerRoutes: e.config.DisableServerRoutes,
@@ -526,7 +616,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
if err = e.wgInterfaceCreate(); err != nil {
log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error())
- e.close()
return fmt.Errorf("create wg interface: %w", err)
}
@@ -535,7 +624,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
}
if err := e.createFirewall(); err != nil {
- e.close()
return err
}
@@ -547,7 +635,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.udpMux, err = e.wgInterface.Up()
if err != nil {
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
- e.close()
return fmt.Errorf("up wg interface: %w", err)
}
@@ -572,21 +659,37 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.acl = acl.NewDefaultManager(e.firewall)
}
- err = e.dnsServer.Initialize()
- if err != nil {
- e.close()
+ if err := e.dnsServer.Initialize(); err != nil {
return fmt.Errorf("initialize dns server: %w", err)
}
iceCfg := e.createICEConfig()
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
+ e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error {
+ if e.routeManager == nil {
+ return nil
+ }
+ return e.routeManager.ReconcilePeerAllowedIPs(peerKey)
+ })
e.connMgr.Start(e.ctx)
+ // Wire DNS-time lazy-connection warm-up now that the connection manager
+ // exists (it does not at DNS-server construction time). A DNS answer that
+ // points at an idle peer then wakes it before the client's first request.
+ e.dnsServer.SetPeerActivator(&dnsPeerActivator{
+ connMgr: e.connMgr,
+ peerStore: e.peerStore,
+ status: e.statusRecorder,
+ ctx: e.ctx,
+ })
+
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
e.srWatcher.Start(peer.IsForceRelayed())
- e.receiveSignalEvents()
+ if err = e.receiveSignalEvents(); err != nil {
+ return err
+ }
e.receiveManagementEvents()
e.receiveJobEvents()
@@ -638,7 +741,6 @@ func (e *Engine) createFirewall() error {
func (e *Engine) initFirewall() error {
if err := e.routeManager.SetFirewall(e.firewall); err != nil {
- e.close()
return fmt.Errorf("set firewall: %w", err)
}
@@ -864,6 +966,16 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate
e.updateManager.SetVersion(autoUpdateSettings.Version, autoUpdateSettings.AlwaysUpdate)
}
+// phase times a sync sub-phase: it returns a function that records the elapsed
+// duration when called. Starting the timer at the call site keeps inter-phase
+// glue code out of the measurement.
+func (e *Engine) phase(name string) func() {
+ start := time.Now()
+ return func() {
+ e.clientMetrics.RecordSyncPhase(e.ctx, name, time.Since(start))
+ }
+}
+
func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
started := time.Now()
defer func() {
@@ -879,29 +991,86 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return e.ctx.Err()
}
- if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
- e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
+ e.ApplySessionDeadline(update.GetSessionExpiresAt())
+
+ // Envelope sync responses carry PeerConfig at the top level; legacy
+ // NetworkMap syncs carry it under NetworkMap.PeerConfig.
+ if pc := update.GetPeerConfig(); pc != nil {
+ e.handleAutoUpdateVersion(pc.GetAutoUpdate())
+ } else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil {
+ e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate())
}
- if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
+ done := e.phase("netbird_config")
+ err := e.updateNetbirdConfig(update.GetNetbirdConfig())
+ done()
+ if err != nil {
return err
}
+ // Decode the network map from either the components envelope or the
+ // legacy proto.NetworkMap before the posture-check gating below, so the
+ // "is there a network map" decision covers both wire shapes.
+ var (
+ nm *mgmProto.NetworkMap
+ components *types.NetworkMapComponents
+ )
+ if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) {
+ // Components-format peer: decode the envelope back to typed
+ // components, run Calculate() locally, and convert to the wire
+ // NetworkMap shape the rest of the engine consumes. Components are
+ // retained so future incremental updates can apply deltas instead
+ // of doing a full reconstruction.
+ envelope := update.GetNetworkMapEnvelope()
+ if envelope == nil {
+ return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing")
+ }
+
+ localKey := e.config.WgPrivateKey.PublicKey().String()
+ dnsName := ""
+ if pc := update.GetPeerConfig(); pc != nil {
+ // PeerConfig.Fqdn = "." — extract the
+ // shared domain by stripping the peer's own label prefix. Falls
+ // back to empty if the FQDN doesn't have the expected shape.
+ dnsName = extractDNSDomainFromFQDN(pc.GetFqdn())
+ }
+ result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName)
+ if err != nil {
+ return fmt.Errorf("decode network map envelope: %w", err)
+ }
+ nm = result.NetworkMap
+ components = result.Components
+ } else {
+ nm = update.GetNetworkMap()
+ }
+
// Posture checks are bound to the network map presence:
// NetworkMap != nil, checks present -> apply the received checks
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
// leave the previously applied checks untouched
- nm := update.GetNetworkMap()
if nm == nil {
return nil
}
- if err := e.updateChecksIfNew(update.Checks); err != nil {
+ done = e.phase("checks")
+ err = e.updateChecksIfNew(update.Checks)
+ done()
+ if err != nil {
return err
}
+ done = e.phase("persist")
+ // Only retain the components view when the server sent the envelope
+ // path. A legacy proto.NetworkMap means components == nil; writing it
+ // here would clobber a previously-cached snapshot, breaking the
+ // incremental-delta base on a future envelope sync.
+ if components != nil {
+ e.latestComponents = components
+ }
+
e.persistSyncResponse(update)
+ done()
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -913,6 +1082,19 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
+// extractDNSDomainFromFQDN returns the trailing dotted domain part of the
+// receiving peer's FQDN — the same value the management server fills as
+// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" →
+// "netbird.cloud". An empty string is returned for unrecognized formats.
+func extractDNSDomainFromFQDN(fqdn string) string {
+ for i := 0; i < len(fqdn); i++ {
+ if fqdn[i] == '.' && i+1 < len(fqdn) {
+ return fqdn[i+1:]
+ }
+ }
+ return ""
+}
+
// updateNetbirdConfig applies the management-provided NetBird configuration:
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
// which is the case for sync updates carrying only a network map.
@@ -942,6 +1124,8 @@ func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error {
return fmt.Errorf("handle the flow configuration: %w", err)
}
+ e.handleMetricsUpdate(wCfg.GetMetrics())
+
if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
log.Warnf("Failed to update DNS server config: %v", err)
}
@@ -1011,6 +1195,14 @@ func (e *Engine) handleFlowUpdate(config *mgmProto.FlowConfig) error {
return e.flowManager.Update(flowConfig)
}
+func (e *Engine) handleMetricsUpdate(config *mgmProto.MetricsConfig) {
+ if config == nil {
+ return
+ }
+ log.Infof("received metrics configuration from management: enabled=%v", config.GetEnabled())
+ e.clientMetrics.UpdatePushFromMgm(e.metricsCtx, config.GetEnabled())
+}
+
func toFlowLoggerConfig(config *mgmProto.FlowConfig) (*nftypes.FlowConfig, error) {
if config.GetInterval() == nil {
return nil, errors.New("flow interval is nil")
@@ -1035,11 +1227,22 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
}
e.checks = checks
- info, err := system.GetInfoWithChecks(e.ctx, checks)
- if err != nil {
- log.Warnf("failed to get system info with checks: %v", err)
- info = system.GetInfo(e.ctx)
+ info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
+ if !ok {
+ // Gathering timed out; skip the meta sync this cycle rather than blocking the
+ // sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
+ return nil
}
+ e.applyInfoFlags(info)
+
+ if err := e.mgmClient.SyncMeta(info); err != nil {
+ return fmt.Errorf("could not sync meta: error %s", err)
+ }
+ return nil
+}
+
+// applyInfoFlags sets the engine's config-derived feature flags on the gathered system info.
+func (e *Engine) applyInfoFlags(info *system.Info) {
info.SetFlags(
e.config.RosenpassEnabled,
e.config.RosenpassPermissive,
@@ -1051,19 +1254,27 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
- e.config.LazyConnectionEnabled,
+ e.config.SyncMessageVersion,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
e.config.EnableSSHRemotePortForwarding,
e.config.DisableSSHAuth,
)
+}
- if err := e.mgmClient.SyncMeta(info); err != nil {
- log.Errorf("could not sync meta: error %s", err)
- return err
+// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
+// can be excluded from the reported network addresses; the interface coming and
+// going otherwise churns the peer meta on the management server.
+func (e *Engine) overlayAddresses() []netip.Addr {
+ var ips []netip.Addr
+ if e.config.WgAddr.IP.IsValid() {
+ ips = append(ips, e.config.WgAddr.IP)
}
- return nil
+ if e.config.WgAddr.HasIPv6() {
+ ips = append(ips, e.config.WgAddr.IPv6)
+ }
+ return ips
}
func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
@@ -1178,12 +1389,13 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
ClientMetrics: e.clientMetrics,
DaemonVersion: version.NetbirdVersion(),
RefreshStatus: func() {
- e.RunHealthProbes(true)
+ e.RunHealthProbes(e.ctx, true)
},
}
bundleJobParams := debug.BundleConfig{
Anonymize: params.Anonymize,
+ AnonymizeLevel: anonymize.ParseLevel(params.AnonymizeLevel),
IncludeSystemInfo: true,
LogFileCount: uint32(params.LogFileCount),
}
@@ -1209,31 +1421,15 @@ func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
- info, err := system.GetInfoWithChecks(e.ctx, e.checks)
- if err != nil {
- log.Warnf("failed to get system info with checks: %v", err)
+ info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
+ if !ok {
+ // Gathering timed out; connect the stream with base info so management
+ // connectivity still comes up rather than blocking here.
info = system.GetInfo(e.ctx)
}
- info.SetFlags(
- e.config.RosenpassEnabled,
- e.config.RosenpassPermissive,
- &e.config.ServerSSHAllowed,
- e.config.DisableClientRoutes,
- e.config.DisableServerRoutes,
- e.config.DisableDNS,
- e.config.DisableFirewall,
- e.config.BlockLANAccess,
- e.config.BlockInbound,
- e.config.DisableIPv6,
- e.config.LazyConnectionEnabled,
- e.config.EnableSSHRoot,
- e.config.EnableSSHSFTP,
- e.config.EnableSSHLocalPortForwarding,
- e.config.EnableSSHRemotePortForwarding,
- e.config.DisableSSHAuth,
- )
+ e.applyInfoFlags(info)
- err = e.mgmClient.Sync(e.ctx, info, e.handleSync)
+ err := e.mgmClient.Sync(e.ctx, info, e.handleSync)
if err != nil {
// happens if management is unavailable for a long time.
// We want to cancel the operation of the whole client
@@ -1326,13 +1522,16 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
dnsConfig := toDNSConfig(protoDNSConfig, e.wgInterface.Address())
+ done := e.phase("dns_server")
if err := e.dnsServer.UpdateDNSServer(serial, dnsConfig); err != nil {
log.Errorf("failed to update dns server, err: %v", err)
}
+ done()
e.routeManager.SetDNSForwarderPort(dnsConfig.ForwarderPort)
// apply routes first, route related actions might depend on routing being enabled
+ done = e.phase("routes_classify")
routes := toRoutes(networkMap.GetRoutes())
serverRoutes, clientRoutes := e.routeManager.ClassifyRoutes(routes)
@@ -1341,29 +1540,60 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
e.connMgr.UpdateRouteHAMap(clientRoutes)
log.Debugf("updated lazy connection manager with %d HA groups", len(clientRoutes))
}
+ done()
+ done = e.phase("routes_apply")
dnsRouteFeatureFlag := toDNSFeatureFlag(networkMap)
if err := e.routeManager.UpdateRoutes(serial, serverRoutes, clientRoutes, dnsRouteFeatureFlag); err != nil {
log.Errorf("failed to update routes: %v", err)
}
+ done()
+ done = e.phase("filtering")
if e.acl != nil {
e.acl.ApplyFiltering(networkMap, dnsRouteFeatureFlag)
}
+ done()
+ done = e.phase("dns_forwarder")
fwdEntries := toRouteDomains(e.config.WgPrivateKey.PublicKey().String(), routes)
e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries)
+ done()
// Ingress forward rules
+ done = e.phase("forward_rules")
forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules())
if err != nil {
log.Errorf("failed to update forward rules, err: %v", err)
}
+ done()
log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers()))
+ done = e.phase("offline_peers")
e.updateOfflinePeers(networkMap.GetOfflinePeers())
+ done()
+ remotePeers, err := e.reconcilePeers(networkMap)
+ if err != nil {
+ return err
+ }
+
+ // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
+ done = e.phase("lazy_exclude")
+ excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
+ e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
+ done()
+
+ e.networkSerial = serial
+
+ return nil
+}
+
+// reconcilePeers applies the remote peer list from the network map (removing,
+// modifying and adding peers, then updating SSH config) and returns the remote
+// peers with our own peer filtered out, for use by later sync steps.
+func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) {
// Filter out own peer from the remote peers list
localPubKey := e.config.WgPrivateKey.PublicKey().String()
remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers()))
@@ -1378,42 +1608,43 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
err := e.removeAllPeers()
e.statusRecorder.FinishPeerListModifications()
if err != nil {
- return err
+ return nil, err
}
- } else {
- err := e.removePeers(remotePeers)
- if err != nil {
- return err
- }
-
- err = e.modifyPeers(remotePeers)
- if err != nil {
- return err
- }
-
- err = e.addNewPeers(remotePeers)
- if err != nil {
- return err
- }
-
- e.statusRecorder.FinishPeerListModifications()
-
- e.updatePeerSSHHostKeys(remotePeers)
-
- if err := e.updateSSHClientConfig(remotePeers); err != nil {
- log.Warnf("failed to update SSH client config: %v", err)
- }
-
- e.updateSSHServerAuth(networkMap.GetSshAuth())
+ return remotePeers, nil
}
- // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
- excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
- e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
+ done := e.phase("removed_peers")
+ err := e.removePeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
- e.networkSerial = serial
+ done = e.phase("modified_peers")
+ err = e.modifyPeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
- return nil
+ done = e.phase("added_peers")
+ err = e.addNewPeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
+
+ e.statusRecorder.FinishPeerListModifications()
+
+ e.updatePeerSSHHostKeys(remotePeers)
+
+ if err := e.updateSSHClientConfig(remotePeers); err != nil {
+ log.Warnf("failed to update SSH client config: %v", err)
+ }
+
+ e.updateSSHServerAuth(networkMap.GetSshAuth())
+
+ return remotePeers, nil
}
func toDNSFeatureFlag(networkMap *mgmProto.NetworkMap) bool {
@@ -1671,7 +1902,8 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
Addr: e.getRosenpassAddr(),
PermissiveMode: e.config.RosenpassPermissive,
},
- ICEConfig: e.createICEConfig(),
+ ICEConfig: e.createICEConfig(),
+ NetworkState: e.netState,
}
serviceDependencies := peer.ServiceDependencies{
@@ -1698,7 +1930,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
}
// receiveSignalEvents connects to the Signal Service event stream to negotiate connection with remote peers
-func (e *Engine) receiveSignalEvents() {
+func (e *Engine) receiveSignalEvents() error {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
@@ -1769,7 +2001,12 @@ func (e *Engine) receiveSignalEvents() {
}
}()
- e.signal.WaitStreamConnected()
+ // todo: consider to remove this blocker. I do not see benefit to block the Start operations
+ e.signal.WaitStreamConnected(e.ctx)
+ if err := e.ctx.Err(); err != nil {
+ return fmt.Errorf("wait for signal stream: %w", err)
+ }
+ return nil
}
func (e *Engine) parseNATExternalIPMappings() []string {
@@ -1870,42 +2107,6 @@ func (e *Engine) close() {
}
}
-func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
- if runtime.GOOS != "android" {
- // nolint:nilnil
- return nil, nil, false, nil
- }
-
- info := system.GetInfo(e.ctx)
- info.SetFlags(
- e.config.RosenpassEnabled,
- e.config.RosenpassPermissive,
- &e.config.ServerSSHAllowed,
- e.config.DisableClientRoutes,
- e.config.DisableServerRoutes,
- e.config.DisableDNS,
- e.config.DisableFirewall,
- e.config.BlockLANAccess,
- e.config.BlockInbound,
- e.config.DisableIPv6,
- e.config.LazyConnectionEnabled,
- e.config.EnableSSHRoot,
- e.config.EnableSSHSFTP,
- e.config.EnableSSHLocalPortForwarding,
- e.config.EnableSSHRemotePortForwarding,
- e.config.DisableSSHAuth,
- )
-
- netMap, err := e.mgmClient.GetNetworkMap(info)
- if err != nil {
- return nil, nil, false, err
- }
- routes := toRoutes(netMap.GetRoutes())
- dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address())
- dnsFeatureFlag := toDNSFeatureFlag(netMap)
- return routes, &dnsCfg, dnsFeatureFlag, nil
-}
-
func (e *Engine) newWgIface() (*iface.WGIface, error) {
transportNet, err := e.newStdNet()
if err != nil {
@@ -1940,7 +2141,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
func (e *Engine) wgInterfaceCreate() (err error) {
switch runtime.GOOS {
case "android":
- err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
+ err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
case "ios":
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
if e.config.WgAddr.HasIPv6() {
@@ -1953,7 +2154,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
return err
}
-func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
+func (e *Engine) newDnsServer() (dns.Server, error) {
// due to tests where we are using a mocked version of the DNS server
if e.dnsServer != nil {
return e.dnsServer, nil
@@ -1965,7 +2166,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
e.ctx,
e.wgInterface,
e.mobileDep.HostDNSAddresses,
- *dnsConfig,
+ nbdns.Config{},
e.mobileDep.NetworkChangeListener,
e.statusRecorder,
e.config.DisableDNS,
@@ -2081,7 +2282,20 @@ func (e *Engine) getRosenpassAddr() string {
// RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services
// and updates the status recorder with the latest states.
-func (e *Engine) RunHealthProbes(waitForResult bool) bool {
+//
+// ctx scopes the (potentially slow) STUN/TURN probing: a caller that gives up —
+// e.g. a Status RPC whose client disconnected — cancels its ctx and the probe
+// returns instead of running to its per-component timeout. The engine's own
+// lifetime ctx still applies independently, so an engine shutdown aborts the
+// probe even if the caller's ctx is context.Background().
+func (e *Engine) RunHealthProbes(ctx context.Context, waitForResult bool) bool {
+ // Tie the caller's ctx to the engine lifetime: either cancelling aborts
+ // the probe below.
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ stop := context.AfterFunc(e.ctx, cancel)
+ defer stop()
+
e.syncMsgMux.Lock()
signalHealthy := e.signal.IsHealthy()
@@ -2104,9 +2318,9 @@ func (e *Engine) RunHealthProbes(waitForResult bool) bool {
if runtime.GOOS != "js" {
var results []relay.ProbeResult
if waitForResult {
- results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns)
+ results = e.probeStunTurn.ProbeAllWaitResult(ctx, stuns, turns)
} else {
- results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns)
+ results = e.probeStunTurn.ProbeAll(ctx, stuns, turns)
}
e.statusRecorder.UpdateRelayStates(results)
@@ -2358,7 +2572,7 @@ func (e *Engine) SetCapture(pc device.PacketCapture) error {
}
afc := capture.NewAFPacketCapture(intf.Name(), sess)
- if err := afc.Start(); err != nil {
+ if err := afc.Start(); err != nil { //nolint:staticcheck // always errors on non-Linux builds
return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err)
}
e.afpacketCapture = afc
@@ -2449,13 +2663,14 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal
func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool {
excludedPeers := make(map[string]bool)
+
+ // Ingress forward targets: inbound forwarded traffic is initiated remotely and
+ // cannot wake a lazy connection, so the peer routing the target must stay
+ // permanently connected. AllowedIPs are already parsed on the peer conn, so
+ // reuse those typed prefixes instead of re-parsing the network map strings.
for _, r := range rules {
- ip := r.TranslatedAddress
for _, p := range peers {
- for _, allowedIP := range p.GetAllowedIps() {
- if allowedIP != ip.String() {
- continue
- }
+ if e.peerRoutesAddr(p, r.TranslatedAddress) {
log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey())
excludedPeers[p.GetWgPubKey()] = true
}
@@ -2465,6 +2680,27 @@ func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers
return excludedPeers
}
+// peerRoutesAddr reports whether the peer is a router for addr, matched against
+// the peer's already-parsed AllowedIPs from the store (the same typed value the
+// lazy manager consumes) rather than re-parsing the network map strings.
+func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool {
+ prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey())
+ if !ok {
+ return false
+ }
+ return prefixesContain(prefixes, addr)
+}
+
+// prefixesContain reports whether addr falls within any of the prefixes.
+func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool {
+ for _, prefix := range prefixes {
+ if prefix.Contains(addr) {
+ return true
+ }
+ }
+ return false
+}
+
// isChecksEqual checks if two slices of checks are equal.
func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
normalize := func(checks []*mgmProto.Checks) []string {
diff --git a/client/internal/engine_authsession.go b/client/internal/engine_authsession.go
new file mode 100644
index 000000000..725c0903f
--- /dev/null
+++ b/client/internal/engine_authsession.go
@@ -0,0 +1,108 @@
+package internal
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
+ cProto "github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/client/system"
+)
+
+// ApplySessionDeadline propagates the absolute SSO session deadline carried on
+// LoginResponse / SyncResponse to both the watcher (for the edge-triggered
+// warning) and the status recorder (for the SubscribeStatus / Status RPC
+// snapshot the UI consumes).
+//
+// The wire field is 3-state:
+// - nil → snapshot carries no info; keep the
+// previously-anchored deadline (no-op)
+// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is
+// disabled; clear both sinks
+// - valid timestamp → new deadline; arm watcher, expose on
+// status recorder
+//
+// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected
+// value is treated as a clear on both sinks: the alternative — leaving the
+// previously-known deadline in place — risks the UI confidently displaying
+// a stale "expires in X" while the server has actually invalidated it.
+func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
+ if ts == nil {
+ return
+ }
+ var deadline time.Time
+ // Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled".
+ // Everything else flows through Watcher.Update, whose sanity-checks
+ // reject out-of-range / pre-epoch / far-future / too-stale values and
+ // clear on rejection.
+ if ts.GetSeconds() != 0 || ts.GetNanos() != 0 {
+ deadline = ts.AsTime().UTC()
+ }
+ if e.sessionWatcher == nil {
+ return
+ }
+ // Watcher.Update owns the propagation to the status recorder (the
+ // SubscribeStatus / Status snapshot the UI reads): a set writes the
+ // deadline, a clear or a sanity-check rejection writes the zero value.
+ // Keeping a single writer is what stops the recorder from drifting out
+ // of sync with the warning timers.
+ if err := e.sessionWatcher.Update(deadline); err != nil {
+ log.Errorf("auth session deadline rejected: %v, clearing", err)
+ e.statusRecorder.PublishEvent(
+ cProto.SystemEvent_ERROR,
+ cProto.SystemEvent_AUTHENTICATION,
+ "session deadline rejected",
+ "",
+ map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()},
+ )
+ }
+}
+
+// DismissSessionWarning records the user's "Dismiss" click on the
+// T-WarningLead interactive notification and suppresses the upcoming
+// T-FinalWarningLead fallback for the current deadline. No-op when the
+// watcher is not running or holds no deadline.
+func (e *Engine) DismissSessionWarning() {
+ if e.sessionWatcher == nil {
+ return
+ }
+ e.sessionWatcher.Dismiss()
+}
+
+// ExtendAuthSession asks the management server to refresh the SSO session
+// expiry deadline using the supplied JWT, then mirrors the new deadline into
+// the daemon's state. The tunnel is untouched; no resync, no reconnect.
+//
+// Returns the new absolute UTC deadline (or zero time when the server
+// reports the peer is not eligible for extension).
+func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) {
+ if jwtToken == "" {
+ return time.Time{}, errors.New("jwt token is required")
+ }
+ if e.mgmClient == nil {
+ return time.Time{}, errors.New("management client is not initialised")
+ }
+
+ info, err := system.GetInfoWithChecks(ctx, e.checks)
+ if err != nil {
+ log.Warnf("failed to collect system info for session extend: %v", err)
+ info = system.GetInfo(ctx)
+ }
+
+ resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("extend auth session on management: %w", err)
+ }
+
+ e.ApplySessionDeadline(resp.GetSessionExpiresAt())
+
+ if resp.GetSessionExpiresAt().IsValid() {
+ return resp.GetSessionExpiresAt().AsTime().UTC(), nil
+ }
+ return time.Time{}, nil
+}
diff --git a/client/internal/engine_lazy_exclude_test.go b/client/internal/engine_lazy_exclude_test.go
new file mode 100644
index 000000000..b5ef16c3b
--- /dev/null
+++ b/client/internal/engine_lazy_exclude_test.go
@@ -0,0 +1,87 @@
+package internal
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/peerstore"
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+func TestPrefixesContain(t *testing.T) {
+ tests := []struct {
+ name string
+ prefixes []string
+ addr string
+ want bool
+ }{
+ {name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true},
+ {name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true},
+ {name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false},
+ {name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false},
+ {name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true},
+ {name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ prefixes := make([]netip.Prefix, 0, len(tt.prefixes))
+ for _, p := range tt.prefixes {
+ prefixes = append(prefixes, netip.MustParsePrefix(p))
+ }
+ require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr)))
+ })
+ }
+}
+
+// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target
+// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from
+// lazy connections, matched via the peer's already-parsed AllowedIPs.
+func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) {
+ const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0="
+ const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0="
+
+ store := peerstore.NewConnStore()
+ store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32"))
+ store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32"))
+
+ e := &Engine{peerStore: store}
+
+ peers := []*mgmProto.RemotePeerConfig{
+ {WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}},
+ {WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}},
+ }
+ rules := []firewallManager.ForwardRule{
+ {TranslatedAddress: netip.MustParseAddr("100.110.8.145")},
+ }
+
+ excluded := e.toExcludedLazyPeers(rules, peers)
+
+ require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections")
+ require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded")
+ require.Len(t, excluded, 1)
+}
+
+func TestToExcludedLazyPeers_NoRules(t *testing.T) {
+ e := &Engine{peerStore: peerstore.NewConnStore()}
+
+ peers := []*mgmProto.RemotePeerConfig{
+ {WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}},
+ }
+
+ require.Empty(t, e.toExcludedLazyPeers(nil, peers))
+}
+
+func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn {
+ t.Helper()
+ conn, err := peer.NewConn(peer.ConnConfig{
+ Key: key,
+ WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}},
+ }, peer.ServiceDependencies{})
+ require.NoError(t, err)
+ return conn
+}
diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go
new file mode 100644
index 000000000..032992464
--- /dev/null
+++ b/client/internal/engine_privileged_test.go
@@ -0,0 +1,565 @@
+//go:build privileged
+
+package internal
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "go.uber.org/mock/gomock"
+ "github.com/google/uuid"
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/iface/device"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/dns"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ "github.com/netbirdio/netbird/client/system"
+ nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbcache "github.com/netbirdio/netbird/management/server/cache"
+ "github.com/netbirdio/netbird/management/server/groups"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
+ "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/telemetry"
+ "github.com/netbirdio/netbird/management/server/types"
+ mgmt "github.com/netbirdio/netbird/shared/management/client"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ relayClient "github.com/netbirdio/netbird/shared/relay/client"
+ signal "github.com/netbirdio/netbird/shared/signal/client"
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ signalServer "github.com/netbirdio/netbird/signal/server"
+ "github.com/netbirdio/netbird/util"
+)
+
+func TestEngine_SSH(t *testing.T) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ engine := NewEngine(
+ ctx, cancel,
+ &EngineConfig{
+ WgIfaceName: "utun101",
+ WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
+ WgPrivateKey: key,
+ WgPort: 33100,
+ ServerSSHAllowed: true,
+ MTU: iface.DefaultMTU,
+ SSHKey: sshKey,
+ },
+ EngineServices{
+ SignalClient: &signal.MockClient{},
+ MgmClient: &mgmt.MockClient{},
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ },
+ MobileDependency{},
+ )
+
+ engine.dnsServer = &dns.MockServer{
+ UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
+ }
+
+ err = engine.Start(nil, nil)
+ require.NoError(t, err)
+
+ defer func() {
+ err := engine.Stop()
+ if err != nil {
+ return
+ }
+ }()
+
+ peerWithSSH := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ AllowedIps: []string{"100.64.0.21/24"},
+ SshConfig: &mgmtProto.SSHConfig{
+ SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"),
+ },
+ }
+
+ // SSH server is not enabled so SSH config of a remote peer should be ignored
+ networkMap := &mgmtProto.NetworkMap{
+ Serial: 6,
+ PeerConfig: nil,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ assert.Nil(t, engine.sshServer)
+
+ // SSH server is enabled, therefore SSH config should be applied
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 7,
+ PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
+ SshConfig: &mgmtProto.SSHConfig{
+ SshEnabled: true,
+ JwtConfig: &mgmtProto.JWTConfig{
+ Issuer: "test-issuer",
+ Audience: "test-audience",
+ KeysLocation: "test-keys",
+ MaxTokenAge: 3600,
+ },
+ }},
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ time.Sleep(250 * time.Millisecond)
+ assert.NotNil(t, engine.sshServer)
+
+ // now remove peer
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 8,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ // time.Sleep(250 * time.Millisecond)
+ assert.NotNil(t, engine.sshServer)
+
+ // now disable SSH server
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 9,
+ PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
+ SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}},
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ assert.Nil(t, engine.sshServer)
+}
+
+func TestEngine_Sync(t *testing.T) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ // feed updates to Engine via mocked Management client
+ updates := make(chan *mgmtProto.SyncResponse)
+ defer close(updates)
+ syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
+ for msg := range updates {
+ err := msgHandler(msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ engine := NewEngine(ctx, cancel, &EngineConfig{
+ WgIfaceName: "utun103",
+ WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
+ WgPrivateKey: key,
+ WgPort: 33100,
+ MTU: iface.DefaultMTU,
+ }, EngineServices{
+ SignalClient: &signal.MockClient{},
+ MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ }, MobileDependency{})
+ engine.ctx = ctx
+
+ engine.dnsServer = &dns.MockServer{
+ UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
+ }
+
+ defer func() {
+ err := engine.Stop()
+ if err != nil {
+ return
+ }
+ }()
+
+ err = engine.Start(nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ peer1 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ AllowedIps: []string{"100.64.0.10/24"},
+ }
+ peer2 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
+ AllowedIps: []string{"100.64.0.11/24"},
+ }
+ peer3 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
+ AllowedIps: []string{"100.64.0.12/24"},
+ }
+ // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update
+ updates <- &mgmtProto.SyncResponse{
+ NetworkMap: &mgmtProto.NetworkMap{
+ Serial: 10,
+ PeerConfig: nil,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3},
+ RemotePeersIsEmpty: false,
+ },
+ }
+
+ timeout := time.After(time.Second * 2)
+ for {
+ select {
+ case <-timeout:
+ t.Fatalf("timeout while waiting for test to finish")
+ return
+ default:
+ }
+
+ if getPeers(engine) == 3 && engine.networkSerial == 10 {
+ break
+ }
+ }
+}
+
+func TestEngine_MultiplePeers(t *testing.T) {
+ // log.SetLevel(log.DebugLevel)
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ sigServer, signalAddr, err := startSignal(t)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+ defer sigServer.Stop()
+ mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql")
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+ defer mgmtServer.GracefulStop()
+
+ setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
+
+ mu := sync.Mutex{}
+ engines := []*Engine{}
+ numPeers := 10
+ wg := sync.WaitGroup{}
+ wg.Add(numPeers)
+ // create and start peers
+ for i := 0; i < numPeers; i++ {
+ j := i
+ go func() {
+ engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr)
+ if err != nil {
+ wg.Done()
+ t.Errorf("unable to create the engine for peer %d with error %v", j, err)
+ return
+ }
+ engine.dnsServer = &dns.MockServer{}
+ mu.Lock()
+ defer mu.Unlock()
+ guid := fmt.Sprintf("{%s}", uuid.New().String())
+ device.CustomWindowsGUIDString = strings.ToLower(guid)
+ err = engine.Start(nil, nil)
+ if err != nil {
+ t.Errorf("unable to start engine for peer %d with error %v", j, err)
+ wg.Done()
+ return
+ }
+ engines = append(engines, engine)
+ wg.Done()
+ }()
+ }
+
+ // wait until all have been created and started
+ wg.Wait()
+ if len(engines) != numPeers {
+ t.Fatal("not all peers were started")
+ }
+ // check whether all the peer have expected peers connected
+
+ expectedConnected := numPeers * (numPeers - 1)
+
+ // adjust according to timeouts
+ timeout := 50 * time.Second
+ timeoutChan := time.After(timeout)
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+loop:
+ for {
+ select {
+ case <-timeoutChan:
+ t.Fatalf("waiting for expected connections timeout after %s", timeout.String())
+ break loop
+ case <-ticker.C:
+ totalConnected := 0
+ for _, engine := range engines {
+ totalConnected += getConnectedPeers(engine)
+ }
+ if totalConnected == expectedConnected {
+ log.Infof("total connected=%d", totalConnected)
+ break loop
+ }
+ log.Infof("total connected=%d", totalConnected)
+ }
+ }
+ // cleanup test
+ for n, peerEngine := range engines {
+ t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n)
+ errStop := peerEngine.mgmClient.Close()
+ if errStop != nil {
+ log.Infoln("got error trying to close management clients from engine: ", errStop)
+ }
+ errStop = peerEngine.Stop()
+ if errStop != nil {
+ log.Infoln("got error trying to close testing peers engine: ", errStop)
+ }
+ }
+}
+
+var (
+ kaep = keepalive.EnforcementPolicy{
+ MinTime: 15 * time.Second,
+ PermitWithoutStream: true,
+ }
+
+ kasp = keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Second,
+ MaxConnectionAgeGrace: 5 * time.Second,
+ Time: 5 * time.Second,
+ Timeout: 2 * time.Second,
+ }
+)
+
+func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ return nil, err
+ }
+ mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false)
+ if err != nil {
+ return nil, err
+ }
+ signalClient, err := signal.NewClient(ctx, signalAddr, key, false)
+ if err != nil {
+ return nil, err
+ }
+
+ info := system.GetInfo(ctx)
+ resp, err := mgmtClient.Register(setupKey, "", info, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var ifaceName string
+ if runtime.GOOS == "darwin" {
+ ifaceName = fmt.Sprintf("utun1%d", i)
+ } else {
+ ifaceName = fmt.Sprintf("wt%d", i)
+ }
+
+ wgPort := 33100 + i
+ conf := &EngineConfig{
+ WgIfaceName: ifaceName,
+ WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
+ WgPrivateKey: key,
+ WgPort: wgPort,
+ MTU: iface.DefaultMTU,
+ }
+
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ e, err := NewEngine(ctx, cancel, conf, EngineServices{
+ SignalClient: signalClient,
+ MgmClient: mgmtClient,
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ }, MobileDependency{}), nil
+ e.ctx = ctx
+ return e, err
+}
+
+func startSignal(t *testing.T) (*grpc.Server, string, error) {
+ t.Helper()
+
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ log.Fatalf("failed to listen: %v", err)
+ }
+
+ srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ proto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) {
+ t.Helper()
+
+ config := &config.Config{
+ Stuns: []*config.Host{},
+ TURNConfig: &config.TURNConfig{},
+ Relay: &config.Relay{
+ Addresses: []string{"127.0.0.1:1234"},
+ CredentialsTTL: util.Duration{Duration: time.Hour},
+ Secret: "222222222222222222",
+ },
+ Signal: &config.Host{
+ Proto: "http",
+ URI: "localhost:10000",
+ },
+ Datadir: dataDir,
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir)
+ if err != nil {
+ return nil, "", err
+ }
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+ if err != nil {
+ return nil, "", err
+ }
+
+ permissionsManager := permissions.NewManager(store)
+ peersManager := peers.NewManager(store, permissionsManager)
+ jobManager := job.NewJobManager(nil, store, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
+
+ metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+ require.NoError(t, err)
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ settingsMockManager := settings.NewMockManager(ctrl)
+ settingsMockManager.EXPECT().
+ GetSettings(gomock.Any(), gomock.Any(), gomock.Any()).
+ Return(&types.Settings{}, nil).
+ AnyTimes()
+ settingsMockManager.EXPECT().
+ GetExtraSettings(gomock.Any(), gomock.Any()).
+ Return(&types.ExtraSettings{}, nil).
+ AnyTimes()
+
+ groupsManager := groups.NewManagerMock()
+
+ updateManager := update_channel.NewPeersUpdateManager(metrics)
+ requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
+ networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
+ if err != nil {
+ return nil, "", err
+ }
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+// getConnectedPeers returns a connection Status or nil if peer connection wasn't found
+func getConnectedPeers(e *Engine) int {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+ i := 0
+ for _, id := range e.peerStore.PeersPubKey() {
+ conn, _ := e.peerStore.PeerConn(id)
+ if conn.IsConnected() {
+ i++
+ }
+ }
+ return i
+}
+
+func getPeers(e *Engine) int {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+
+ return len(e.peerStore.PeersPubKey())
+}
diff --git a/client/internal/engine_session_deadline_test.go b/client/internal/engine_session_deadline_test.go
new file mode 100644
index 000000000..5a67f103a
--- /dev/null
+++ b/client/internal/engine_session_deadline_test.go
@@ -0,0 +1,88 @@
+package internal
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the
+// wire field carried on LoginResponse / SyncResponse:
+//
+// - nil pointer → no info; previously-anchored deadline survives
+// - explicit zero value → "expiry disabled" sentinel; both sinks cleared
+// - valid future timestamp → new deadline propagated to both sinks
+func TestApplySessionDeadline_ThreeState(t *testing.T) {
+ newEngine := func() *Engine {
+ recorder := peer.NewRecorder("")
+ return &Engine{
+ statusRecorder: recorder,
+ sessionWatcher: sessionwatch.New(recorder),
+ }
+ }
+
+ t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) {
+ e := newEngine()
+ deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
+
+ e.ApplySessionDeadline(timestamppb.New(deadline))
+
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline),
+ "status recorder should hold the new deadline")
+ })
+
+ t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) {
+ e := newEngine()
+ seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
+ e.ApplySessionDeadline(timestamppb.New(seeded))
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
+
+ e.ApplySessionDeadline(nil)
+
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded),
+ "nil snapshot must not disturb the existing deadline")
+ })
+
+ t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) {
+ e := newEngine()
+ seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
+ e.ApplySessionDeadline(timestamppb.New(seeded))
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
+
+ // Explicit zero Timestamp{} (seconds=0, nanos=0) is the
+ // "expiry disabled / not SSO" sentinel.
+ e.ApplySessionDeadline(×tamppb.Timestamp{})
+
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
+ "explicit zero sentinel must clear the deadline")
+ })
+
+ t.Run("invalid timestamp clears the deadline", func(t *testing.T) {
+ e := newEngine()
+ seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
+ e.ApplySessionDeadline(timestamppb.New(seeded))
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
+
+ // Out-of-range nanos → IsValid()==false; same-meaning as the
+ // disabled sentinel for downstream sinks.
+ e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1})
+
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
+ "invalid timestamp must clear the deadline")
+ })
+
+ t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
+ e := newEngine()
+ expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
+
+ e.ApplySessionDeadline(timestamppb.New(expired))
+
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
+ "recently-expired deadline must stay on the recorder so consumers render it as expired")
+ })
+}
diff --git a/client/internal/engine_sessionwatch.go b/client/internal/engine_sessionwatch.go
new file mode 100644
index 000000000..a46d73f87
--- /dev/null
+++ b/client/internal/engine_sessionwatch.go
@@ -0,0 +1,16 @@
+//go:build !js
+
+package internal
+
+import (
+ "github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+// newSessionWatcher returns the real SSO session expiry watcher for every
+// non-wasm build. The js/wasm build gets a no-op stub from
+// engine_sessionwatch_js.go so the sessionwatch package (and its timer
+// machinery) never links into the wasm binary.
+func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
+ return sessionwatch.New(recorder)
+}
diff --git a/client/internal/engine_sessionwatch_js.go b/client/internal/engine_sessionwatch_js.go
new file mode 100644
index 000000000..50e148ab9
--- /dev/null
+++ b/client/internal/engine_sessionwatch_js.go
@@ -0,0 +1,44 @@
+//go:build js
+
+package internal
+
+import (
+ "time"
+
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The
+// wasm client never runs the engine's session-warning flow (the interactive
+// T-WarningLead notification and the T-FinalWarningLead fallback dialog live
+// in the desktop UI), so linking the full sessionwatch package (timers, event
+// composition) would only bloat the binary.
+//
+// It still mirrors the deadline into the status recorder so the SubscribeStatus
+// / Status snapshot the UI consumes stays correct — only the timer-driven
+// warnings are dropped.
+type noopSessionWatcher struct {
+ recorder *peer.Status
+}
+
+func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
+ return noopSessionWatcher{recorder: recorder}
+}
+
+// Update mirrors the real watcher's recorder propagation without the timers or
+// sanity-check sentinels: a valid deadline is exposed on the status snapshot,
+// the zero time clears it.
+func (w noopSessionWatcher) Update(deadline time.Time) error {
+ if w.recorder != nil {
+ w.recorder.SetSessionExpiresAt(deadline)
+ }
+ return nil
+}
+
+func (noopSessionWatcher) Dismiss() {
+ // No-op: only suppresses the timer-driven final-warning, which this stub never arms.
+}
+
+func (noopSessionWatcher) Close() {
+ // No-op: no timers to stop and no state to unwind; the recorder is cleared via Update(zero).
+}
diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go
index 289f1906f..fbd47ed74 100644
--- a/client/internal/engine_test.go
+++ b/client/internal/engine_test.go
@@ -6,37 +6,18 @@ import (
"net"
"net/netip"
"os"
- "runtime"
"strings"
"sync"
"testing"
"time"
- "github.com/golang/mock/gomock"
- "github.com/google/uuid"
- log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "go.opentelemetry.io/otel"
wgdevice "golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
- "google.golang.org/grpc"
- "google.golang.org/grpc/keepalive"
"github.com/netbirdio/netbird/client/internal/stdnet"
- "github.com/netbirdio/netbird/management/server/job"
-
- "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
-
- "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
- "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
- "github.com/netbirdio/netbird/management/internals/modules/peers"
- "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
- nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
-
- "github.com/netbirdio/netbird/management/internals/server/config"
- "github.com/netbirdio/netbird/management/server/groups"
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/configurer"
@@ -50,18 +31,7 @@ import (
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/routemanager"
- nbssh "github.com/netbirdio/netbird/client/ssh"
- "github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
- "github.com/netbirdio/netbird/management/server"
- "github.com/netbirdio/netbird/management/server/activity"
- nbcache "github.com/netbirdio/netbird/management/server/cache"
- "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
- "github.com/netbirdio/netbird/management/server/permissions"
- "github.com/netbirdio/netbird/management/server/settings"
- "github.com/netbirdio/netbird/management/server/store"
- "github.com/netbirdio/netbird/management/server/telemetry"
- "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/monotime"
"github.com/netbirdio/netbird/route"
mgmt "github.com/netbirdio/netbird/shared/management/client"
@@ -69,25 +39,9 @@ import (
"github.com/netbirdio/netbird/shared/netiputil"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
signal "github.com/netbirdio/netbird/shared/signal/client"
- "github.com/netbirdio/netbird/shared/signal/proto"
- signalServer "github.com/netbirdio/netbird/signal/server"
"github.com/netbirdio/netbird/util"
)
-var (
- kaep = keepalive.EnforcementPolicy{
- MinTime: 15 * time.Second,
- PermitWithoutStream: true,
- }
-
- kasp = keepalive.ServerParameters{
- MaxConnectionIdle: 15 * time.Second,
- MaxConnectionAgeGrace: 5 * time.Second,
- Time: 5 * time.Second,
- Timeout: 2 * time.Second,
- }
-)
-
type MockWGIface struct {
CreateFunc func() error
CreateOnAndroidFunc func(routeRange []string, ip string, domains []string) error
@@ -224,6 +178,10 @@ func (m *MockWGIface) LastActivities() map[string]monotime.Time {
return nil
}
+func (m *MockWGIface) MTU() uint16 {
+ return 1280
+}
+
func (m *MockWGIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
return nil
}
@@ -234,129 +192,6 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
-func TestEngine_SSH(t *testing.T) {
- key, err := wgtypes.GeneratePrivateKey()
- if err != nil {
- t.Fatal(err)
- return
- }
-
- sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- if err != nil {
- t.Fatal(err)
- return
- }
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
- engine := NewEngine(
- ctx, cancel,
- &EngineConfig{
- WgIfaceName: "utun101",
- WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
- WgPrivateKey: key,
- WgPort: 33100,
- ServerSSHAllowed: true,
- MTU: iface.DefaultMTU,
- SSHKey: sshKey,
- },
- EngineServices{
- SignalClient: &signal.MockClient{},
- MgmClient: &mgmt.MockClient{},
- RelayManager: relayMgr,
- StatusRecorder: peer.NewRecorder("https://mgm"),
- },
- MobileDependency{},
- )
-
- engine.dnsServer = &dns.MockServer{
- UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
- }
-
- err = engine.Start(nil, nil)
- require.NoError(t, err)
-
- defer func() {
- err := engine.Stop()
- if err != nil {
- return
- }
- }()
-
- peerWithSSH := &mgmtProto.RemotePeerConfig{
- WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
- AllowedIps: []string{"100.64.0.21/24"},
- SshConfig: &mgmtProto.SSHConfig{
- SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"),
- },
- }
-
- // SSH server is not enabled so SSH config of a remote peer should be ignored
- networkMap := &mgmtProto.NetworkMap{
- Serial: 6,
- PeerConfig: nil,
- RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- assert.Nil(t, engine.sshServer)
-
- // SSH server is enabled, therefore SSH config should be applied
- networkMap = &mgmtProto.NetworkMap{
- Serial: 7,
- PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
- SshConfig: &mgmtProto.SSHConfig{
- SshEnabled: true,
- JwtConfig: &mgmtProto.JWTConfig{
- Issuer: "test-issuer",
- Audience: "test-audience",
- KeysLocation: "test-keys",
- MaxTokenAge: 3600,
- },
- }},
- RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- time.Sleep(250 * time.Millisecond)
- assert.NotNil(t, engine.sshServer)
-
- // now remove peer
- networkMap = &mgmtProto.NetworkMap{
- Serial: 8,
- RemotePeers: []*mgmtProto.RemotePeerConfig{},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- // time.Sleep(250 * time.Millisecond)
- assert.NotNil(t, engine.sshServer)
-
- // now disable SSH server
- networkMap = &mgmtProto.NetworkMap{
- Serial: 9,
- PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
- SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}},
- RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- assert.Nil(t, engine.sshServer)
-}
-
func TestEngine_SSHUpdateLogic(t *testing.T) {
// Test that SSH server start/stop logic works based on config
engine := &Engine{
@@ -426,7 +261,7 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
@@ -631,97 +466,6 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
}
}
-func TestEngine_Sync(t *testing.T) {
- key, err := wgtypes.GeneratePrivateKey()
- if err != nil {
- t.Fatal(err)
- return
- }
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // feed updates to Engine via mocked Management client
- updates := make(chan *mgmtProto.SyncResponse)
- defer close(updates)
- syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
- for msg := range updates {
- err := msgHandler(msg)
- if err != nil {
- t.Fatal(err)
- }
- }
- return nil
- }
- relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
- engine := NewEngine(ctx, cancel, &EngineConfig{
- WgIfaceName: "utun103",
- WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
- WgPrivateKey: key,
- WgPort: 33100,
- MTU: iface.DefaultMTU,
- }, EngineServices{
- SignalClient: &signal.MockClient{},
- MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
- RelayManager: relayMgr,
- StatusRecorder: peer.NewRecorder("https://mgm"),
- }, MobileDependency{})
- engine.ctx = ctx
-
- engine.dnsServer = &dns.MockServer{
- UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
- }
-
- defer func() {
- err := engine.Stop()
- if err != nil {
- return
- }
- }()
-
- err = engine.Start(nil, nil)
- if err != nil {
- t.Fatal(err)
- return
- }
-
- peer1 := &mgmtProto.RemotePeerConfig{
- WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
- AllowedIps: []string{"100.64.0.10/24"},
- }
- peer2 := &mgmtProto.RemotePeerConfig{
- WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
- AllowedIps: []string{"100.64.0.11/24"},
- }
- peer3 := &mgmtProto.RemotePeerConfig{
- WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
- AllowedIps: []string{"100.64.0.12/24"},
- }
- // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update
- updates <- &mgmtProto.SyncResponse{
- NetworkMap: &mgmtProto.NetworkMap{
- Serial: 10,
- PeerConfig: nil,
- RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3},
- RemotePeersIsEmpty: false,
- },
- }
-
- timeout := time.After(time.Second * 2)
- for {
- select {
- case <-timeout:
- t.Fatalf("timeout while waiting for test to finish")
- return
- default:
- }
-
- if getPeers(engine) == 3 && engine.networkSerial == 10 {
- break
- }
- }
-}
-
func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
testCases := []struct {
name string
@@ -817,7 +561,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
wgIfaceName := fmt.Sprintf("utun%d", 104+n)
@@ -1024,7 +768,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
wgIfaceName := fmt.Sprintf("utun%d", 104+n)
@@ -1105,104 +849,6 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
}
}
-func TestEngine_MultiplePeers(t *testing.T) {
- // log.SetLevel(log.DebugLevel)
-
- ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
- defer cancel()
-
- sigServer, signalAddr, err := startSignal(t)
- if err != nil {
- t.Fatal(err)
- return
- }
- defer sigServer.Stop()
- mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql")
- if err != nil {
- t.Fatal(err)
- return
- }
- defer mgmtServer.GracefulStop()
-
- setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
-
- mu := sync.Mutex{}
- engines := []*Engine{}
- numPeers := 10
- wg := sync.WaitGroup{}
- wg.Add(numPeers)
- // create and start peers
- for i := 0; i < numPeers; i++ {
- j := i
- go func() {
- engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr)
- if err != nil {
- wg.Done()
- t.Errorf("unable to create the engine for peer %d with error %v", j, err)
- return
- }
- engine.dnsServer = &dns.MockServer{}
- mu.Lock()
- defer mu.Unlock()
- guid := fmt.Sprintf("{%s}", uuid.New().String())
- device.CustomWindowsGUIDString = strings.ToLower(guid)
- err = engine.Start(nil, nil)
- if err != nil {
- t.Errorf("unable to start engine for peer %d with error %v", j, err)
- wg.Done()
- return
- }
- engines = append(engines, engine)
- wg.Done()
- }()
- }
-
- // wait until all have been created and started
- wg.Wait()
- if len(engines) != numPeers {
- t.Fatal("not all peers was started")
- }
- // check whether all the peer have expected peers connected
-
- expectedConnected := numPeers * (numPeers - 1)
-
- // adjust according to timeouts
- timeout := 50 * time.Second
- timeoutChan := time.After(timeout)
- ticker := time.NewTicker(time.Second)
- defer ticker.Stop()
-loop:
- for {
- select {
- case <-timeoutChan:
- t.Fatalf("waiting for expected connections timeout after %s", timeout.String())
- break loop
- case <-ticker.C:
- totalConnected := 0
- for _, engine := range engines {
- totalConnected += getConnectedPeers(engine)
- }
- if totalConnected == expectedConnected {
- log.Infof("total connected=%d", totalConnected)
- break loop
- }
- log.Infof("total connected=%d", totalConnected)
- }
- }
- // cleanup test
- for n, peerEngine := range engines {
- t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n)
- errStop := peerEngine.mgmClient.Close()
- if errStop != nil {
- log.Infoln("got error trying to close management clients from engine: ", errStop)
- }
- errStop = peerEngine.Stop()
- if errStop != nil {
- log.Infoln("got error trying to close testing peers engine: ", errStop)
- }
- }
-}
-
func Test_ParseNATExternalIPMappings(t *testing.T) {
ifaceList, err := net.Interfaces()
if err != nil {
@@ -1526,187 +1172,6 @@ func TestCompareNetIPLists(t *testing.T) {
}
}
-func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) {
- key, err := wgtypes.GeneratePrivateKey()
- if err != nil {
- return nil, err
- }
- mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false)
- if err != nil {
- return nil, err
- }
- signalClient, err := signal.NewClient(ctx, signalAddr, key, false)
- if err != nil {
- return nil, err
- }
-
- info := system.GetInfo(ctx)
- resp, err := mgmtClient.Register(setupKey, "", info, nil, nil)
- if err != nil {
- return nil, err
- }
-
- var ifaceName string
- if runtime.GOOS == "darwin" {
- ifaceName = fmt.Sprintf("utun1%d", i)
- } else {
- ifaceName = fmt.Sprintf("wt%d", i)
- }
-
- wgPort := 33100 + i
- conf := &EngineConfig{
- WgIfaceName: ifaceName,
- WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
- WgPrivateKey: key,
- WgPort: wgPort,
- MTU: iface.DefaultMTU,
- }
-
- relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
- e, err := NewEngine(ctx, cancel, conf, EngineServices{
- SignalClient: signalClient,
- MgmClient: mgmtClient,
- RelayManager: relayMgr,
- StatusRecorder: peer.NewRecorder("https://mgm"),
- }, MobileDependency{}), nil
- e.ctx = ctx
- return e, err
-}
-
-func startSignal(t *testing.T) (*grpc.Server, string, error) {
- t.Helper()
-
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- log.Fatalf("failed to listen: %v", err)
- }
-
- srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
- require.NoError(t, err)
- proto.RegisterSignalExchangeServer(s, srv)
-
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
-
-func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) {
- t.Helper()
-
- config := &config.Config{
- Stuns: []*config.Host{},
- TURNConfig: &config.TURNConfig{},
- Relay: &config.Relay{
- Addresses: []string{"127.0.0.1:1234"},
- CredentialsTTL: util.Duration{Duration: time.Hour},
- Secret: "222222222222222222",
- },
- Signal: &config.Host{
- Proto: "http",
- URI: "localhost:10000",
- },
- Datadir: dataDir,
- HttpConfig: nil,
- }
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- return nil, "", err
- }
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
-
- store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir)
- if err != nil {
- return nil, "", err
- }
- t.Cleanup(cleanUp)
-
- eventStore := &activity.InMemoryEventStore{}
- if err != nil {
- return nil, "", err
- }
-
- permissionsManager := permissions.NewManager(store)
- peersManager := peers.NewManager(store, permissionsManager)
- jobManager := job.NewJobManager(nil, store, peersManager)
-
- cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
- if err != nil {
- return nil, "", err
- }
-
- ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
-
- metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
- require.NoError(t, err)
-
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- settingsMockManager := settings.NewMockManager(ctrl)
- settingsMockManager.EXPECT().
- GetSettings(gomock.Any(), gomock.Any(), gomock.Any()).
- Return(&types.Settings{}, nil).
- AnyTimes()
- settingsMockManager.EXPECT().
- GetExtraSettings(gomock.Any(), gomock.Any()).
- Return(&types.ExtraSettings{}, nil).
- AnyTimes()
-
- groupsManager := groups.NewManagerMock()
-
- updateManager := update_channel.NewPeersUpdateManager(metrics)
- requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
- networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
- accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
- if err != nil {
- return nil, "", err
- }
-
- secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
- if err != nil {
- return nil, "", err
- }
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
- if err != nil {
- return nil, "", err
- }
- mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
-
-// getConnectedPeers returns a connection Status or nil if peer connection wasn't found
-func getConnectedPeers(e *Engine) int {
- e.syncMsgMux.Lock()
- defer e.syncMsgMux.Unlock()
- i := 0
- for _, id := range e.peerStore.PeersPubKey() {
- conn, _ := e.peerStore.PeerConn(id)
- if conn.IsConnected() {
- i++
- }
- }
- return i
-}
-
-func getPeers(e *Engine) int {
- e.syncMsgMux.Lock()
- defer e.syncMsgMux.Unlock()
-
- return len(e.peerStore.PeersPubKey())
-}
-
func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte {
t.Helper()
b, err := netiputil.EncodePrefix(p)
diff --git a/client/internal/engine_tunsettings.go b/client/internal/engine_tunsettings.go
new file mode 100644
index 000000000..34a59671a
--- /dev/null
+++ b/client/internal/engine_tunsettings.go
@@ -0,0 +1,20 @@
+package internal
+
+func (e *Engine) TunSettings() ([]string, []string) {
+ e.syncMsgMux.Lock()
+ routeManager := e.routeManager
+ dnsServer := e.dnsServer
+ e.syncMsgMux.Unlock()
+
+ var routes []string
+ if routeManager != nil {
+ routes = routeManager.CurrentRouteRange()
+ }
+
+ var searchDomains []string
+ if dnsServer != nil {
+ searchDomains = dnsServer.SearchDomains()
+ }
+
+ return routes, searchDomains
+}
diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go
index 2eeac1954..8ffa0b102 100644
--- a/client/internal/iface_common.go
+++ b/client/internal/iface_common.go
@@ -44,4 +44,5 @@ type wgIfaceBase interface {
FullStats() (*configurer.Stats, error)
LastActivities() map[string]monotime.Time
SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
+ MTU() uint16
}
diff --git a/client/internal/ipcauth/creds_stub.go b/client/internal/ipcauth/creds_stub.go
new file mode 100644
index 000000000..154948716
--- /dev/null
+++ b/client/internal/ipcauth/creds_stub.go
@@ -0,0 +1,31 @@
+//go:build !linux && !darwin && !freebsd && !windows
+
+package ipcauth
+
+import (
+ "errors"
+ "net"
+
+ "google.golang.org/grpc/credentials"
+)
+
+// errUnsupported is returned on platforms with no local peer-identity
+// primitive, so consumers fail closed instead of guessing an identity.
+var errUnsupported = errors.New("peer identity is not available on this platform")
+
+// NewTransportCredentials returns nil: without a peer-identity primitive the
+// daemon cannot authenticate local callers, and the caller must treat that as
+// "authorization cannot be enforced".
+func NewTransportCredentials() credentials.TransportCredentials {
+ return nil
+}
+
+// PeerIdentity always fails on this platform.
+func PeerIdentity(net.Conn) (Identity, error) {
+ return Identity{}, errUnsupported
+}
+
+// ConnIdentity always fails on this platform.
+func ConnIdentity(net.Conn) (Identity, error) {
+ return Identity{}, errUnsupported
+}
diff --git a/client/internal/ipcauth/creds_unix.go b/client/internal/ipcauth/creds_unix.go
new file mode 100644
index 000000000..688fe4623
--- /dev/null
+++ b/client/internal/ipcauth/creds_unix.go
@@ -0,0 +1,56 @@
+//go:build linux || darwin || freebsd
+
+package ipcauth
+
+import (
+ "context"
+ "net"
+
+ "google.golang.org/grpc/credentials"
+)
+
+// NewTransportCredentials returns gRPC transport credentials that expose the
+// caller's kernel-authenticated identity via IdentityFromContext. It returns
+// nil on platforms that have no peer-identity primitive, which the caller must
+// treat as "authorization cannot be enforced".
+//
+// The handshake exchanges no bytes on the wire, so a client dialing with
+// insecure credentials interoperates with a server using these. That keeps
+// older CLI and UI binaries working against an upgraded daemon.
+func NewTransportCredentials() credentials.TransportCredentials {
+ return unixCreds{}
+}
+
+// ConnIdentity extracts the caller's identity from an accepted local IPC
+// connection. It is shared by the gRPC transport credentials and by the JSON
+// gateway, which reads the identity of its own HTTP clients.
+func ConnIdentity(conn net.Conn) (Identity, error) {
+ return PeerIdentity(conn)
+}
+
+type unixCreds struct{}
+
+func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
+ return conn, AuthInfo{}, nil
+}
+
+// ServerHandshake extracts the peer identity and fails closed when it cannot
+// be read, so a connection whose caller is unknown never reaches a handler.
+func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
+ id, err := ConnIdentity(conn)
+ if err != nil {
+ return nil, nil, err
+ }
+ return conn, AuthInfo{
+ CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
+ Identity: id,
+ }, nil
+}
+
+func (unixCreds) Info() credentials.ProtocolInfo {
+ return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
+}
+
+func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} }
+
+func (unixCreds) OverrideServerName(string) error { return nil }
diff --git a/client/internal/ipcauth/creds_windows.go b/client/internal/ipcauth/creds_windows.go
new file mode 100644
index 000000000..37f902c52
--- /dev/null
+++ b/client/internal/ipcauth/creds_windows.go
@@ -0,0 +1,194 @@
+//go:build windows
+
+package ipcauth
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "runtime"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows"
+ "google.golang.org/grpc/credentials"
+)
+
+var (
+ modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
+ procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
+)
+
+// DefaultPipeSDDL is the security descriptor for the daemon control pipe.
+//
+// D:P protected DACL, no inheritance
+// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon's service account)
+// (A;;GA;;;WD) allow GENERIC_ALL to Everyone
+//
+// Any local caller may connect, as with a Unix socket at 0666; what a caller may
+// actually do is decided from its token, not from the DACL. Remote callers are not
+// a concern here: winio.ListenPipe creates the pipe with
+// FILE_PIPE_REJECT_REMOTE_CLIENTS, so NPFS rejects connections from other machines
+// before the descriptor is consulted.
+//
+// A deny ACE on the NETWORK SID would not add anything and would break callers:
+// that SID is present in any network-logon token, which includes OpenSSH and WinRM
+// sessions, so it denies administrators driving the CLI over SSH and denies the
+// daemon itself when started from such a session.
+func DefaultPipeSDDL() string {
+ return "D:P(A;;GA;;;SY)(A;;GA;;;WD)"
+}
+
+// NewTransportCredentials returns gRPC transport credentials that derive the
+// caller's identity from the named-pipe client token.
+//
+// The client must connect at SECURITY_IDENTIFICATION for the daemon to be able
+// to read its token, which is what DialNamedPipe does.
+func NewTransportCredentials() credentials.TransportCredentials {
+ return winpipeCreds{}
+}
+
+// ConnIdentity extracts the caller's identity from an accepted named-pipe
+// connection by impersonating the pipe client and reading its token. It is
+// shared by the gRPC transport credentials and by the JSON gateway, which
+// reads the identity of its own HTTP clients.
+func ConnIdentity(conn net.Conn) (Identity, error) {
+ // go-winio's pipe connection embeds *win32File, which exposes Fd().
+ fdConn, ok := conn.(interface{ Fd() uintptr })
+ if !ok {
+ return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn)
+ }
+ return pipeClientIdentity(windows.Handle(fdConn.Fd()))
+}
+
+type winpipeCreds struct{}
+
+func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
+ return conn, AuthInfo{}, nil
+}
+
+// ServerHandshake extracts the connecting client's identity and fails closed
+// when the handle or token cannot be read, so a connection whose caller is
+// unknown never reaches a handler.
+func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
+ id, err := ConnIdentity(conn)
+ if err != nil {
+ return nil, nil, err
+ }
+ return conn, AuthInfo{
+ CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
+ Identity: id,
+ }, nil
+}
+
+func (winpipeCreds) Info() credentials.ProtocolInfo {
+ return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
+}
+
+func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} }
+
+func (winpipeCreds) OverrideServerName(string) error { return nil }
+
+// pipeClientIdentity reads the connecting client's user SID, usable group
+// SIDs, and elevation state by impersonating the pipe client on this thread
+// and reading the resulting impersonation token.
+func pipeClientIdentity(handle windows.Handle) (id Identity, err error) {
+ // Impersonation is per-thread, so the goroutine must stay on this thread
+ // until RevertToSelf, otherwise an unrelated goroutine could inherit the
+ // impersonated context.
+ runtime.LockOSThread()
+
+ // The thread only goes back to the runtime's pool once it is provably no
+ // longer impersonating the client. If the revert fails, leaving it locked
+ // makes Go terminate it when this goroutine exits, which costs one thread
+ // and keeps a thread running as the client from ever being reused.
+ clean := false
+ defer func() {
+ if clean {
+ runtime.UnlockOSThread()
+ }
+ }()
+
+ if err = impersonateNamedPipeClient(handle); err != nil {
+ clean = true
+ return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
+ }
+ defer func() {
+ // Surface the revert failure only when nothing else failed: leaving
+ // the thread impersonated is worse than the original error.
+ revErr := windows.RevertToSelf()
+ if revErr != nil {
+ if err == nil {
+ err = fmt.Errorf("revert impersonation: %w", revErr)
+ }
+ return
+ }
+ clean = true
+ }()
+
+ // openAsSelf=true opens the token with the daemon's own process context
+ // rather than the impersonated client's, so the open cannot fail because
+ // the client lacks access to its own token.
+ var token windows.Token
+ if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil {
+ return Identity{}, fmt.Errorf("open thread token: %w", err)
+ }
+ defer func() {
+ if cerr := token.Close(); cerr != nil {
+ log.Debugf("close client token: %v", cerr)
+ }
+ }()
+
+ return identityFromToken(token)
+}
+
+// identityFromToken reads the user SID, usable group SIDs and elevation state
+// out of a Windows token.
+func identityFromToken(token windows.Token) (Identity, error) {
+ user, err := token.GetTokenUser()
+ if err != nil {
+ return Identity{}, fmt.Errorf("read token user: %w", err)
+ }
+
+ groups, err := tokenGroupSIDs(token)
+ if err != nil {
+ return Identity{}, err
+ }
+
+ return Identity{
+ SID: user.User.Sid.String(),
+ Groups: groups,
+ Elevated: token.IsElevated(),
+ }, nil
+}
+
+// tokenGroupSIDs returns the SIDs of the groups the token can actually
+// exercise. Groups that are disabled or marked deny-only are skipped: a
+// UAC-filtered administrator carries BUILTIN\Administrators as deny-only, and
+// treating that as membership would hand every admin account privilege it
+// cannot currently use.
+func tokenGroupSIDs(token windows.Token) ([]string, error) {
+ tg, err := token.GetTokenGroups()
+ if err != nil {
+ return nil, fmt.Errorf("read token groups: %w", err)
+ }
+
+ var sids []string
+ for _, g := range tg.AllGroups() {
+ if g.Attributes&windows.SE_GROUP_ENABLED == 0 {
+ continue
+ }
+ if g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
+ continue
+ }
+ sids = append(sids, g.Sid.String())
+ }
+ return sids, nil
+}
+
+func impersonateNamedPipeClient(h windows.Handle) error {
+ r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
+ if r == 0 {
+ return e
+ }
+ return nil
+}
diff --git a/client/internal/ipcauth/forward.go b/client/internal/ipcauth/forward.go
new file mode 100644
index 000000000..57749528c
--- /dev/null
+++ b/client/internal/ipcauth/forward.go
@@ -0,0 +1,272 @@
+package ipcauth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/hex"
+ "fmt"
+ "slices"
+ "strconv"
+ "strings"
+
+ "google.golang.org/grpc/metadata"
+)
+
+// Metadata keys the local JSON gateway uses to forward the identity of its own
+// HTTP client to the daemon. The gateway runs inside the daemon process and
+// re-dials the daemon over the control socket, so without forwarding every
+// JSON request would appear to come from the daemon itself.
+const (
+ // mdFwd marks a request as forwarded by the JSON gateway. It is always
+ // set, even when the gateway could not read its client's identity, so the
+ // daemon can tell "no identity available" apart from "not forwarded".
+ mdFwd = "x-netbird-fwd"
+ mdFwdUID = "x-netbird-fwd-uid" // Unix user ID
+ mdFwdGID = "x-netbird-fwd-gid" // Unix primary group ID
+ mdFwdSID = "x-netbird-fwd-sid" // Windows user SID
+ mdFwdGroup = "x-netbird-fwd-group" // Windows group SID, repeated
+ mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" when elevated
+
+ // mdFwdProof proves the forwarded identity was stamped by this process. The
+ // gateway runs inside the daemon, so a secret held in memory is available to
+ // the only legitimate producer and to nothing else.
+ mdFwdProof = "x-netbird-fwd-proof"
+)
+
+// forwardKeys is every metadata key the gateway sets. An HTTP client must never
+// be able to supply one itself: see IsReservedForwardKey.
+var forwardKeys = []string{mdFwd, mdFwdUID, mdFwdGID, mdFwdSID, mdFwdGroup, mdFwdElevated, mdFwdProof}
+
+// forwardProof authenticates the gateway's forwarding metadata. It is generated
+// once per daemon process and never leaves it: it is not written to disk, not
+// logged, and not sent anywhere except over the daemon's own control socket to
+// itself.
+//
+// Without it, trusting a forwarded identity rests on every layer in front of it
+// stripping incoming forwarding keys, and on each key's value shape being
+// distinguishable from an injected one. A single injected group SID or an
+// injected "elevated" flag has the same shape as a legitimate one, so no
+// cardinality rule can catch it. Requiring the proof means metadata that did not
+// come from this process is refused whatever it contains.
+var forwardProof = mustForwardProof()
+
+func mustForwardProof() string {
+ var buf [32]byte
+ if _, err := rand.Read(buf[:]); err != nil {
+ // Continuing would leave the forwarded path authenticated by a
+ // predictable value, which is worse than not starting.
+ panic(fmt.Sprintf("generate identity forwarding proof: %v", err))
+ }
+ return hex.EncodeToString(buf[:])
+}
+
+// IsReservedForwardKey reports whether a gRPC metadata key belongs to the
+// gateway's identity forwarding, and therefore must be dropped when it arrives
+// from outside.
+//
+// grpc-gateway maps "Grpc-Metadata-" request headers into gRPC metadata and
+// joins them ahead of the values its own annotators add. Without dropping these,
+// an HTTP client could hand the daemon "x-netbird-fwd-uid: 0" and be believed,
+// because the daemon trusts forwarded metadata when the transport peer is the
+// (privileged) gateway.
+func IsReservedForwardKey(key string) bool {
+ key = strings.ToLower(key)
+ return slices.Contains(forwardKeys, key)
+}
+
+// ForwardIdentityMetadata encodes an HTTP client's identity for the JSON
+// gateway to forward to the daemon. When known is false only the marker is
+// set, which makes the daemon treat the caller as unidentified rather than as
+// the daemon itself.
+func ForwardIdentityMetadata(id Identity, known bool) metadata.MD {
+ md := metadata.MD{}
+ md.Set(mdFwd, "1")
+ md.Set(mdFwdProof, forwardProof)
+ if !known {
+ return md
+ }
+
+ if id.IsWindows() {
+ md.Set(mdFwdSID, id.SID)
+ if len(id.Groups) > 0 {
+ md.Set(mdFwdGroup, id.Groups...)
+ }
+ if id.Elevated {
+ md.Set(mdFwdElevated, "1")
+ }
+ return md
+ }
+
+ md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10))
+ md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10))
+ return md
+}
+
+// CallerIdentity returns the identity to authorize a request against. For a
+// direct connection that is the transport peer's kernel identity. For a
+// request relayed by the local JSON gateway it is the identity the gateway
+// forwarded, since the transport peer is then the daemon itself.
+//
+// A forwarded identity is only honoured when the transport peer is the daemon's
+// own identity and the metadata carries this process's forwarding proof, so
+// forged forwarding metadata gains a caller nothing. A forwarded request that
+// carries no identity is reported as unidentified, never as the daemon.
+//
+// The second return value is false when no identity could be established, and
+// callers MUST fail closed in that case.
+func CallerIdentity(ctx context.Context) (Identity, bool) {
+ id, ok := IdentityFromContext(ctx)
+ if !ok {
+ return Identity{}, false
+ }
+
+ // A forwarding key that arrives more than once did not come from the gateway
+ // alone, so nothing about the request can be trusted to describe its caller.
+ // Refusing outright matters because the alternative reading, "not forwarded",
+ // would authorize the request as the transport peer, which on the gateway's
+ // connection is the daemon itself.
+ if duplicatedForwardKey(ctx) {
+ return Identity{}, false
+ }
+
+ forwarded := isForwarded(ctx)
+
+ // Our own process on the other end of the socket is the JSON gateway, the only
+ // thing that dials the daemon from inside it. Such a call must carry a
+ // forwarded identity; without one there is no caller to authorize, and
+ // treating it as the daemon would authorize whatever reached the JSON socket.
+ // Only Linux reports the peer PID, so this is a belt on top of the gateway's
+ // interceptor rather than the sole guarantee.
+ if id.PID != 0 && int(id.PID) == selfPID && !forwarded {
+ return Identity{}, false
+ }
+
+ // Only the gateway's own connection may speak for someone else. Being
+ // privileged is not enough and not the point: the gateway runs inside the
+ // daemon, so it dials as the daemon's identity whatever user that is, which
+ // also covers a rootless container.
+ if !forwarded || !IsDaemonSelf(id) {
+ return id, true
+ }
+
+ // Speaking for someone else additionally requires the proof only this process
+ // holds. Refusing is the only safe reading: the transport peer here is the
+ // daemon itself, so falling back to it would authorize the request as the
+ // daemon. This is also what makes the forwarded values trustworthy once
+ // accepted, so they need no shape checks of their own.
+ if !authenticForward(ctx) {
+ return Identity{}, false
+ }
+
+ return forwardedIdentity(ctx)
+}
+
+// duplicatedForwardKey reports whether any forwarding key carries more than one
+// value. The gateway's interceptor sets each key exactly once and replaces what
+// was already there, so a repeat means a second source supplied it.
+func duplicatedForwardKey(ctx context.Context) bool {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return false
+ }
+ for _, key := range forwardKeys {
+ // Group SIDs are legitimately repeated; the rest identify the caller.
+ if key == mdFwdGroup {
+ continue
+ }
+ if len(md.Get(key)) > 1 {
+ return true
+ }
+ }
+ return false
+}
+
+// authenticForward reports whether the request carries this process's forwarding
+// proof, which only the in-process JSON gateway can supply.
+func authenticForward(ctx context.Context) bool {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return false
+ }
+ got := mdSingle(md, mdFwdProof)
+ return subtle.ConstantTimeCompare([]byte(got), []byte(forwardProof)) == 1
+}
+
+// isForwarded reports whether the request carries the JSON gateway marker.
+func isForwarded(ctx context.Context) bool {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return false
+ }
+ return mdSingle(md, mdFwd) != ""
+}
+
+// forwardedIdentity decodes the identity the JSON gateway attached.
+func forwardedIdentity(ctx context.Context) (Identity, bool) {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return Identity{}, false
+ }
+
+ if sid := mdSingle(md, mdFwdSID); sid != "" {
+ return Identity{
+ SID: sid,
+ // Repeated by design, one value per group, and only reachable once
+ // the forwarding proof has been verified.
+ Groups: md.Get(mdFwdGroup),
+ Elevated: mdSingle(md, mdFwdElevated) == "1",
+ }, true
+ }
+
+ uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32)
+ if err != nil {
+ return Identity{}, false
+ }
+
+ id := Identity{UID: uint32(uid)}
+ if gid, err := strconv.ParseUint(mdSingle(md, mdFwdGID), 10, 32); err == nil {
+ id.GID = uint32(gid)
+ }
+ return id, true
+}
+
+// mdSingle returns the value of a forwarded key only when exactly one was
+// supplied. The gateway's interceptor sets each key exactly once, so more than one
+// value means something else also supplied it, and the whole identity is treated as
+// unknown rather than picking a winner. Defence in depth behind the gateway's
+// header filter.
+func mdSingle(md metadata.MD, key string) string {
+ if v := md.Get(key); len(v) == 1 {
+ return v[0]
+ }
+ return ""
+}
+
+// WithForwardedIdentity stamps id onto a context's outgoing metadata for the JSON
+// gateway's call to the daemon, replacing any forwarding keys already present so
+// values supplied from outside cannot survive alongside it.
+//
+// This is deliberately not done with runtime.WithMetadata: grpc-gateway skips its
+// annotators entirely when no request header maps to metadata ("if len(pairs) == 0
+// { return ctx, nil, nil }", runtime/context.go), which an HTTP/1.0 request with no
+// Host header over a unix socket achieves. The daemon would then see an unmarked
+// call whose transport peer is the daemon's own identity, and authorize it as the
+// daemon. A client interceptor runs for every RPC regardless of headers.
+func WithForwardedIdentity(ctx context.Context, id Identity, known bool) context.Context {
+ md, ok := metadata.FromOutgoingContext(ctx)
+ if !ok {
+ md = metadata.MD{}
+ } else {
+ md = md.Copy()
+ }
+
+ for _, key := range forwardKeys {
+ delete(md, key)
+ }
+ for key, values := range ForwardIdentityMetadata(id, known) {
+ md[key] = values
+ }
+
+ return metadata.NewOutgoingContext(ctx, md)
+}
diff --git a/client/internal/ipcauth/forward_test.go b/client/internal/ipcauth/forward_test.go
new file mode 100644
index 000000000..d9adf05da
--- /dev/null
+++ b/client/internal/ipcauth/forward_test.go
@@ -0,0 +1,214 @@
+package ipcauth
+
+import (
+ "context"
+ "testing"
+
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/peer"
+)
+
+// transportCtx builds a request context as the daemon's transport credentials
+// would: the identity of whoever opened the socket, plus whatever metadata the
+// request carried.
+func transportCtx(id Identity, md metadata.MD) context.Context {
+ ctx := peer.NewContext(context.Background(), &peer.Peer{
+ AuthInfo: AuthInfo{
+ CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
+ Identity: id,
+ },
+ })
+ if md != nil {
+ ctx = metadata.NewIncomingContext(ctx, md)
+ }
+ return ctx
+}
+
+var (
+ root = Identity{UID: 0}
+ unprivUser = Identity{UID: 1000, GID: 1000}
+)
+
+// asDaemon pins which identity counts as this process for the duration of a test.
+// Without it the test binary's own uid decides, which silently changes what
+// "the gateway" means.
+func asDaemon(t *testing.T, id Identity) {
+ t.Helper()
+ prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
+ t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
+ selfIdentity, selfKnown = id, true
+ selfMayDelegate = !id.IsPrivileged()
+}
+
+func TestCallerIdentity_DirectConnections(t *testing.T) {
+ t.Run("no transport credentials is not an identity", func(t *testing.T) {
+ if _, ok := CallerIdentity(context.Background()); ok {
+ t.Fatal("a caller with no credentials must not be identified")
+ }
+ })
+
+ t.Run("a direct caller is its transport identity", func(t *testing.T) {
+ id, ok := CallerIdentity(transportCtx(unprivUser, nil))
+ if !ok || id.UID != 1000 {
+ t.Fatalf("got %v ok=%t, want uid 1000", id, ok)
+ }
+ })
+
+ // The whole point of honouring forwarded metadata only from a privileged
+ // transport peer: an unprivileged caller can set any metadata it likes on its
+ // own connection to the daemon socket.
+ t.Run("an unprivileged caller cannot forge an identity", func(t *testing.T) {
+ asDaemon(t, root)
+ forged := metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdGID, "0")
+ id, ok := CallerIdentity(transportCtx(unprivUser, forged))
+ if !ok {
+ t.Fatal("caller should still be identified, as itself")
+ }
+ if id.IsPrivileged() || id.UID != 1000 {
+ t.Fatalf("forged metadata was believed: got %v", id)
+ }
+ })
+}
+
+func TestCallerIdentity_GatewayForwarding(t *testing.T) {
+ t.Run("the gateway's client identity is used, not the gateway's own", func(t *testing.T) {
+ asDaemon(t, root)
+ md := ForwardIdentityMetadata(unprivUser, true)
+ id, ok := CallerIdentity(transportCtx(root, md))
+ if !ok {
+ t.Fatal("forwarded identity should be usable")
+ }
+ if id.IsPrivileged() || id.UID != 1000 {
+ t.Fatalf("got %v, want the forwarded uid 1000 and not privileged", id)
+ }
+ })
+
+ t.Run("a privileged gateway client stays privileged", func(t *testing.T) {
+ asDaemon(t, root)
+ md := ForwardIdentityMetadata(root, true)
+ id, ok := CallerIdentity(transportCtx(root, md))
+ if !ok || !id.IsPrivileged() {
+ t.Fatalf("got %v ok=%t, want a privileged identity", id, ok)
+ }
+ })
+
+ // A JSON socket the gateway cannot read peer credentials from (a TCP socket,
+ // say) must not make every request look like the daemon itself.
+ t.Run("an unreadable client identity is unknown, not the daemon", func(t *testing.T) {
+ asDaemon(t, root)
+ md := ForwardIdentityMetadata(Identity{}, false)
+ if _, ok := CallerIdentity(transportCtx(root, md)); ok {
+ t.Fatal("a forwarded request with no identity must not be identified")
+ }
+ })
+
+ // grpc-gateway turns Grpc-Metadata- headers into gRPC metadata and joins
+ // them ahead of its annotators' values. If an HTTP client's header survived
+ // that, this is the shape the daemon would see: the attacker's uid 0 first,
+ // the real uid second. The gateway filters those headers out, and reading a
+ // duplicated key as unknown makes the daemon safe even if it did not.
+ t.Run("a duplicated key from an injected header is not believed", func(t *testing.T) {
+ asDaemon(t, root)
+ md := metadata.MD{}
+ md.Append(mdFwd, "1")
+ md.Append(mdFwdUID, "0") // injected by the HTTP client
+ md.Append(mdFwdUID, "1000") // appended by the gateway's annotator
+ if id, ok := CallerIdentity(transportCtx(root, md)); ok {
+ t.Fatalf("injected uid was accepted: got %v", id)
+ }
+ })
+
+ t.Run("a duplicated marker is not believed either", func(t *testing.T) {
+ asDaemon(t, root)
+ md := metadata.MD{}
+ md.Append(mdFwd, "1")
+ md.Append(mdFwd, "1")
+ md.Append(mdFwdUID, "1000")
+ // A repeated marker must not be read as "not forwarded": that would
+ // authorize the request as the transport peer, which on the gateway's
+ // connection is the daemon itself.
+ if id, ok := CallerIdentity(transportCtx(root, md)); ok {
+ t.Fatalf("a duplicated marker was believed: got %v", id)
+ }
+ })
+
+ // The layers in front of this (the gateway's header matcher, and its
+ // interceptor replacing every forwarding key) are what keep outside metadata
+ // from arriving at all. The proof is what the daemon can check for itself, and
+ // it is the only defence that works for a value whose legitimate shape is
+ // indistinguishable from an injected one: a lone group SID, or "elevated".
+ t.Run("forwarding metadata without this process's proof is refused", func(t *testing.T) {
+ asDaemon(t, root)
+ for name, md := range map[string]metadata.MD{
+ "no proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0"),
+ "wrong proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdProof, "deadbeef"),
+ "windows identity without a proof": metadata.Pairs(mdFwd, "1",
+ mdFwdSID, "S-1-5-21-1-2-3-1001", mdFwdGroup, sidAdministrators, mdFwdElevated, "1"),
+ } {
+ t.Run(name, func(t *testing.T) {
+ if id, ok := CallerIdentity(transportCtx(root, md)); ok {
+ t.Fatalf("unstamped forwarding metadata was believed: got %v", id)
+ }
+ })
+ }
+ })
+
+ // A caller that reaches the gateway cannot see the proof, so it cannot append
+ // a group of its own to a genuine forwarded identity: doing so would have to
+ // go through the interceptor, which replaces the whole set.
+ t.Run("a group appended to a stamped identity does not survive the interceptor", func(t *testing.T) {
+ asDaemon(t, root)
+ injected := metadata.MD{}
+ injected.Append(mdFwdGroup, sidAdministrators)
+
+ ctx := WithForwardedIdentity(metadata.NewOutgoingContext(context.Background(), injected),
+ Identity{SID: "S-1-5-21-1-2-3-1001"}, true)
+ out, ok := metadata.FromOutgoingContext(ctx)
+ if !ok {
+ t.Fatal("no outgoing metadata")
+ }
+ if groups := out.Get(mdFwdGroup); len(groups) != 0 {
+ t.Fatalf("injected group survived: %v", groups)
+ }
+ })
+}
+
+func TestIsReservedForwardKey(t *testing.T) {
+ for _, key := range forwardKeys {
+ if !IsReservedForwardKey(key) {
+ t.Errorf("%q must be reserved", key)
+ }
+ }
+
+ // grpc-gateway canonicalises header names, so the check has to be
+ // case-insensitive.
+ if !IsReservedForwardKey("X-Netbird-Fwd-Uid") {
+ t.Error("the check must be case-insensitive")
+ }
+
+ for _, key := range []string{"authorization", "x-netbird", "x-netbird-fwd-uid-extra", ""} {
+ if IsReservedForwardKey(key) {
+ t.Errorf("%q must not be reserved", key)
+ }
+ }
+}
+
+func TestForwardIdentityMetadata_AlwaysMarksForwarded(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ id Identity
+ known bool
+ }{
+ {"known unix identity", unprivUser, true},
+ {"unknown identity", Identity{}, false},
+ {"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ md := ForwardIdentityMetadata(tc.id, tc.known)
+ if got := md.Get(mdFwd); len(got) != 1 || got[0] != "1" {
+ t.Fatalf("marker = %v, want exactly one \"1\"", got)
+ }
+ })
+ }
+}
diff --git a/client/internal/ipcauth/identity.go b/client/internal/ipcauth/identity.go
new file mode 100644
index 000000000..ff70c209a
--- /dev/null
+++ b/client/internal/ipcauth/identity.go
@@ -0,0 +1,127 @@
+// Package ipcauth provides the kernel-authenticated identity of a local IPC
+// (gRPC) caller and the transport credentials that surface it into the gRPC
+// context, so the daemon can authorize individual RPCs by caller identity.
+//
+// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
+// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the
+// named-pipe client token. Platforms without a peer-identity primitive get no
+// credentials, and every consumer must fail closed when no identity is
+// available.
+package ipcauth
+
+import (
+ "context"
+ "fmt"
+ "slices"
+
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/peer"
+)
+
+// Well-known Windows SIDs that identify a fully privileged principal.
+const (
+ sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM
+ sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE
+ sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE
+ sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators
+)
+
+// Identity is the kernel-authenticated identity of a local IPC caller. The
+// zero value is not a valid identity: consumers must only use one obtained
+// with a true ok/nil error return.
+type Identity struct {
+ // UID and GID are the caller's Unix user ID and primary group ID. Both are
+ // zero on Windows, where SID is authoritative instead.
+ UID uint32
+ GID uint32
+
+ // SID is the caller's Windows security identifier, empty on Unix.
+ SID string
+
+ // Groups holds the caller's Windows group SIDs, captured from the client
+ // token at handshake time. Only groups that are enabled and not
+ // deny-only are captured, so a group listed here is one the caller can
+ // actually exercise. Empty on Unix.
+ Groups []string
+
+ // Elevated reports whether the Windows client token is elevated (running
+ // as administrator, or an administrator with UAC turned off). Always false
+ // on Unix, where privilege is uid 0.
+ Elevated bool
+
+ // PID is the caller's process ID where the platform reports it (Linux's
+ // SO_PEERCRED), and 0 where it does not. It identifies the daemon's own
+ // process dialling itself, which is what the JSON gateway does, and is never
+ // used to grant anything.
+ PID int32
+}
+
+// IsWindows reports whether this identity is a Windows principal (SID-based)
+// rather than a Unix uid/gid principal.
+func (i Identity) IsWindows() bool {
+ return i.SID != ""
+}
+
+// IsPrivileged reports whether the caller is the platform's administrative
+// principal, which is what the daemon requires for changes that cross the
+// user-to-root boundary.
+//
+// On Windows the decision comes from the caller's token rather than from
+// account names or group RIDs: an elevated token, one of the service accounts
+// the daemon itself may run as, or a token with BUILTIN\Administrators
+// enabled. A UAC-filtered administrator has that group marked deny-only, and
+// deny-only groups are dropped when the identity is captured, so such a
+// caller is correctly reported as unprivileged. Domain group memberships
+// (Domain Admins and friends) are deliberately not consulted: they say
+// nothing about what this token may do on this machine.
+func (i Identity) IsPrivileged() bool {
+ if !i.IsWindows() {
+ return i.UID == 0
+ }
+
+ if i.Elevated {
+ return true
+ }
+
+ switch i.SID {
+ case sidLocalSystem, sidLocalService, sidNetworkService:
+ return true
+ }
+
+ return slices.Contains(i.Groups, sidAdministrators)
+}
+
+// String renders the identity for audit logs and denial messages.
+func (i Identity) String() string {
+ if i.IsWindows() {
+ return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
+ }
+ return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
+}
+
+// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so
+// handlers can retrieve it from the request context via IdentityFromContext.
+type AuthInfo struct {
+ credentials.CommonAuthInfo
+ Identity Identity
+}
+
+// AuthType identifies the authentication scheme.
+func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
+
+// IdentityFromContext extracts the caller's kernel-authenticated identity from
+// the gRPC peer context. The second return value is false when no IPC
+// transport credentials were negotiated, which happens on a TCP daemon socket
+// and on platforms without a peer-identity primitive. Callers MUST fail closed
+// in that case.
+func IdentityFromContext(ctx context.Context) (Identity, bool) {
+ p, ok := peer.FromContext(ctx)
+ if !ok {
+ return Identity{}, false
+ }
+ info, ok := p.AuthInfo.(AuthInfo)
+ if !ok {
+ return Identity{}, false
+ }
+ return info.Identity, true
+}
diff --git a/client/internal/ipcauth/ownedfile.go b/client/internal/ipcauth/ownedfile.go
new file mode 100644
index 000000000..be7bf4864
--- /dev/null
+++ b/client/internal/ipcauth/ownedfile.go
@@ -0,0 +1,63 @@
+package ipcauth
+
+import (
+ "fmt"
+ "os"
+)
+
+// OpenOwnedFile opens path for reading on behalf of the IPC caller identified by
+// id, and fails unless the opened file is a regular file that id owns.
+//
+// It exists for the paths a local caller hands to the daemon over the IPC. The
+// daemon runs as root, so opening such a path unchecked lets any local user read
+// any file through it. Ownership is the invariant that keeps the daemon from
+// reading, with its own privileges, a file the caller could not read itself: a
+// symlink or hard link planted at the path resolves to a file someone else owns
+// and is refused.
+//
+// The check is made against the open descriptor rather than the path, so
+// swapping the path between the check and the read cannot change the answer.
+//
+// A privileged caller is exempt: it can read the file directly, so refusing it
+// here would protect nothing. The regular-file requirement still applies to
+// everyone, since a fifo or device planted at the path is never a log file.
+func OpenOwnedFile(id Identity, path string) (*os.File, error) {
+ f, err := openForRead(path)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := checkOwnership(id, f); err != nil {
+ if cerr := f.Close(); cerr != nil {
+ return nil, fmt.Errorf("%w (close: %v)", err, cerr)
+ }
+ return nil, err
+ }
+
+ return f, nil
+}
+
+func checkOwnership(id Identity, f *os.File) error {
+ info, err := f.Stat()
+ if err != nil {
+ return fmt.Errorf("stat %s: %w", f.Name(), err)
+ }
+
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("%s is not a regular file", f.Name())
+ }
+
+ if IsPrivilegedCaller(id) {
+ return nil
+ }
+
+ owned, err := fileOwnedBy(id, f)
+ if err != nil {
+ return fmt.Errorf("read owner of %s: %w", f.Name(), err)
+ }
+ if !owned {
+ return fmt.Errorf("%s is not owned by the caller (%s)", f.Name(), id)
+ }
+
+ return nil
+}
diff --git a/client/internal/ipcauth/ownedfile_test.go b/client/internal/ipcauth/ownedfile_test.go
new file mode 100644
index 000000000..b8178ad45
--- /dev/null
+++ b/client/internal/ipcauth/ownedfile_test.go
@@ -0,0 +1,64 @@
+package ipcauth
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// otherIdentity is an unprivileged caller that owns nothing the test creates.
+func otherIdentity(t *testing.T) Identity {
+ t.Helper()
+ if runtime.GOOS == "windows" {
+ return Identity{SID: "S-1-5-21-1-2-3-1001"}
+ }
+ return Identity{UID: uint32(os.Geteuid() + 1), GID: uint32(os.Getegid() + 1)}
+}
+
+func TestOpenOwnedFileReadsFileOwnedByCaller(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "gui-client.log")
+ require.NoError(t, os.WriteFile(path, []byte("hello"), 0600))
+
+ id, err := CurrentProcessIdentity()
+ require.NoError(t, err)
+
+ f, err := OpenOwnedFile(id, path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = f.Close() })
+
+ content, err := io.ReadAll(f)
+ require.NoError(t, err)
+ require.Equal(t, "hello", string(content))
+}
+
+func TestOpenOwnedFileRefusesFileOwnedByAnother(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "gui-client.log")
+ require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
+
+ _, err := OpenOwnedFile(otherIdentity(t), path)
+ require.ErrorContains(t, err, "not owned by the caller")
+}
+
+func TestOpenOwnedFileRefusesNonRegularFile(t *testing.T) {
+ dir := t.TempDir()
+
+ // The caller owns the directory, so this is the regular-file requirement
+ // talking, not the ownership check.
+ id, err := CurrentProcessIdentity()
+ require.NoError(t, err)
+
+ _, err = OpenOwnedFile(id, dir)
+ require.ErrorContains(t, err, "not a regular file")
+}
+
+func TestOpenOwnedFileRefusesMissingFile(t *testing.T) {
+ id, err := CurrentProcessIdentity()
+ require.NoError(t, err)
+
+ _, err = OpenOwnedFile(id, filepath.Join(t.TempDir(), "absent.log"))
+ require.Error(t, err)
+}
diff --git a/client/internal/ipcauth/ownedfile_unix.go b/client/internal/ipcauth/ownedfile_unix.go
new file mode 100644
index 000000000..4a6afcea7
--- /dev/null
+++ b/client/internal/ipcauth/ownedfile_unix.go
@@ -0,0 +1,35 @@
+//go:build !windows
+
+package ipcauth
+
+import (
+ "fmt"
+ "os"
+ "syscall"
+)
+
+// openForRead opens a caller-supplied path without following a symlink at its
+// final component and without blocking: a fifo planted at the path would
+// otherwise stall the open until a writer appears, and the daemon holds a lock
+// while it collects the file.
+func openForRead(path string) (*os.File, error) {
+ f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", path, err)
+ }
+ return f, nil
+}
+
+func fileOwnedBy(id Identity, f *os.File) (bool, error) {
+ info, err := f.Stat()
+ if err != nil {
+ return false, err
+ }
+
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return false, fmt.Errorf("no owner information in %T", info.Sys())
+ }
+
+ return stat.Uid == id.UID, nil
+}
diff --git a/client/internal/ipcauth/ownedfile_unix_test.go b/client/internal/ipcauth/ownedfile_unix_test.go
new file mode 100644
index 000000000..9e7831991
--- /dev/null
+++ b/client/internal/ipcauth/ownedfile_unix_test.go
@@ -0,0 +1,57 @@
+//go:build !windows
+
+package ipcauth
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// A symlink is the shape the arbitrary-read attempt takes: the caller owns the
+// link, the file it points at belongs to someone else.
+func TestOpenOwnedFileRefusesSymlink(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "target.log")
+ require.NoError(t, os.WriteFile(target, []byte("secret"), 0600))
+
+ link := filepath.Join(dir, "gui-client.log")
+ require.NoError(t, os.Symlink(target, link))
+
+ id, err := CurrentProcessIdentity()
+ require.NoError(t, err)
+
+ _, err = OpenOwnedFile(id, link)
+ // O_NOFOLLOW on a symlink reports ELOOP on Linux/Darwin and EMLINK on FreeBSD.
+ if !errors.Is(err, syscall.ELOOP) && !errors.Is(err, syscall.EMLINK) {
+ t.Fatalf("symlink open: got %v, want ELOOP or EMLINK", err)
+ }
+}
+
+// A fifo would block the open until a writer showed up, stalling the daemon
+// while it holds its lock.
+func TestOpenOwnedFileRefusesFifoWithoutBlocking(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "gui-client.log")
+ require.NoError(t, syscall.Mkfifo(path, 0600))
+
+ id, err := CurrentProcessIdentity()
+ require.NoError(t, err)
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := OpenOwnedFile(id, path)
+ done <- err
+ }()
+
+ select {
+ case err := <-done:
+ require.ErrorContains(t, err, "not a regular file")
+ case <-time.After(5 * time.Second):
+ t.Fatal("opening a fifo blocked")
+ }
+}
diff --git a/client/internal/ipcauth/ownedfile_windows.go b/client/internal/ipcauth/ownedfile_windows.go
new file mode 100644
index 000000000..19acaec4d
--- /dev/null
+++ b/client/internal/ipcauth/ownedfile_windows.go
@@ -0,0 +1,59 @@
+//go:build windows
+
+package ipcauth
+
+import (
+ "fmt"
+ "os"
+
+ "golang.org/x/sys/windows"
+)
+
+// openForRead opens a caller-supplied path without following a reparse point at
+// it. FILE_FLAG_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW: it
+// opens a symlink/junction itself rather than its target, so the regular-file
+// check in checkOwnership refuses a link the caller planted to redirect the
+// read. FILE_FLAG_BACKUP_SEMANTICS lets a directory open too (as os.Open does),
+// so a directory planted at the path is refused as non-regular rather than
+// erroring here. The share mode matches os.Open so a log being written stays
+// openable.
+func openForRead(path string) (*os.File, error) {
+ p, err := windows.UTF16PtrFromString(path)
+ if err != nil {
+ return nil, fmt.Errorf("convert path %s: %w", path, err)
+ }
+
+ handle, err := windows.CreateFile(
+ p,
+ windows.GENERIC_READ,
+ windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
+ nil,
+ windows.OPEN_EXISTING,
+ windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS,
+ 0,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", path, err)
+ }
+
+ return os.NewFile(uintptr(handle), path), nil
+}
+
+// fileOwnedBy compares the file's owner SID with the caller's. Files an elevated
+// process creates are owned by BUILTIN\Administrators rather than by the user,
+// but such a caller is privileged and never reaches this check.
+func fileOwnedBy(id Identity, f *os.File) (bool, error) {
+ // x/sys/windows GetSecurityInfo frees the OS buffer itself and returns a
+ // Go-heap copy, so there is nothing to LocalFree here.
+ sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
+ if err != nil {
+ return false, fmt.Errorf("read security info: %w", err)
+ }
+
+ owner, _, err := sd.Owner()
+ if err != nil {
+ return false, fmt.Errorf("read owner: %w", err)
+ }
+
+ return id.SID != "" && owner.String() == id.SID, nil
+}
diff --git a/client/internal/ipcauth/ownedfile_windows_test.go b/client/internal/ipcauth/ownedfile_windows_test.go
new file mode 100644
index 000000000..ab68fdf39
--- /dev/null
+++ b/client/internal/ipcauth/ownedfile_windows_test.go
@@ -0,0 +1,78 @@
+//go:build windows
+
+package ipcauth
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "golang.org/x/sys/windows"
+)
+
+// fileOwnerSID reads the owner SID of path the same way OpenOwnedFile does, so
+// the test can construct an Identity that matches (or deliberately does not).
+func fileOwnerSID(t *testing.T, path string) string {
+ t.Helper()
+ f, err := os.Open(path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = f.Close() })
+
+ sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
+ require.NoError(t, err)
+ owner, _, err := sd.Owner()
+ require.NoError(t, err)
+ return owner.String()
+}
+
+// The allow branch of fileOwnedBy is the SID-equality path the legitimate GUI
+// flow depends on. Running elevated, a created file is owned by
+// BUILTIN\Administrators; an Identity carrying that SID with Elevated=false and
+// no groups is unprivileged by IsPrivileged (which reads the token, not the
+// SID's RID), so this exercises the real GetSecurityInfo equality rather than
+// the privileged-caller shortcut.
+func TestOpenOwnedFileWindowsOwnerMatchAllows(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "gui-client.log")
+ require.NoError(t, os.WriteFile(path, []byte("hello"), 0600))
+
+ ownerSID := fileOwnerSID(t, path)
+ id := Identity{SID: ownerSID}
+ require.False(t, id.IsPrivileged(), "identity built from the owner SID must be unprivileged for this to test the match path")
+
+ f, err := OpenOwnedFile(id, path)
+ require.NoError(t, err)
+ _ = f.Close()
+}
+
+func TestOpenOwnedFileWindowsOwnerMismatchRefuses(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "gui-client.log")
+ require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
+
+ other := Identity{SID: "S-1-5-21-9-9-9-9999"}
+ require.False(t, other.IsPrivileged())
+
+ _, err := OpenOwnedFile(other, path)
+ require.ErrorContains(t, err, "not owned by the caller")
+}
+
+// FILE_FLAG_OPEN_REPARSE_POINT must make OpenOwnedFile refuse a symlink the same
+// way O_NOFOLLOW does on Unix, so a planted link can't redirect the read to
+// another file. Creating a symlink needs a privilege the runner may lack, so the
+// test skips rather than fails when it can't.
+func TestOpenOwnedFileWindowsRefusesSymlink(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "target.log")
+ require.NoError(t, os.WriteFile(target, []byte("secret"), 0600))
+
+ link := filepath.Join(dir, "gui-client.log")
+ if err := os.Symlink(target, link); err != nil {
+ t.Skipf("cannot create symlink (privilege not held?): %v", err)
+ }
+
+ id, err := CurrentProcessIdentity()
+ require.NoError(t, err)
+
+ _, err = OpenOwnedFile(id, link)
+ require.Error(t, err, "a symlink must be refused")
+}
diff --git a/client/internal/ipcauth/peercred_bsd.go b/client/internal/ipcauth/peercred_bsd.go
new file mode 100644
index 000000000..6d9c5247f
--- /dev/null
+++ b/client/internal/ipcauth/peercred_bsd.go
@@ -0,0 +1,43 @@
+//go:build darwin || freebsd
+
+package ipcauth
+
+import (
+ "fmt"
+ "net"
+
+ "golang.org/x/sys/unix"
+)
+
+// PeerIdentity reads the kernel-authenticated identity of the process on the
+// other end of a Unix socket via LOCAL_PEERCRED. The xucred is recorded by the
+// kernel at connect() time and carries the peer's uid and its group list, of
+// which the first entry is the primary group.
+func PeerIdentity(conn net.Conn) (Identity, error) {
+ uc, ok := conn.(*net.UnixConn)
+ if !ok {
+ return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
+ }
+
+ raw, err := uc.SyscallConn()
+ if err != nil {
+ return Identity{}, fmt.Errorf("raw conn: %w", err)
+ }
+
+ var cred *unix.Xucred
+ var credErr error
+ if err := raw.Control(func(fd uintptr) {
+ cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
+ }); err != nil {
+ return Identity{}, fmt.Errorf("control raw conn: %w", err)
+ }
+ if credErr != nil {
+ return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr)
+ }
+
+ id := Identity{UID: cred.Uid}
+ if cred.Ngroups > 0 {
+ id.GID = cred.Groups[0]
+ }
+ return id, nil
+}
diff --git a/client/internal/ipcauth/peercred_linux.go b/client/internal/ipcauth/peercred_linux.go
new file mode 100644
index 000000000..417cc1e00
--- /dev/null
+++ b/client/internal/ipcauth/peercred_linux.go
@@ -0,0 +1,39 @@
+//go:build linux
+
+package ipcauth
+
+import (
+ "fmt"
+ "net"
+
+ "golang.org/x/sys/unix"
+)
+
+// PeerIdentity reads the kernel-authenticated identity of the process on the
+// other end of a Unix socket via SO_PEERCRED. The credentials are recorded by
+// the kernel at connect() time and cannot be changed for the life of the
+// connection, so they are not spoofable by the caller.
+func PeerIdentity(conn net.Conn) (Identity, error) {
+ uc, ok := conn.(*net.UnixConn)
+ if !ok {
+ return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
+ }
+
+ raw, err := uc.SyscallConn()
+ if err != nil {
+ return Identity{}, fmt.Errorf("raw conn: %w", err)
+ }
+
+ var cred *unix.Ucred
+ var credErr error
+ if err := raw.Control(func(fd uintptr) {
+ cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
+ }); err != nil {
+ return Identity{}, fmt.Errorf("control raw conn: %w", err)
+ }
+ if credErr != nil {
+ return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr)
+ }
+
+ return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil
+}
diff --git a/client/internal/ipcauth/pipeserver_windows.go b/client/internal/ipcauth/pipeserver_windows.go
new file mode 100644
index 000000000..7ba59d574
--- /dev/null
+++ b/client/internal/ipcauth/pipeserver_windows.go
@@ -0,0 +1,87 @@
+//go:build windows
+
+package ipcauth
+
+import (
+ "fmt"
+ "net"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows"
+)
+
+// PipeServerTrusted reports an error unless the pipe behind conn was created by a
+// principal this client may hand secrets to. Clients call it for a pipe whose name
+// carries no guarantee of its own, which is any name outside the
+// ProtectedPrefix\Administrators namespace: that namespace already restricts
+// creation to administrators and LocalSystem, while a plain name can be created by
+// any local user before the daemon gets there.
+//
+// The decision is made from the pipe object's owner, not from the serving process,
+// because a client cannot open a process running as another user at all, and the
+// legitimate case is precisely an unprivileged client talking to a privileged
+// daemon. Trusted owners are the service accounts, BUILTIN\Administrators, and
+// this client's own user, the last of which is the daemon a user runs themselves
+// as in netstack mode. A pipe owned by anyone else gets no setup key, pre-shared
+// key or SSO prompt out of this client.
+func PipeServerTrusted(conn net.Conn) error {
+ // go-winio's pipe connection embeds *win32File, which exposes Fd().
+ fdConn, ok := conn.(interface{ Fd() uintptr })
+ if !ok {
+ return fmt.Errorf("connection %T does not expose a pipe handle", conn)
+ }
+
+ owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
+ if err != nil {
+ return err
+ }
+
+ if !trustedPipeOwner(owner) {
+ return fmt.Errorf("pipe owned by %s, which is neither an administrator nor this user", owner)
+ }
+ return nil
+}
+
+// PipeOwnedBySelf reports whether the pipe behind conn was created by this very
+// user, which is how a client recognises a daemon running as itself. Ownership it
+// cannot read is reported as false.
+func PipeOwnedBySelf(conn net.Conn) bool {
+ fdConn, ok := conn.(interface{ Fd() uintptr })
+ if !ok {
+ return false
+ }
+
+ owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
+ if err != nil {
+ log.Debugf("read daemon pipe owner: %v", err)
+ return false
+ }
+ return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
+}
+
+// pipeOwnerSID reads the owner of the pipe object a client is connected to. The
+// handle was opened with GENERIC_READ, which includes READ_CONTROL, so no extra
+// access is needed.
+func pipeOwnerSID(handle windows.Handle) (string, error) {
+ sd, err := windows.GetSecurityInfo(handle, windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION)
+ if err != nil {
+ return "", fmt.Errorf("read pipe security info: %w", err)
+ }
+
+ owner, _, err := sd.Owner()
+ if err != nil {
+ return "", fmt.Errorf("read pipe owner: %w", err)
+ }
+ return owner.String(), nil
+}
+
+// trustedPipeOwner reports whether a pipe's owner is a principal a client may
+// speak to. An elevated process's objects are owned by BUILTIN\Administrators by
+// default, an unelevated one's by the user, which is why both forms appear here.
+func trustedPipeOwner(owner string) bool {
+ switch owner {
+ case sidLocalSystem, sidLocalService, sidNetworkService, sidAdministrators:
+ return true
+ }
+ return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
+}
diff --git a/client/internal/ipcauth/privileged.go b/client/internal/ipcauth/privileged.go
new file mode 100644
index 000000000..95f2a50e9
--- /dev/null
+++ b/client/internal/ipcauth/privileged.go
@@ -0,0 +1,125 @@
+package ipcauth
+
+import (
+ "os"
+ "runtime"
+)
+
+// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it
+// raises for an operation that requires root/administrator. Clients match on
+// Reason and Domain rather than on the message text, and render the summary and
+// command themselves so the user gets guidance instead of a gRPC error dump.
+const (
+ // ErrorReasonPrivilegeRequired identifies the detail.
+ ErrorReasonPrivilegeRequired = "PRIVILEGE_REQUIRED"
+ // ErrorDomain scopes the reason to the NetBird daemon.
+ ErrorDomain = "daemon.netbird.io"
+ // ErrorMetaSummary is the one-sentence explanation of what was refused.
+ ErrorMetaSummary = "summary"
+ // ErrorMetaCommand is the command that performs the same operation with the
+ // privileges it needs, ready to copy and run.
+ ErrorMetaCommand = "command"
+)
+
+// The identity of the process evaluating callers, captured once because it cannot
+// change. selfKnown is false when it could not be read, in which case nothing is
+// ever treated as this process. selfMayDelegate additionally requires this
+// process to be unprivileged: see IsPrivilegedCaller.
+var (
+ selfIdentity Identity
+ selfKnown bool
+ selfMayDelegate bool
+ // selfPID is this process's PID, used to recognise the daemon dialling itself.
+ selfPID = os.Getpid()
+)
+
+func init() {
+ id, err := CurrentProcessIdentity()
+ if err != nil {
+ return
+ }
+ selfIdentity, selfKnown = id, true
+ // Only an unprivileged daemon delegates its authority to its own identity.
+ // When it is root or LocalSystem, sharing its identity does not mean sharing
+ // its power: on Windows a filtered and a full token carry the same SID, so
+ // matching there would let a non-elevated shell of an administrator account
+ // act as an administrator, which is the boundary the token check exists to
+ // keep.
+ selfMayDelegate = !id.IsPrivileged()
+}
+
+// IsDaemonSelf reports whether an identity is this very process. The JSON gateway
+// runs inside the daemon and re-dials it locally, so this is what distinguishes
+// the gateway from any other caller, whatever user the daemon runs as.
+func IsDaemonSelf(id Identity) bool {
+ if !selfKnown || id.IsWindows() != selfIdentity.IsWindows() {
+ return false
+ }
+ if id.IsWindows() {
+ return id.SID != "" && id.SID == selfIdentity.SID
+ }
+ return id.UID == selfIdentity.UID
+}
+
+// IsPrivilegedCaller reports whether an identity may make the changes the daemon
+// restricts to the platform administrator. This is the daemon's own rule and
+// cannot be evaluated by a client, which does not know what the daemon runs as.
+//
+// Beyond root/administrator it accepts a caller running as the daemon's own
+// identity when the daemon is itself unprivileged. That keeps a rootless container
+// working, where there is no uid 0 at all, and a Windows daemon in netstack mode,
+// which needs no administrator rights. In those setups a caller sharing the
+// daemon's identity can already rewrite the config files it reads and replace the
+// binary it runs, so refusing it a config change would protect nothing; and an
+// unprivileged daemon cannot hand out a root shell in the first place.
+func IsPrivilegedCaller(id Identity) bool {
+ if id.IsPrivileged() {
+ return true
+ }
+ return selfMayDelegate && IsDaemonSelf(id)
+}
+
+// SelfDelegatesTo returns the identity this process delegates its authority to,
+// and whether it delegates at all. Only an unprivileged daemon does: see
+// IsPrivilegedCaller. It exists so a refusal can name who may actually perform the
+// operation, because on such a host root is neither required nor necessarily
+// available.
+func SelfDelegatesTo() (Identity, bool) {
+ if !selfKnown || !selfMayDelegate {
+ return Identity{}, false
+ }
+ return selfIdentity, true
+}
+
+// PrivilegedActor names the principal a privileged operation requires, for use
+// in messages shown to the user.
+func PrivilegedActor() string {
+ if runtime.GOOS == "windows" {
+ return "administrator privileges"
+ }
+ return "root"
+}
+
+// ElevatedCommand renders a command so that running it grants the privileges the
+// operation needs. Windows has no in-line equivalent of sudo, so the command is
+// returned unchanged and the user is expected to run it from an elevated
+// terminal.
+func ElevatedCommand(command string) string {
+ if runtime.GOOS == "windows" {
+ return command
+ }
+ return "sudo " + command
+}
+
+// UpCommand renders an elevated `netbird up` with the given flags, preceded by a
+// `down`. The down is what makes the command work on a connected client: `netbird
+// up` prints "Already connected" and returns without applying any config flag, so
+// on its own the command would appear to do nothing. It is a no-op, exit 0, when
+// the client is not connected.
+//
+// ";" rather than "&&" so the line can be pasted into any of the shells a user
+// might have: PowerShell 5.1, still the default on Windows Server, rejects "&&"
+// as a syntax error.
+func UpCommand(flags string) string {
+ return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags)
+}
diff --git a/client/internal/ipcauth/privileged_test.go b/client/internal/ipcauth/privileged_test.go
new file mode 100644
index 000000000..c1c7c1543
--- /dev/null
+++ b/client/internal/ipcauth/privileged_test.go
@@ -0,0 +1,134 @@
+package ipcauth
+
+import "testing"
+
+// The self rule is the one place privilege is granted to something other than the
+// platform administrator, so its two guards matter: it must apply only when the
+// daemon is itself unprivileged, and only to a caller with the daemon's identity.
+func TestIsPrivilegedCaller_SelfRule(t *testing.T) {
+ tests := []struct {
+ name string
+ // self stands in for the process the daemon runs as.
+ self Identity
+ selfKnown bool
+ caller Identity
+ want bool
+ }{
+ {
+ name: "root is privileged whatever the daemon runs as",
+ self: Identity{UID: 1000},
+ selfKnown: true,
+ caller: Identity{UID: 0},
+ want: true,
+ },
+ {
+ name: "an unprivileged daemon delegates to its own user (rootless container)",
+ self: Identity{UID: 1000},
+ selfKnown: true,
+ caller: Identity{UID: 1000},
+ want: true,
+ },
+ {
+ name: "an unprivileged daemon delegates to nobody else",
+ self: Identity{UID: 1000},
+ selfKnown: true,
+ caller: Identity{UID: 1001},
+ want: false,
+ },
+ {
+ // The daemon is root on a normal install, so sharing its identity is
+ // already covered by being root; nothing else may match.
+ name: "a root daemon delegates to nobody",
+ self: Identity{UID: 0},
+ selfKnown: true,
+ caller: Identity{UID: 1000},
+ want: false,
+ },
+ {
+ // Windows netstack mode: the daemon needs no administrator rights.
+ name: "an unprivileged windows daemon delegates to its own SID",
+ self: Identity{SID: "S-1-5-21-1-2-3-1001"},
+ selfKnown: true,
+ caller: Identity{SID: "S-1-5-21-1-2-3-1001"},
+ want: true,
+ },
+ {
+ name: "an unprivileged windows daemon delegates to no other SID",
+ self: Identity{SID: "S-1-5-21-1-2-3-1001"},
+ selfKnown: true,
+ caller: Identity{SID: "S-1-5-21-1-2-3-1002"},
+ want: false,
+ },
+ {
+ // The UAC boundary: a filtered and a full token of the same account
+ // carry the same SID but not the same power, so an elevated daemon must
+ // never delegate to its own SID.
+ name: "an elevated windows daemon does not delegate to its own SID",
+ self: Identity{SID: "S-1-5-21-1-2-3-500", Elevated: true},
+ selfKnown: true,
+ caller: Identity{SID: "S-1-5-21-1-2-3-500"},
+ want: false,
+ },
+ {
+ name: "LocalSystem is privileged on its own merits, not by delegation",
+ self: Identity{SID: sidLocalSystem},
+ selfKnown: true,
+ caller: Identity{SID: sidLocalSystem},
+ want: true, // LocalSystem is privileged on its own merits
+ },
+ {
+ name: "identities of different kinds never match",
+ self: Identity{UID: 1000},
+ selfKnown: true,
+ caller: Identity{SID: "S-1-5-21-1-2-3-1001"},
+ want: false,
+ },
+ {
+ name: "an unknown self identity delegates to nobody",
+ self: Identity{},
+ selfKnown: false,
+ caller: Identity{UID: 1000},
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
+ t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
+
+ selfIdentity, selfKnown = tt.self, tt.selfKnown
+ selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged()
+
+ if got := IsPrivilegedCaller(tt.caller); got != tt.want {
+ t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t",
+ tt.caller, tt.self, got, tt.want)
+ }
+ })
+ }
+}
+
+// The real process must never accidentally delegate: a test binary running as a
+// normal user is unprivileged, so it may match itself, but nothing else.
+func TestIsPrivilegedCaller_ThisProcess(t *testing.T) {
+ id, err := CurrentProcessIdentity()
+ if err != nil {
+ t.Skipf("cannot read this process's identity: %v", err)
+ }
+
+ // This process is always allowed to act as itself: either it is privileged, or
+ // it is unprivileged and therefore delegates to its own identity.
+ if !IsPrivilegedCaller(id) {
+ t.Errorf("this process %v was refused its own identity", id)
+ }
+
+ // A caller that is neither root nor this process must be refused, whatever
+ // this process happens to be.
+ other := Identity{UID: id.UID + 1}
+ if id.IsWindows() {
+ other = Identity{SID: id.SID + "9"}
+ }
+ if IsPrivilegedCaller(other) {
+ t.Errorf("an unrelated identity %v was treated as privileged", other)
+ }
+}
diff --git a/client/internal/ipcauth/self_unix.go b/client/internal/ipcauth/self_unix.go
new file mode 100644
index 000000000..1b86c4fc0
--- /dev/null
+++ b/client/internal/ipcauth/self_unix.go
@@ -0,0 +1,17 @@
+//go:build !windows
+
+package ipcauth
+
+import "os"
+
+// CurrentProcessIdentity returns this process's identity as the daemon would
+// see it if this process connected to the local IPC. It lets a client (the UI)
+// decide up front whether a privileged operation can succeed, without a
+// round-trip and without duplicating the rules: the answer comes from the same
+// Identity.IsPrivileged the daemon applies.
+func CurrentProcessIdentity() (Identity, error) {
+ return Identity{
+ UID: uint32(os.Geteuid()),
+ GID: uint32(os.Getegid()),
+ }, nil
+}
diff --git a/client/internal/ipcauth/self_windows.go b/client/internal/ipcauth/self_windows.go
new file mode 100644
index 000000000..5474cc101
--- /dev/null
+++ b/client/internal/ipcauth/self_windows.go
@@ -0,0 +1,35 @@
+//go:build windows
+
+package ipcauth
+
+import (
+ "fmt"
+
+ "golang.org/x/sys/windows"
+)
+
+// CurrentProcessIdentity returns this process's identity as the daemon would see
+// it if this process connected to the local IPC. It lets a client (the UI)
+// decide up front whether a privileged operation can succeed, without a
+// round-trip and without duplicating the rules: the answer comes from the same
+// Identity.IsPrivileged the daemon applies to the token it reads off the pipe.
+func CurrentProcessIdentity() (Identity, error) {
+ // A pseudo-token, so it must not be closed.
+ token := windows.GetCurrentProcessToken()
+
+ user, err := token.GetTokenUser()
+ if err != nil {
+ return Identity{}, fmt.Errorf("read token user: %w", err)
+ }
+
+ groups, err := tokenGroupSIDs(token)
+ if err != nil {
+ return Identity{}, err
+ }
+
+ return Identity{
+ SID: user.User.Sid.String(),
+ Groups: groups,
+ Elevated: token.IsElevated(),
+ }, nil
+}
diff --git a/client/internal/lazyconn/activity/listener_bind.go b/client/internal/lazyconn/activity/listener_bind.go
index 60b8baadb..72a0cfc76 100644
--- a/client/internal/lazyconn/activity/listener_bind.go
+++ b/client/internal/lazyconn/activity/listener_bind.go
@@ -119,15 +119,16 @@ func (d *BindListener) ReadPackets() {
}
d.peerCfg.Log.Debugf("removing lazy endpoint for peer %s", d.peerCfg.PublicKey)
- if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil {
- d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err)
- }
-
_ = d.lazyConn.Close()
d.bind.RemoveEndpoint(d.fakeIP)
d.done.Done()
}
+// CapturedPacket is unused in userspace bind mode: first-packet reinjection is kernel-only.
+func (d *BindListener) CapturedPacket() []byte {
+ return nil
+}
+
// Close stops the listener and cleans up resources.
func (d *BindListener) Close() {
d.peerCfg.Log.Infof("closing activity listener (LazyConn)")
diff --git a/client/internal/lazyconn/activity/listener_bind_test.go b/client/internal/lazyconn/activity/listener_bind_test.go
index 1baaae6be..7026a9c97 100644
--- a/client/internal/lazyconn/activity/listener_bind_test.go
+++ b/client/internal/lazyconn/activity/listener_bind_test.go
@@ -45,10 +45,6 @@ type MockWGIfaceBind struct {
endpointMgr *mockEndpointManager
}
-func (m *MockWGIfaceBind) RemovePeer(string) error {
- return nil
-}
-
func (m *MockWGIfaceBind) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
return nil
}
@@ -68,6 +64,10 @@ func (m *MockWGIfaceBind) GetBind() device.EndpointManager {
return m.endpointMgr
}
+func (m *MockWGIfaceBind) MTU() uint16 {
+ return 1280
+}
+
func TestBindListener_Creation(t *testing.T) {
mockEndpointMgr := newMockEndpointManager()
mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr}
@@ -207,8 +207,9 @@ func TestManager_BindMode(t *testing.T) {
require.NoError(t, err)
select {
- case peerConnID := <-mgr.OnActivityChan:
- assert.Equal(t, cfg.PeerConnID, peerConnID, "Received peer connection ID should match")
+ case ev := <-mgr.OnActivityChan:
+ assert.Equal(t, cfg.PeerConnID, ev.PeerConnID, "Received peer connection ID should match")
+ assert.Nil(t, ev.FirstPacket, "Bind mode does not capture packets: reinjection is kernel-only")
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for activity notification")
}
@@ -266,8 +267,8 @@ func TestManager_BindMode_MultiplePeers(t *testing.T) {
receivedPeers := make(map[peerid.ConnID]bool)
for i := 0; i < 2; i++ {
select {
- case peerConnID := <-mgr.OnActivityChan:
- receivedPeers[peerConnID] = true
+ case ev := <-mgr.OnActivityChan:
+ receivedPeers[ev.PeerConnID] = true
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for activity notifications")
}
diff --git a/client/internal/lazyconn/activity/listener_udp.go b/client/internal/lazyconn/activity/listener_udp.go
index e0b09be6c..4b7e0ddf7 100644
--- a/client/internal/lazyconn/activity/listener_udp.go
+++ b/client/internal/lazyconn/activity/listener_udp.go
@@ -3,11 +3,13 @@ package activity
import (
"fmt"
"net"
+ "slices"
"sync"
"sync/atomic"
log "github.com/sirupsen/logrus"
+ "github.com/netbirdio/netbird/client/iface/bufsize"
"github.com/netbirdio/netbird/client/internal/lazyconn"
)
@@ -20,6 +22,8 @@ type UDPListener struct {
done sync.Mutex
isClosed atomic.Bool
+
+ capturedPacket []byte
}
// NewUDPListener creates a listener that detects activity via UDP socket reads.
@@ -46,9 +50,13 @@ func NewUDPListener(wgIface WgInterface, cfg lazyconn.PeerConfig) (*UDPListener,
}
// ReadPackets blocks reading from the UDP socket until activity is detected or the listener is closed.
+// The first packet that triggers activity is captured so it can be reinjected through the real
+// transport once it is established. Without this, kernel WireGuard's handshake initiation would be
+// dropped and WG would only retry after REKEY_TIMEOUT.
func (d *UDPListener) ReadPackets() {
for {
- n, remoteAddr, err := d.conn.ReadFromUDP(make([]byte, 1))
+ buf := make([]byte, int(d.wgIface.MTU())+bufsize.WGBufferOverhead)
+ n, remoteAddr, err := d.conn.ReadFromUDP(buf)
if err != nil {
if d.isClosed.Load() {
d.peerCfg.Log.Infof("exit from activity listener")
@@ -62,20 +70,24 @@ func (d *UDPListener) ReadPackets() {
d.peerCfg.Log.Warnf("received %d bytes from %s, too short", n, remoteAddr)
continue
}
- d.peerCfg.Log.Infof("activity detected")
+ d.capturedPacket = slices.Clone(buf[:n])
+ d.peerCfg.Log.Infof("activity detected, captured %d bytes for reinjection", n)
break
}
- d.peerCfg.Log.Debugf("removing lazy endpoint: %s", d.endpoint.String())
- if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil {
- d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err)
- }
-
- // Ignore close error as it may return "use of closed network connection" if already closed.
+ // Leave the peer in place. ConfigureWGEndpoint will UpdatePeer with the real endpoint;
+ // removing the peer here wipes kernel WG's staged queue and drops the user packet that
+ // triggered activation.
_ = d.conn.Close()
d.done.Unlock()
}
+// CapturedPacket returns the first packet that triggered activity, or nil if none was captured.
+// Safe to call after ReadPackets returns.
+func (d *UDPListener) CapturedPacket() []byte {
+ return d.capturedPacket
+}
+
// Close stops the listener and cleans up resources.
func (d *UDPListener) Close() {
d.peerCfg.Log.Infof("closing activity listener: %s", d.conn.LocalAddr().String())
diff --git a/client/internal/lazyconn/activity/manager.go b/client/internal/lazyconn/activity/manager.go
index cccc0669f..9de8c0fa7 100644
--- a/client/internal/lazyconn/activity/manager.go
+++ b/client/internal/lazyconn/activity/manager.go
@@ -19,17 +19,25 @@ import (
type listener interface {
ReadPackets()
Close()
+ CapturedPacket() []byte
+}
+
+// Event reports activity on a managed peer. FirstPacket is the bytes that triggered activation,
+// captured for reinjection through the real transport.
+type Event struct {
+ PeerConnID peerid.ConnID
+ FirstPacket []byte
}
type WgInterface interface {
- RemovePeer(peerKey string) error
UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
IsUserspaceBind() bool
Address() wgaddr.Address
+ MTU() uint16
}
type Manager struct {
- OnActivityChan chan peerid.ConnID
+ OnActivityChan chan Event
wgIface WgInterface
@@ -41,7 +49,7 @@ type Manager struct {
func NewManager(wgIface WgInterface) *Manager {
m := &Manager{
- OnActivityChan: make(chan peerid.ConnID, 1),
+ OnActivityChan: make(chan Event, 1),
wgIface: wgIface,
peers: make(map[peerid.ConnID]listener),
done: make(chan struct{}),
@@ -116,12 +124,12 @@ func (m *Manager) waitForTraffic(l listener, peerConnID peerid.ConnID) {
delete(m.peers, peerConnID)
m.mu.Unlock()
- m.notify(peerConnID)
+ m.notify(Event{PeerConnID: peerConnID, FirstPacket: l.CapturedPacket()})
}
-func (m *Manager) notify(peerConnID peerid.ConnID) {
+func (m *Manager) notify(ev Event) {
select {
case <-m.done:
- case m.OnActivityChan <- peerConnID:
+ case m.OnActivityChan <- ev:
}
}
diff --git a/client/internal/lazyconn/activity/manager_test.go b/client/internal/lazyconn/activity/manager_test.go
index 0768d9219..07dd8d84c 100644
--- a/client/internal/lazyconn/activity/manager_test.go
+++ b/client/internal/lazyconn/activity/manager_test.go
@@ -1,6 +1,7 @@
package activity
import (
+ "bytes"
"net"
"net/netip"
"testing"
@@ -25,10 +26,6 @@ func (m *MocPeer) ConnID() peerid.ConnID {
type MocWGIface struct {
}
-func (m MocWGIface) RemovePeer(string) error {
- return nil
-}
-
func (m MocWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
return nil
}
@@ -44,6 +41,10 @@ func (m MocWGIface) Address() wgaddr.Address {
}
}
+func (m MocWGIface) MTU() uint16 {
+ return 1280
+}
+
// GetPeerListener is a test helper to access listeners
func (m *Manager) GetPeerListener(peerConnID peerid.ConnID) (listener, bool) {
m.mu.Lock()
@@ -86,11 +87,15 @@ func TestManager_MonitorPeerActivity(t *testing.T) {
}
select {
- case peerConnID := <-mgr.OnActivityChan:
- if peerConnID != peerCfg1.PeerConnID {
- t.Fatalf("unexpected peerConnID: %v", peerConnID)
+ case ev := <-mgr.OnActivityChan:
+ if ev.PeerConnID != peerCfg1.PeerConnID {
+ t.Fatalf("unexpected peerConnID: %v", ev.PeerConnID)
+ }
+ if !bytes.Equal(ev.FirstPacket, []byte{0x01, 0x02, 0x03, 0x04, 0x05}) {
+ t.Fatalf("unexpected first packet: %v", ev.FirstPacket)
}
case <-time.After(1 * time.Second):
+ t.Fatal("timed out waiting for activity")
}
}
diff --git a/client/internal/lazyconn/env.go b/client/internal/lazyconn/env.go
index 649d1cd65..d408083e7 100644
--- a/client/internal/lazyconn/env.go
+++ b/client/internal/lazyconn/env.go
@@ -3,24 +3,57 @@ package lazyconn
import (
"os"
"strconv"
+ "strings"
log "github.com/sirupsen/logrus"
)
const (
- EnvEnableLazyConn = "NB_ENABLE_EXPERIMENTAL_LAZY_CONN"
+ EnvLazyConn = "NB_LAZY_CONN"
EnvInactivityThreshold = "NB_LAZY_CONN_INACTIVITY_THRESHOLD"
)
-func IsLazyConnEnabledByEnv() bool {
- val := os.Getenv(EnvEnableLazyConn)
- if val == "" {
- return false
- }
- enabled, err := strconv.ParseBool(val)
- if err != nil {
- log.Warnf("failed to parse %s: %v", EnvEnableLazyConn, err)
- return false
- }
- return enabled
+// State is the tri-state local override for lazy connections read from the environment.
+type State int
+
+const (
+ // StateUnset means no local override; defer to the management feature flag.
+ StateUnset State = iota
+ // StateOn forces lazy connections on, overriding management.
+ StateOn
+ // StateOff forces lazy connections off, overriding management.
+ StateOff
+)
+
+// EnvState reads NB_LAZY_CONN and returns the local override state.
+func EnvState() State {
+ return ParseState(os.Getenv(EnvLazyConn))
+}
+
+// ParseState interprets a lazy-connection override value (from the environment or an MDM
+// policy). It accepts the on/off aliases plus any value strconv.ParseBool understands
+// (true/false/1/0). An empty or unrecognized value returns StateUnset so that the
+// management feature flag remains in control.
+func ParseState(raw string) State {
+ if raw == "" {
+ return StateUnset
+ }
+
+ normalized := strings.ToLower(strings.TrimSpace(raw))
+ switch normalized {
+ case "on":
+ return StateOn
+ case "off":
+ return StateOff
+ }
+
+ enabled, err := strconv.ParseBool(normalized)
+ if err != nil {
+ log.Warnf("failed to parse lazy connection value %q (from %s env or MDM policy): %v", raw, EnvLazyConn, err)
+ return StateUnset
+ }
+ if enabled {
+ return StateOn
+ }
+ return StateOff
}
diff --git a/client/internal/lazyconn/env_test.go b/client/internal/lazyconn/env_test.go
new file mode 100644
index 000000000..59ee40c4b
--- /dev/null
+++ b/client/internal/lazyconn/env_test.go
@@ -0,0 +1,45 @@
+package lazyconn
+
+import (
+ "os"
+ "testing"
+)
+
+func TestEnvState(t *testing.T) {
+ tests := []struct {
+ value string
+ set bool
+ want State
+ }{
+ {set: false, want: StateUnset},
+ {value: "", set: true, want: StateUnset},
+ {value: "on", set: true, want: StateOn},
+ {value: "ON", set: true, want: StateOn},
+ {value: "true", set: true, want: StateOn},
+ {value: "1", set: true, want: StateOn},
+ {value: " on ", set: true, want: StateOn},
+ {value: "off", set: true, want: StateOff},
+ {value: "OFF", set: true, want: StateOff},
+ {value: "false", set: true, want: StateOff},
+ {value: "0", set: true, want: StateOff},
+ {value: "auto", set: true, want: StateUnset},
+ {value: "garbage", set: true, want: StateUnset},
+ }
+
+ for _, tt := range tests {
+ name := tt.value
+ if !tt.set {
+ name = "unset"
+ }
+ t.Run(name, func(t *testing.T) {
+ t.Setenv(EnvLazyConn, tt.value)
+ if !tt.set {
+ os.Unsetenv(EnvLazyConn)
+ }
+
+ if got := EnvState(); got != tt.want {
+ t.Fatalf("EnvState() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go
index fc47bda39..b7424bb2f 100644
--- a/client/internal/lazyconn/manager/manager.go
+++ b/client/internal/lazyconn/manager/manager.go
@@ -29,6 +29,11 @@ type managedPeer struct {
type Config struct {
InactivityThreshold *time.Duration
+ // ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is
+ // armed. The activity listener creates the wake peer with the overlay /32 only; without the
+ // routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an
+ // idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile.
+ ReconcileAllowedIPs func(peerKey string) error
}
// Manager manages lazy connections
@@ -56,6 +61,9 @@ type Manager struct {
peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to
haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group
routesMu sync.RWMutex
+
+ // reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed.
+ reconcileAllowedIPs func(peerKey string) error
}
// NewManager creates a new lazy connection manager
@@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S
activityManager: activity.NewManager(wgIface),
peerToHAGroups: make(map[string][]route.HAUniqueID),
haGroupToPeers: make(map[route.HAUniqueID][]string),
+ reconcileAllowedIPs: config.ReconcileAllowedIPs,
}
if wgIface.IsUserspaceBind() {
@@ -130,8 +139,8 @@ func (m *Manager) Start(ctx context.Context) {
select {
case <-ctx.Done():
return
- case peerConnID := <-m.activityManager.OnActivityChan:
- m.onPeerActivity(peerConnID)
+ case ev := <-m.activityManager.OnActivityChan:
+ m.onPeerActivity(ev)
case peerIDs := <-m.inactivityManager.InactivePeersChan():
m.onPeerInactivityTimedOut(peerIDs)
}
@@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) {
return false, nil
}
- if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
+ if err := m.armActivityListener(peerCfg); err != nil {
return false, err
}
@@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) {
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
- if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
+ if err := m.armActivityListener(*mp.peerCfg); err != nil {
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
return
}
@@ -465,6 +474,31 @@ func (m *Manager) close() {
}
// shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements
+// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake
+// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without
+// this the routed prefixes would be missing and traffic to a routed subnet could not wake the
+// idle routing peer. It is a no-op when no reconciler is configured.
+// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then
+// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing
+// peer. The routed prefixes must be re-applied after the wake endpoint exists because the
+// listener creates it with the overlay /32 only.
+func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error {
+ if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
+ return err
+ }
+ m.armRoutedAllowedIPs(&peerCfg)
+ return nil
+}
+
+func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) {
+ if m.reconcileAllowedIPs == nil {
+ return
+ }
+ if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil {
+ peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err)
+ }
+}
+
func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool {
m.routesMu.RLock()
defer m.routesMu.RUnlock()
@@ -513,13 +547,13 @@ func (m *Manager) checkHaGroupActivity(haGroup route.HAUniqueID, peerID string,
return false
}
-func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) {
+func (m *Manager) onPeerActivity(ev activity.Event) {
m.managedPeersMu.Lock()
defer m.managedPeersMu.Unlock()
- mp, ok := m.managedPeersByConnID[peerConnID]
+ mp, ok := m.managedPeersByConnID[ev.PeerConnID]
if !ok {
- log.Errorf("peer not found by conn id: %v", peerConnID)
+ log.Errorf("peer not found by conn id: %v", ev.PeerConnID)
return
}
@@ -536,7 +570,7 @@ func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) {
m.activateHAGroupPeers(mp.peerCfg)
- m.peerStore.PeerConnOpen(m.engineCtx, mp.peerCfg.PublicKey)
+ m.peerStore.PeerConnOpenWithFirstPacket(m.engineCtx, mp.peerCfg.PublicKey, ev.FirstPacket)
}
func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
@@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
mp.peerCfg.Log.Infof("start activity monitor")
- if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
+ if err := m.armActivityListener(*mp.peerCfg); err != nil {
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
continue
}
diff --git a/client/internal/lazyconn/wgiface.go b/client/internal/lazyconn/wgiface.go
index 0626c1815..f003ab3cf 100644
--- a/client/internal/lazyconn/wgiface.go
+++ b/client/internal/lazyconn/wgiface.go
@@ -17,4 +17,5 @@ type WGIface interface {
IsUserspaceBind() bool
Address() wgaddr.Address
LastActivities() map[string]monotime.Time
+ MTU() uint16
}
diff --git a/client/internal/metrics/connection_type.go b/client/internal/metrics/connection_type.go
index a3406a6b8..d393e5112 100644
--- a/client/internal/metrics/connection_type.go
+++ b/client/internal/metrics/connection_type.go
@@ -4,11 +4,17 @@ package metrics
type ConnectionType string
const (
- // ConnectionTypeICE represents a direct peer-to-peer connection using ICE
- ConnectionTypeICE ConnectionType = "ice"
+ // ConnectionTypeICEP2P represents a direct peer-to-peer connection using ICE
+ ConnectionTypeICEP2P ConnectionType = "ice_p2p"
+
+ // ConnectionTypeICETurn represents an ICE connection through a TURN server
+ ConnectionTypeICETurn ConnectionType = "ice_turn"
// ConnectionTypeRelay represents a relayed connection
ConnectionTypeRelay ConnectionType = "relay"
+
+ // ConnectionTypeUnknown represents a connection with no active transport. It is not pushed.
+ ConnectionTypeUnknown ConnectionType = "unknown"
)
// String returns the string representation of the connection type
diff --git a/client/internal/metrics/env.go b/client/internal/metrics/env.go
index 1f06ce484..c19dcc7f1 100644
--- a/client/internal/metrics/env.go
+++ b/client/internal/metrics/env.go
@@ -60,6 +60,13 @@ func getMetricsInterval() time.Duration {
return interval
}
+// isMetricsPushEnvSet returns true if NB_METRICS_PUSH_ENABLED is explicitly set (to any value).
+// When set, the env var takes full precedence over management server configuration.
+func isMetricsPushEnvSet() bool {
+ _, set := os.LookupEnv(EnvMetricsPushEnabled)
+ return set
+}
+
func isForceSending() bool {
force, _ := strconv.ParseBool(os.Getenv(EnvMetricsForceSending))
return force
diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go
index 531f6a986..4ba14bf44 100644
--- a/client/internal/metrics/influxdb.go
+++ b/client/internal/metrics/influxdb.go
@@ -120,6 +120,30 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
m.trimLocked()
}
+func (m *influxDBMetrics) RecordSyncPhase(_ context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
+ tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,phase=%s",
+ agentInfo.DeploymentType.String(),
+ agentInfo.Version,
+ agentInfo.OS,
+ agentInfo.Arch,
+ agentInfo.peerID,
+ phase,
+ )
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.samples = append(m.samples, influxSample{
+ measurement: "netbird_sync_phase",
+ tags: tags,
+ fields: map[string]float64{
+ "duration_seconds": duration.Seconds(),
+ },
+ timestamp: time.Now(),
+ })
+ m.trimLocked()
+}
+
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
result := "success"
if !success {
diff --git a/client/internal/metrics/influxdb_test.go b/client/internal/metrics/influxdb_test.go
index b964e31a3..6a226fe2f 100644
--- a/client/internal/metrics/influxdb_test.go
+++ b/client/internal/metrics/influxdb_test.go
@@ -28,7 +28,7 @@ func TestInfluxDBMetrics_RecordAndExport(t *testing.T) {
WgHandshakeSuccess: time.Now().Add(-1 * time.Second),
}
- m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
+ m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts)
var buf bytes.Buffer
err := m.Export(&buf)
@@ -60,7 +60,7 @@ func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) {
// Record multiple times and verify consistent field order
for i := 0; i < 10; i++ {
- m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
+ m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts)
}
var buf bytes.Buffer
diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md
index 5a93dbd87..0a69404df 100644
--- a/client/internal/metrics/infra/README.md
+++ b/client/internal/metrics/infra/README.md
@@ -56,14 +56,33 @@ Measurement: `netbird_peer_connection`
Tags:
- `deployment_type`: "cloud" | "selfhosted" | "unknown"
-- `connection_type`: "ice" | "relay"
+- `connection_type`: "ice_p2p" | "ice_turn" | "relay" (see below)
- `attempt_type`: "initial" | "reconnection"
- `version`: NetBird version string
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
- `arch`: CPU architecture (amd64, arm64, etc.)
+- `peer_id`: anonymised peer identifier (truncated SHA-256 of the WireGuard public key)
+- `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides
**Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online.
+#### `connection_type` values
+
+Derived from the connection priority (`conntype.ConnPriority`) by `metricsConnType` in `client/internal/peer/conn.go`:
+
+| Value | Priority | Traffic is |
+|-------|----------|------------|
+| `ice_p2p` | `ICEP2P` | direct peer-to-peer |
+| `ice_turn` | `ICETurn` | relayed, through a TURN server |
+| `relay` | `Relay` | relayed, through a NetBird relay |
+| `unknown` | `None` or unrecognised | no active transport — **the sample is not pushed** |
+
+**Direct traffic is `ice_p2p` only.** `ice_turn` is relayed despite being negotiated by ICE, matching `Conn.isRelayed`.
+
+`None` means no transport is active: not established yet, or reset after a relay drop or a peer-state reset. Such a sample cannot be attributed to a transport, so `recordConnectionMetrics` drops it instead of pushing it — `unknown` therefore never appears in the bucket. Connection counts are counts of connections whose transport was known at sampling time.
+
+**Samples recorded before 0.77 used a single `ice` value** which covered `ICEP2P`, `ICETurn` *and* `None`, so historical `ice` samples overstate direct connections by an unknown amount and must not be compared with `ice_p2p`.
+
### Sync Duration
Measurement: `netbird_sync`
@@ -78,6 +97,25 @@ Tags:
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
- `arch`: CPU architecture (amd64, arm64, etc.)
+### Sync Phase Timing
+
+Measurement: `netbird_sync_phase`
+
+Breaks down where time goes inside a single sync, so the total `netbird_sync` duration can be attributed to the sub-step that dominates.
+
+| Field | Description |
+|-------|-------------|
+| `duration_seconds` | Time spent in one sub-phase of sync processing |
+
+Tags:
+- `phase`: the sub-phase — `netbird_config`, `checks`, `persist`, `dns_server`, `routes_classify`, `routes_apply`, `filtering`, `dns_forwarder`, `forward_rules`, `offline_peers`, `removed_peers`, `modified_peers`, `added_peers`, `lazy_exclude`
+- `deployment_type`: "cloud" | "selfhosted" | "unknown"
+- `version`: NetBird version string
+- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
+- `arch`: CPU architecture (amd64, arm64, etc.)
+
+**Note:** this is wall-time per phase — it includes both CPU work and time spent waiting on locks. A slow phase points to *where* the time goes, not *why*; pair it with lock-wait metrics to tell contention apart from real work.
+
### Login Duration
Measurement: `netbird_login`
@@ -191,4 +229,52 @@ docker compose exec influxdb influx query \
# Check ingest server health
curl http://localhost:8087/health
-```
\ No newline at end of file
+```
+
+## Analyzing a Debug Bundle
+
+Metrics collection is always on, so every debug bundle ships a `metrics.txt` in InfluxDB line protocol — a timestamped time series of all recorded events (sync durations, sync phases, connection stages, login). You can replay it into the local stack and graph it, without a running client.
+
+The bundle's `metrics.txt` is a rolling window (capped at 5 days / ~20k samples, see [Buffer Limits](#buffer-limits)). For a connection incident the relevant window is short (connection setup is seconds), so a bundle captured during the issue is enough.
+
+### 1. Start the stack
+
+```bash
+# From this directory (client/internal/metrics/infra)
+INFLUXDB_ADMIN_TOKEN=admin123 INFLUXDB_ADMIN_PASSWORD=admin123 GRAFANA_ADMIN_PASSWORD=admin123 \
+ docker compose up -d
+```
+
+(`admin123` are throwaway local credentials — fine for offline analysis.)
+
+### 2. Clear any previous data
+
+So you only see this bundle:
+
+```bash
+docker exec influxdb influx delete --org netbird --bucket metrics --token admin123 \
+ --start 1970-01-01T00:00:00Z --stop 2100-01-01T00:00:00Z
+```
+
+### 3. Import the bundle's metrics.txt
+
+InfluxDB is not exposed on the host, so import inside the container:
+
+```bash
+docker cp /path/to/bundle/metrics.txt influxdb:/tmp/m.txt
+docker exec influxdb influx write --org netbird --bucket metrics --precision ns \
+ --token admin123 --file /tmp/m.txt
+```
+
+Re-importing the same file is idempotent (same measurement+tags+timestamp overwrites).
+
+### 4. View the dashboards
+
+Grafana on http://localhost:3001 (login `admin` / `admin123`), datasource pre-provisioned:
+
+- **Where sync time goes:** http://localhost:3001/d/netbird-sync-phases/netbird-sync-phases-where-time-goes
+- **General client metrics:** http://localhost:3001/d/netbird-influxdb-metrics
+
+**Set the time range** to cover the bundle's timestamps (e.g. "Last 7 days" or an absolute range matching when the bundle was taken) — with the default short range the panels look empty.
+
+Bundles are distinguishable by the `version` tag; add a tag at import time (e.g. `sed 's/^netbird_\([a-z_]*\),/netbird_\1,bundle=mycase,/' metrics.txt`) if you want to compare several side by side.
\ No newline at end of file
diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json
new file mode 100644
index 000000000..69dbac0ae
--- /dev/null
+++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json
@@ -0,0 +1,259 @@
+{
+ "annotations": {
+ "list": []
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 1,
+ "links": [],
+ "refresh": "",
+ "schemaVersion": 39,
+ "tags": [
+ "netbird",
+ "sync"
+ ],
+ "templating": {
+ "list": [
+ {
+ "current": {
+ "text": "All",
+ "value": "$__all"
+ },
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "definition": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")",
+ "includeAll": true,
+ "label": "version",
+ "multi": true,
+ "name": "version",
+ "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")",
+ "refresh": 2,
+ "type": "query",
+ "allValue": ".*"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-2d",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "",
+ "title": "NetBird Sync Phases (where time goes)",
+ "uid": "netbird-sync-phases",
+ "version": 1,
+ "panels": [
+ {
+ "id": 1,
+ "title": "Time per phase over time (stacked, ms)",
+ "type": "timeseries",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 10,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "custom": {
+ "drawStyle": "bars",
+ "stacking": {
+ "mode": "normal",
+ "group": "A"
+ },
+ "fillOpacity": 80,
+ "lineWidth": 0
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "calcs": [
+ "max",
+ "mean"
+ ]
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"phase\"])\n |> group(columns: [\"phase\"])"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "p95 per phase (ms)",
+ "type": "bargauge",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 11,
+ "w": 12,
+ "x": 0,
+ "y": 10
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "color": {
+ "mode": "continuous-GrYlRd"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "displayMode": "gradient",
+ "orientation": "horizontal",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "showUnfilled": true
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> sort(columns: [\"_value\"], desc: true)"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Per-phase stats (ms): mean / p95 / max",
+ "type": "table",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 11,
+ "w": 12,
+ "x": 12,
+ "y": 10
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms"
+ },
+ "overrides": []
+ },
+ "options": {
+ "showHeader": true,
+ "sortBy": [
+ {
+ "displayName": "max",
+ "desc": true
+ }
+ ]
+ },
+ "transformations": [
+ {
+ "id": "merge",
+ "options": {}
+ }
+ ],
+ "targets": [
+ {
+ "refId": "mean",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> mean()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"mean\"})"
+ },
+ {
+ "refId": "p95",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"p95\"})"
+ },
+ {
+ "refId": "max",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> max()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"max\"})"
+ }
+ ]
+ },
+ {
+ "id": 4,
+ "title": "Total sync duration (netbird_sync, ms) \u2014 reference",
+ "type": "timeseries",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 21
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "custom": {
+ "drawStyle": "points",
+ "pointSize": 5
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "calcs": [
+ "max",
+ "mean"
+ ]
+ },
+ "tooltip": {
+ "mode": "single"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"version\"])\n |> group(columns: [\"version\"])"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go
index a5031a873..91405b85f 100644
--- a/client/internal/metrics/infra/ingest/main.go
+++ b/client/internal/metrics/infra/ingest/main.go
@@ -19,7 +19,7 @@ const (
defaultListenAddr = ":8087"
defaultInfluxDBURL = "http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns"
maxBodySize = 50 * 1024 * 1024 // 50 MB max request body
- maxDurationSeconds = 300.0 // reject any duration field > 5 minutes
+ maxDurationSeconds = 86400.0 // reject any duration field > 24 hours
peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars
maxTagValueLength = 64 // reject tag values longer than this
)
@@ -59,6 +59,19 @@ var allowedMeasurements = map[string]measurementSpec{
"peer_id": true,
},
},
+ "netbird_sync_phase": {
+ allowedFields: map[string]bool{
+ "duration_seconds": true,
+ },
+ allowedTags: map[string]bool{
+ "deployment_type": true,
+ "version": true,
+ "os": true,
+ "arch": true,
+ "peer_id": true,
+ "phase": true,
+ },
+ },
"netbird_login": {
allowedFields: map[string]bool{
"duration_seconds": true,
diff --git a/client/internal/metrics/infra/ingest/main_test.go b/client/internal/metrics/infra/ingest/main_test.go
index bacaa4588..96287813e 100644
--- a/client/internal/metrics/infra/ingest/main_test.go
+++ b/client/internal/metrics/infra/ingest/main_test.go
@@ -53,14 +53,14 @@ func TestValidateLine_NegativeValue(t *testing.T) {
}
func TestValidateLine_DurationTooLarge(t *testing.T) {
- line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=999 1234567890`
+ line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=100000 1234567890`
err := validateLine(line)
require.Error(t, err)
assert.Contains(t, err.Error(), "too large")
}
func TestValidateLine_TotalSecondsTooLarge(t *testing.T) {
- line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=500 1234567890`
+ line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=100000 1234567890`
err := validateLine(line)
require.Error(t, err)
assert.Contains(t, err.Error(), "too large")
diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go
index 4ebb43496..cfe477107 100644
--- a/client/internal/metrics/metrics.go
+++ b/client/internal/metrics/metrics.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"sync"
+ "sync/atomic"
"time"
log "github.com/sirupsen/logrus"
@@ -56,6 +57,9 @@ type metricsImplementation interface {
// RecordSyncDuration records how long it took to process a sync message
RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration)
+ // RecordSyncPhase records how long a single sub-phase of sync processing took
+ RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration)
+
// RecordLoginDuration records how long the login to management took
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
@@ -72,7 +76,7 @@ type ClientMetrics struct {
agentInfo AgentInfo
mu sync.RWMutex
- push *Push
+ push atomic.Pointer[Push]
pushMu sync.Mutex
wg sync.WaitGroup
pushCancel context.CancelFunc
@@ -127,6 +131,18 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
}
+// RecordSyncPhase records the duration of a single sub-phase of sync processing
+func (c *ClientMetrics) RecordSyncPhase(ctx context.Context, phase string, duration time.Duration) {
+ if c == nil {
+ return
+ }
+ c.mu.RLock()
+ agentInfo := c.agentInfo
+ c.mu.RUnlock()
+
+ c.impl.RecordSyncPhase(ctx, agentInfo, phase, duration)
+}
+
// RecordLoginDuration records how long the login to management server took
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
if c == nil {
@@ -152,10 +168,7 @@ func (c *ClientMetrics) UpdateAgentInfo(agentInfo AgentInfo, publicKey string) {
c.agentInfo = agentInfo
c.mu.Unlock()
- c.pushMu.Lock()
- push := c.push
- c.pushMu.Unlock()
- if push != nil {
+ if push := c.push.Load(); push != nil {
push.SetPeerID(agentInfo.peerID)
}
}
@@ -169,7 +182,7 @@ func (c *ClientMetrics) Export(w io.Writer) error {
return c.impl.Export(w)
}
-// StartPush starts periodic pushing of metrics with the given configuration
+// StartPush starts periodic pushing of metrics with the given configuration.
// Precedence: PushConfig.ServerAddress > remote config server_url
func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) {
if c == nil {
@@ -179,11 +192,58 @@ func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) {
c.pushMu.Lock()
defer c.pushMu.Unlock()
- if c.push != nil {
+ if c.push.Load() != nil {
log.Warnf("metrics push already running")
return
}
+ c.startPushLocked(ctx, config)
+}
+
+// StopPush stops the periodic metrics push.
+func (c *ClientMetrics) StopPush() {
+ if c == nil {
+ return
+ }
+ c.pushMu.Lock()
+ defer c.pushMu.Unlock()
+
+ c.stopPushLocked()
+}
+
+// UpdatePushFromMgm updates metrics push based on management server configuration.
+// If NB_METRICS_PUSH_ENABLED is explicitly set (true or false), management config is ignored.
+// When unset, management controls whether push is enabled.
+func (c *ClientMetrics) UpdatePushFromMgm(ctx context.Context, enabled bool) {
+ if c == nil {
+ return
+ }
+
+ if isMetricsPushEnvSet() {
+ log.Debugf("ignoring management config, env var is explicitly set: %s", EnvMetricsPushEnabled)
+ return
+ }
+
+ c.pushMu.Lock()
+ defer c.pushMu.Unlock()
+
+ if enabled {
+ if c.push.Load() != nil {
+ return
+ }
+ log.Infof("enabled metrics push by management")
+ c.startPushLocked(ctx, PushConfigFromEnv())
+ } else {
+ if c.push.Load() == nil {
+ return
+ }
+ log.Infof("disabled metrics push by management")
+ c.stopPushLocked()
+ }
+}
+
+// startPushLocked starts push. Caller must hold pushMu.
+func (c *ClientMetrics) startPushLocked(ctx context.Context, config PushConfig) {
c.mu.RLock()
agentVersion := c.agentInfo.Version
peerID := c.agentInfo.peerID
@@ -199,26 +259,23 @@ func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) {
ctx, cancel := context.WithCancel(ctx)
c.pushCancel = cancel
+ c.push.Store(push)
c.wg.Add(1)
go func() {
defer c.wg.Done()
push.Start(ctx)
+ c.push.CompareAndSwap(push, nil)
}()
- c.push = push
}
-func (c *ClientMetrics) StopPush() {
- if c == nil {
- return
- }
- c.pushMu.Lock()
- defer c.pushMu.Unlock()
- if c.push == nil {
+// stopPushLocked stops push. Caller must hold pushMu.
+func (c *ClientMetrics) stopPushLocked() {
+ if c.push.Load() == nil {
return
}
c.pushCancel()
c.wg.Wait()
- c.push = nil
+ c.push.Store(nil)
}
diff --git a/client/internal/metrics/push_test.go b/client/internal/metrics/push_test.go
index 20a509da1..43c1b2c06 100644
--- a/client/internal/metrics/push_test.go
+++ b/client/internal/metrics/push_test.go
@@ -70,6 +70,9 @@ func (m *mockMetrics) RecordConnectionStages(_ context.Context, _ AgentInfo, _ s
func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.Duration) {
}
+func (m *mockMetrics) RecordSyncPhase(_ context.Context, _ AgentInfo, _ string, _ time.Duration) {
+}
+
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
}
diff --git a/client/internal/mobile_dependency.go b/client/internal/mobile_dependency.go
index 310d61a25..0234432b1 100644
--- a/client/internal/mobile_dependency.go
+++ b/client/internal/mobile_dependency.go
@@ -11,12 +11,14 @@ import (
// MobileDependency collect all dependencies for mobile platform
type MobileDependency struct {
- // Android only
- TunAdapter device.TunAdapter
- IFaceDiscover stdnet.ExternalIFaceDiscover
+ // Android and iOS
NetworkChangeListener listener.NetworkChangeListener
- HostDNSAddresses []netip.AddrPort
- DnsReadyListener dns.ReadyListener
+
+ // Android only
+ TunAdapter device.TunAdapter
+ IFaceDiscover stdnet.ExternalIFaceDiscover
+ HostDNSAddresses []netip.AddrPort
+ DnsReadyListener dns.ReadyListener
// iOS only
DnsManager dns.IosDnsManager
diff --git a/client/internal/netflow/logger/logger.go b/client/internal/netflow/logger/logger.go
index 8f8e68784..deb38bc4d 100644
--- a/client/internal/netflow/logger/logger.go
+++ b/client/internal/netflow/logger/logger.go
@@ -27,7 +27,7 @@ type Logger struct {
wgIfaceNetV6 netip.Prefix
dnsCollection atomic.Bool
exitNodeCollection atomic.Bool
- Store types.Store
+ Store types.AggregatingStore
}
func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix) *Logger {
@@ -35,7 +35,7 @@ func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix)
statusRecorder: statusRecorder,
wgIfaceNet: wgIfaceIPNet,
wgIfaceNetV6: wgIfaceIPNetV6,
- Store: store.NewMemoryStore(),
+ Store: store.NewAggregatingMemoryStore(),
}
}
@@ -125,6 +125,10 @@ func (l *Logger) stop() {
l.mux.Unlock()
}
+func (l *Logger) ResetAggregationWindow() types.FlowEventAggregator {
+ return l.Store.ResetAggregationWindow()
+}
+
func (l *Logger) GetEvents() []*types.Event {
return l.Store.GetEvents()
}
diff --git a/client/internal/netflow/manager.go b/client/internal/netflow/manager.go
index eff083dbf..43d61b771 100644
--- a/client/internal/netflow/manager.go
+++ b/client/internal/netflow/manager.go
@@ -9,12 +9,14 @@ import (
"sync"
"time"
+ "github.com/cenkalti/backoff/v4"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/client/internal/netflow/conntrack"
"github.com/netbirdio/netbird/client/internal/netflow/logger"
+ "github.com/netbirdio/netbird/client/internal/netflow/store"
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/flow/client"
@@ -23,14 +25,16 @@ import (
// Manager handles netflow tracking and logging
type Manager struct {
- mux sync.Mutex
- shutdownWg sync.WaitGroup
- logger nftypes.FlowLogger
- flowConfig *nftypes.FlowConfig
- conntrack nftypes.ConnTracker
- receiverClient *client.GRPCClient
- publicKey []byte
- cancel context.CancelFunc
+ mux sync.Mutex
+ shutdownWg sync.WaitGroup
+ logger nftypes.FlowLogger
+ flowConfig *nftypes.FlowConfig
+ conntrack nftypes.ConnTracker
+ receiverClient *client.GRPCClient
+ eventsWithoutAcks nftypes.Store
+ publicKey []byte
+ cancel context.CancelFunc
+ retryInterval time.Duration
}
// NewManager creates a new netflow manager
@@ -48,9 +52,11 @@ func NewManager(iface nftypes.IFaceMapper, publicKey []byte, statusRecorder *pee
}
return &Manager{
- logger: flowLogger,
- conntrack: ct,
- publicKey: publicKey,
+ logger: flowLogger,
+ conntrack: ct,
+ publicKey: publicKey,
+ retryInterval: time.Second,
+ eventsWithoutAcks: store.NewMemoryStore(),
}
}
@@ -66,6 +72,7 @@ func (m *Manager) needsNewClient(previous *nftypes.FlowConfig) bool {
}
// enableFlow starts components for flow tracking
+// must be called under m.mux lock
func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error {
// first make sender ready so events don't pile up
if m.needsNewClient(previous) {
@@ -85,6 +92,7 @@ func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error {
return nil
}
+// must be called under m.mux lock
func (m *Manager) resetClient() error {
if m.receiverClient != nil {
if err := m.receiverClient.Close(); err != nil {
@@ -107,14 +115,19 @@ func (m *Manager) resetClient() error {
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
- m.shutdownWg.Add(2)
+ m.shutdownWg.Add(3)
+ flowConfigInterval := m.flowConfig.Interval
go func() {
defer m.shutdownWg.Done()
- m.receiveACKs(ctx, flowClient)
+ m.receiveACKs(ctx, flowClient, flowConfigInterval)
}()
go func() {
defer m.shutdownWg.Done()
- m.startSender(ctx)
+ m.startSender(ctx, flowConfigInterval)
+ }()
+ go func() {
+ defer m.shutdownWg.Done()
+ m.startRetries(ctx, flowConfigInterval)
}()
return nil
@@ -198,8 +211,8 @@ func (m *Manager) GetLogger() nftypes.FlowLogger {
return m.logger
}
-func (m *Manager) startSender(ctx context.Context) {
- ticker := time.NewTicker(m.flowConfig.Interval)
+func (m *Manager) startSender(ctx context.Context, flowConfigInterval time.Duration) {
+ ticker := time.NewTicker(flowConfigInterval)
defer ticker.Stop()
for {
@@ -207,27 +220,29 @@ func (m *Manager) startSender(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
- events := m.logger.GetEvents()
+ collectedEvents := m.logger.ResetAggregationWindow()
+ events := collectedEvents.GetAggregatedEvents()
for _, event := range events {
+ m.eventsWithoutAcks.StoreEvent(event)
if err := m.send(event); err != nil {
log.Errorf("failed to send flow event to server: %v", err)
- continue
+ } else {
+ log.Tracef("sent flow event: %s", event.ID)
}
- log.Tracef("sent flow event: %s", event.ID)
}
}
}
}
-func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) {
- err := client.Receive(ctx, m.flowConfig.Interval, func(ack *proto.FlowEventAck) error {
+func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient, flowConfigInterval time.Duration) {
+ err := client.Receive(ctx, flowConfigInterval, func(ack *proto.FlowEventAck) error {
id, err := uuid.FromBytes(ack.EventId)
if err != nil {
log.Warnf("failed to convert ack event id to uuid: %v", err)
return nil
}
log.Tracef("received flow event ack: %s", id)
- m.logger.DeleteEvents([]uuid.UUID{id})
+ m.eventsWithoutAcks.DeleteEvents([]uuid.UUID{id})
return nil
})
@@ -236,6 +251,51 @@ func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) {
}
}
+// We effectively never drop events (see MaxInterval), which makes eventsWithoutAcks unbounded.
+// We may want to limit the max size of the store, and start dropping oldest events when the threshold is reached.
+func (m *Manager) startRetries(ctx context.Context, flowConfigInterval time.Duration) {
+ timer := time.NewTimer(m.retryInterval)
+ retryBackoff := backoff.WithContext(&backoff.ExponentialBackOff{
+ InitialInterval: 1 * time.Second,
+ RandomizationFactor: 0.5,
+ Multiplier: 1.7,
+ MaxInterval: flowConfigInterval / 2,
+ MaxElapsedTime: 3 * 30 * 24 * time.Hour, // 3 months
+ Stop: backoff.Stop,
+ Clock: backoff.SystemClock,
+ }, ctx)
+ defer timer.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-timer.C:
+ resetBackoff := true
+ for _, e := range m.eventsWithoutAcks.GetEvents() {
+ if e.Timestamp.Add(time.Second).After(time.Now()) {
+ // grace period on retries to avoid early retries
+ // do not retry if the event is less than 1 sec old
+ continue
+ }
+ if err := m.send(e); err != nil {
+ if nextBackoff := retryBackoff.NextBackOff(); nextBackoff != backoff.Stop {
+ timer = time.NewTimer(nextBackoff)
+ resetBackoff = false
+ } else {
+ resetBackoff = true // we exhausted retries, reset retry loop
+ }
+ break
+ }
+ }
+ if resetBackoff { // use regular retry interval in absence of network errors
+ retryBackoff.Reset()
+ timer = time.NewTimer(m.retryInterval)
+ }
+ }
+ }
+}
+
func (m *Manager) send(event *nftypes.Event) error {
m.mux.Lock()
client := m.receiverClient
@@ -250,9 +310,11 @@ func (m *Manager) send(event *nftypes.Event) error {
func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent {
protoEvent := &proto.FlowEvent{
- EventId: event.ID[:],
- Timestamp: timestamppb.New(event.Timestamp),
- PublicKey: publicKey,
+ EventId: event.ID[:],
+ Timestamp: timestamppb.New(event.Timestamp),
+ PublicKey: publicKey,
+ WindowStart: timestamppb.New(event.WindowStart),
+ WindowEnd: timestamppb.New(event.WindowEnd),
FlowFields: &proto.FlowFields{
FlowId: event.FlowID[:],
RuleId: event.RuleID,
@@ -267,6 +329,9 @@ func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent {
TxBytes: event.TxBytes,
SourceResourceId: event.SourceResourceID,
DestResourceId: event.DestResourceID,
+ NumOfStarts: event.NumOfStarts,
+ NumOfEnds: event.NumOfEnds,
+ NumOfDrops: event.NumOfDrops,
},
}
diff --git a/client/internal/netflow/manager_integration_test.go b/client/internal/netflow/manager_integration_test.go
new file mode 100644
index 000000000..9029bdda2
--- /dev/null
+++ b/client/internal/netflow/manager_integration_test.go
@@ -0,0 +1,291 @@
+package netflow
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/netip"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/netflow/types"
+ "github.com/netbirdio/netbird/flow/proto"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "google.golang.org/grpc"
+)
+
+type testServer struct {
+ proto.UnimplementedFlowServiceServer
+ events chan *proto.FlowEvent
+ acks chan *proto.FlowEventAck
+ grpcSrv *grpc.Server
+ addr string
+ handlerDone chan struct{} // signaled each time Events() exits
+ handlerStarted chan struct{} // signaled each time Events() begins
+}
+
+func newTestServer(t *testing.T) *testServer {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+
+ s := &testServer{
+ events: make(chan *proto.FlowEvent, 100),
+ acks: make(chan *proto.FlowEventAck, 100),
+ grpcSrv: grpc.NewServer(),
+ addr: listener.Addr().String(),
+ handlerDone: make(chan struct{}, 10),
+ handlerStarted: make(chan struct{}, 10),
+ }
+
+ proto.RegisterFlowServiceServer(s.grpcSrv, s)
+
+ go func() {
+ if err := s.grpcSrv.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
+ t.Logf("server error: %v", err)
+ }
+ }()
+
+ t.Cleanup(func() {
+ s.grpcSrv.Stop()
+ })
+
+ return s
+}
+
+func (s *testServer) Events(stream proto.FlowService_EventsServer) error {
+ defer func() {
+ select {
+ case s.handlerDone <- struct{}{}:
+ default:
+ }
+ }()
+
+ err := stream.Send(&proto.FlowEventAck{IsInitiator: true})
+ if err != nil {
+ return err
+ }
+
+ select {
+ case s.handlerStarted <- struct{}{}:
+ default:
+ }
+
+ ctx, cancel := context.WithCancel(stream.Context())
+ defer cancel()
+
+ go func() {
+ defer cancel()
+ for {
+ event, err := stream.Recv()
+ if err != nil {
+ return
+ }
+
+ if !event.IsInitiator {
+ select {
+ case s.events <- event:
+ case <-ctx.Done():
+ return
+ }
+ }
+ }
+ }()
+
+ for {
+ select {
+ case ack := <-s.acks:
+ if err := stream.Send(ack); err != nil {
+ return err
+ }
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+}
+
+func TestSendEventReceiveAck(t *testing.T) {
+ _, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ t.Cleanup(cancel)
+
+ server := newTestServer(t)
+ manager := createManager(t, server.addr, 60*time.Second) // set high to prevent retries in this test
+ defer manager.Close()
+
+ assert.Eventually(t, func() bool {
+ select {
+ case <-server.handlerStarted:
+ return true
+ default:
+ return false
+ }
+ }, 3*time.Second, 100*time.Millisecond)
+
+ event1 := types.EventFields{
+ FlowID: uuid.New(),
+ Type: types.TypeStart,
+ Direction: types.Ingress,
+ DestIP: ipAddr("172.16.1.2"),
+ DestPort: 2345,
+ Protocol: 6,
+ }
+ manager.logger.StoreEvent(event1)
+ event2 := types.EventFields{
+ FlowID: uuid.New(),
+ Type: types.TypeStart,
+ Direction: types.Ingress,
+ DestIP: ipAddr("172.16.1.1"),
+ DestPort: 1234,
+ Protocol: 6,
+ }
+ manager.logger.StoreEvent(event2)
+
+ // verify the server received logged events
+ serverSideEvents := make([]*proto.FlowEvent, 0)
+ assert.Eventually(t, func() bool {
+ select {
+ case event := <-server.events:
+ serverSideEvents = append(serverSideEvents, event)
+ if len(serverSideEvents) == 2 {
+ return true
+ }
+ default:
+ if len(serverSideEvents) == 2 {
+ return true
+ }
+ }
+ return false
+ }, 5*time.Second, 100*time.Millisecond)
+
+ serverSideFlowIds := make([]uuid.UUID, 0, 2)
+ slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool {
+ id, err := uuid.FromBytes(e.FlowFields.FlowId)
+ assert.NoError(t, err)
+ serverSideFlowIds = append(serverSideFlowIds, id)
+ return true
+ })
+ assert.ElementsMatch(t, []uuid.UUID{event1.FlowID, event2.FlowID}, serverSideFlowIds)
+
+ // verify the manager tracks un-acked events
+ unackedEvents := manager.eventsWithoutAcks.GetEvents()
+ assert.Len(t, unackedEvents, 2)
+ flowIds := make([]uuid.UUID, 0)
+ slices.Values(unackedEvents)(func(e *types.Event) bool {
+ flowIds = append(flowIds, e.FlowID)
+ return true
+ })
+ assert.ElementsMatch(t, flowIds, []uuid.UUID{event1.FlowID, event2.FlowID})
+}
+
+// verify handling of retries:
+// - unacked events are retried
+// - when acks arrive, events are removed from the un-acked event tracker
+func TestRetryEvents(t *testing.T) {
+ _, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ t.Cleanup(cancel)
+
+ server := newTestServer(t)
+ manager := createManager(t, server.addr, time.Second) // set low to start retries sooner
+ defer manager.Close()
+
+ assert.Eventually(t, func() bool {
+ select {
+ case <-server.handlerStarted:
+ return true
+ default:
+ return false
+ }
+ }, 3*time.Second, 100*time.Millisecond)
+
+ event1 := types.EventFields{
+ FlowID: uuid.New(),
+ Type: types.TypeStart,
+ Direction: types.Ingress,
+ DestIP: ipAddr("172.16.1.2"),
+ DestPort: 2345,
+ Protocol: 6,
+ }
+ manager.logger.StoreEvent(event1)
+ event2 := types.EventFields{
+ FlowID: uuid.New(),
+ Type: types.TypeStart,
+ Direction: types.Ingress,
+ DestIP: ipAddr("172.16.1.1"),
+ DestPort: 1234,
+ Protocol: 6,
+ }
+ manager.logger.StoreEvent(event2)
+
+ // verify the server received retries of logged events
+ serverSideEvents := make([]*proto.FlowEvent, 0)
+ func() {
+ c := time.After(2500 * time.Millisecond)
+ for {
+ select {
+ case event := <-server.events:
+ serverSideEvents = append(serverSideEvents, event)
+ case <-c:
+ return
+ }
+ }
+ }()
+ assert.True(t, len(serverSideEvents) > 2) // must see retries
+
+ uniqueServerSideEvents := make(map[uuid.UUID]*proto.FlowEvent)
+ slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool {
+ id, err := uuid.FromBytes(e.FlowFields.FlowId)
+ assert.NoError(t, err)
+ uniqueServerSideEvents[id] = e
+ return true
+ })
+ assert.Contains(t, uniqueServerSideEvents, event1.FlowID)
+ assert.Contains(t, uniqueServerSideEvents, event2.FlowID)
+
+ // ack events
+ server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event1.FlowID].EventId}
+ server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event2.FlowID].EventId}
+
+ assert.EventuallyWithT(t, func(c *assert.CollectT) {
+ unackedEvents := manager.eventsWithoutAcks.GetEvents()
+ assert.Empty(c, unackedEvents)
+
+ }, 3*time.Second, 100*time.Millisecond)
+}
+
+func createManager(t *testing.T, serverAddr string, retryInterval time.Duration) *Manager {
+ t.Helper()
+
+ mockIFace := &mockIFaceMapper{
+ address: wgaddr.Address{
+ Network: netip.MustParsePrefix("192.168.1.1/32"),
+ },
+ isUserspaceBind: true,
+ }
+
+ publicKey := []byte("test-public-key")
+ manager := NewManager(mockIFace, publicKey, nil)
+ manager.retryInterval = retryInterval
+
+ initialConfig := &types.FlowConfig{
+ Enabled: true,
+ URL: fmt.Sprintf("http://%s", serverAddr),
+ TokenPayload: "initial-payload",
+ TokenSignature: "initial-signature",
+ Interval: 500 * time.Millisecond,
+ }
+
+ err := manager.Update(initialConfig)
+ require.NoError(t, err)
+
+ return manager
+}
+
+func ipAddr(a string) netip.Addr {
+ addr, _ := netip.ParseAddr(a)
+ return addr
+}
diff --git a/client/internal/netflow/store/event_aggregation_test.go b/client/internal/netflow/store/event_aggregation_test.go
new file mode 100644
index 000000000..8abe0d162
--- /dev/null
+++ b/client/internal/netflow/store/event_aggregation_test.go
@@ -0,0 +1,365 @@
+package store
+
+import (
+ "math/rand"
+ "net/netip"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/netbirdio/netbird/client/internal/netflow/types"
+ "github.com/stretchr/testify/assert"
+)
+
+var random = rand.New(rand.NewSource(time.Now().UnixNano()))
+
+func TestFlowAggregation(t *testing.T) {
+ var protocols = []types.Protocol{types.ICMP, types.ICMPv6, types.TCP, types.UDP}
+ var tests = []struct {
+ description string
+ addresses [][]netip.Addr
+ dstPort uint16
+ eventTypes []types.Type
+ }{
+ {
+ description: "start and stop",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
+ dstPort: uint16(random.Uint32() >> 16),
+ eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
+ },
+ {
+ description: "start and drop",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
+ dstPort: uint16(random.Uint32() >> 16),
+ eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
+ },
+ {
+ description: "start only",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
+ dstPort: uint16(random.Uint32() >> 16),
+ eventTypes: []types.Type{types.TypeStart},
+ },
+ {
+ description: "drop only",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
+ dstPort: uint16(random.Uint32() >> 16),
+ eventTypes: []types.Type{types.TypeDrop},
+ }}
+
+ for _, protocol := range protocols {
+ for _, tt := range tests {
+ t.Run(tt.description+" "+protocol.String(), func(t *testing.T) {
+ store := NewAggregatingMemoryStore()
+ store.WindowEnd = time.Now().Add(5 * time.Second)
+
+ allExpected := make([]*types.Event, 0)
+
+ for _, srcAndDst := range tt.addresses {
+ inEvents, expected := generateEvents(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, protocol, types.Ingress, 0, store.WindowStart, store.WindowEnd)
+ for _, e := range inEvents {
+ store.StoreEvent(e)
+ }
+ allExpected = append(allExpected, expected)
+ }
+
+ events := store.GetAggregatedEvents()
+ assert.ElementsMatch(t, events, allExpected)
+ })
+ }
+ }
+}
+
+func TestIcmpEventAggregation(t *testing.T) {
+ var protocols = []types.Protocol{types.ICMP, types.ICMPv6}
+ var icmpTypes = []uint8{1, 2, 3}
+
+ var tests = []struct {
+ description string
+ addresses [][]netip.Addr
+ eventTypes []types.Type
+ }{
+ {
+ description: "start and stop",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
+ eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
+ },
+ {
+ description: "start and drop",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
+ eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
+ },
+ {
+ description: "start only",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
+ eventTypes: []types.Type{types.TypeStart},
+ },
+ {
+ description: "drop only",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
+ eventTypes: []types.Type{types.TypeDrop},
+ }}
+
+ for _, protocol := range protocols {
+ for _, tt := range tests {
+ t.Run(tt.description+" "+protocol.String(), func(t *testing.T) {
+ store := NewAggregatingMemoryStore()
+ store.WindowEnd = time.Now().Add(5 * time.Second)
+
+ allExpected := make([]*types.Event, 0)
+ for _, icmpType := range icmpTypes {
+ events, expected := generateEvents(tt.addresses[0][0], tt.addresses[0][1], 0, tt.eventTypes, protocol, types.Ingress, icmpType, store.WindowStart, store.WindowEnd)
+ for _, e := range events {
+ store.StoreEvent(e)
+ }
+ allExpected = append(allExpected, expected)
+ }
+ aggregatedEvents := store.GetAggregatedEvents()
+ assert.Len(t, aggregatedEvents, len(allExpected))
+ assert.ElementsMatch(t, aggregatedEvents, allExpected)
+ })
+ }
+ }
+}
+
+func TestFlowAggregationOfUnknownProtocols(t *testing.T) {
+ var tests = []struct {
+ description string
+ addresses [][]netip.Addr
+ dstPort uint16
+ eventTypes []types.Type
+ }{
+ {
+ description: "start and stop",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
+ dstPort: uint16(random.Uint32() >> 16),
+ eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
+ },
+ {
+ description: "start and drop",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
+ dstPort: uint16(random.Uint32() >> 16),
+ eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
+ },
+ {
+ description: "start only",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
+ dstPort: uint16(random.Uint32() >> 16),
+ eventTypes: []types.Type{types.TypeStart},
+ },
+ {
+ description: "drop only",
+ addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
+ dstPort: uint16(random.Uint32() >> 16),
+ eventTypes: []types.Type{types.TypeDrop},
+ }}
+
+ for _, tt := range tests {
+ t.Run(tt.description+" "+types.ProtocolUnknown.String(), func(t *testing.T) {
+ store := NewAggregatingMemoryStore()
+ store.WindowEnd = time.Now().Add(5 * time.Second)
+
+ allExpected := make([]*types.Event, 0)
+
+ for _, srcAndDst := range tt.addresses {
+ inEvents, expected := generateEventsForUnknownProtocol(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, types.ProtocolUnknown, types.Ingress, store.WindowStart, store.WindowEnd)
+ for _, e := range inEvents {
+ store.StoreEvent(e)
+ }
+ allExpected = append(allExpected, expected...)
+ }
+
+ events := store.GetAggregatedEvents()
+ assert.ElementsMatch(t, events, allExpected)
+ })
+ }
+}
+
+func TestResetAggregationWindow(t *testing.T) {
+ now := time.Now()
+ nowFunc := func() time.Time { return now }
+ store := NewAggregatingMemoryStoreWithTimeFunc(nowFunc)
+ store.StoreEvent(&types.Event{
+ ID: uuid.New(),
+ Timestamp: time.Now(),
+ EventFields: types.EventFields{
+ FlowID: uuid.New(),
+ Type: types.TypeStart,
+ Protocol: types.TCP,
+ RuleID: []byte("rule-id-1"),
+ Direction: types.Ingress,
+ SourceIP: netip.MustParseAddr("1.1.1.1"),
+ SourcePort: 1234,
+ DestIP: netip.MustParseAddr("2.2.2.2"),
+ DestPort: 5678,
+ SourceResourceID: []byte("source-resource-id"),
+ DestResourceID: []byte("dest-resource-id"),
+ RxPackets: random.Uint64(),
+ TxPackets: random.Uint64(),
+ RxBytes: random.Uint64(),
+ TxBytes: random.Uint64(),
+ },
+ })
+
+ now = now.Add(1 * time.Second)
+ reset := store.ResetAggregationWindow()
+ previousEvents, ok := reset.(*AggregatingMemory)
+ assert.True(t, ok)
+ assert.NotEqual(t, previousEvents.WindowStart, store.WindowStart)
+ assert.Equal(t, previousEvents.WindowEnd, store.WindowStart)
+ assert.NotEmpty(t, previousEvents.events)
+ assert.Empty(t, store.events)
+}
+
+func generateEvents(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol,
+ direction types.Direction, icmpType uint8, windowStart, windowEnd time.Time) ([]*types.Event, *types.Event) {
+ var rxPackets, txPackets, rxBytes, txBytes uint64
+ inEvents := make([]*types.Event, 0)
+ ts := time.Now()
+ flowId := uuid.New()
+ srcPort := uint16(random.Uint32() >> 16)
+
+ for idx, eventType := range eventTypes {
+ e := &types.Event{
+ ID: uuid.New(),
+ Timestamp: ts.Add(time.Duration(idx) * time.Second),
+ EventFields: types.EventFields{
+ FlowID: flowId,
+ Type: eventType,
+ Protocol: protocol,
+ RuleID: []byte("rule-id-1"),
+ Direction: direction,
+ SourceIP: srcIp,
+ SourcePort: srcPort,
+ DestIP: dstIp,
+ DestPort: dstPort,
+ SourceResourceID: []byte("source-resource-id"),
+ DestResourceID: []byte("dest-resource-id"),
+ RxPackets: random.Uint64(),
+ TxPackets: random.Uint64(),
+ RxBytes: random.Uint64(),
+ TxBytes: random.Uint64(),
+ }}
+ rxBytes += e.RxBytes
+ txBytes += e.TxBytes
+ rxPackets += e.RxPackets
+ txPackets += e.TxPackets
+ inEvents = append(inEvents, e)
+ if protocol == types.ICMP || protocol == types.ICMPv6 {
+ e.ICMPType = icmpType
+ }
+ }
+
+ var start, end, drop uint64
+ for _, eventType := range eventTypes {
+ switch eventType {
+ case types.TypeStart:
+ start += 1
+ case types.TypeDrop:
+ drop += 1
+ case types.TypeEnd:
+ end += 1
+ }
+ }
+ aggregatedEvent := &types.Event{
+ ID: inEvents[0].ID,
+ Timestamp: inEvents[0].Timestamp,
+ WindowStart: windowStart,
+ WindowEnd: windowEnd,
+ EventFields: types.EventFields{
+ FlowID: flowId,
+ Type: types.TypeUnknown,
+ Protocol: inEvents[0].Protocol,
+ RuleID: []byte("rule-id-1"),
+ Direction: inEvents[0].Direction,
+ SourceIP: srcIp,
+ SourcePort: srcPort,
+ DestIP: dstIp,
+ DestPort: dstPort,
+ SourceResourceID: []byte("source-resource-id"),
+ DestResourceID: []byte("dest-resource-id"),
+ RxPackets: rxPackets,
+ TxPackets: txPackets,
+ RxBytes: rxBytes,
+ TxBytes: txBytes,
+ NumOfStarts: start,
+ NumOfEnds: end,
+ NumOfDrops: drop,
+ }}
+ if protocol == types.ICMP || protocol == types.ICMPv6 {
+ aggregatedEvent.ICMPType = icmpType
+ }
+
+ return inEvents, aggregatedEvent
+}
+
+func generateEventsForUnknownProtocol(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol,
+ direction types.Direction, windowStart, windowEnd time.Time) ([]*types.Event, []*types.Event) {
+ inEvents := make([]*types.Event, 0)
+ expectedEvents := make([]*types.Event, 0)
+
+ ts := time.Now()
+ flowId := uuid.New()
+ srcPort := uint16(random.Uint32() >> 16)
+
+ for idx, eventType := range eventTypes {
+ e := &types.Event{
+ ID: uuid.New(),
+ Timestamp: ts.Add(time.Duration(idx) * time.Second),
+ EventFields: types.EventFields{
+ FlowID: flowId,
+ Type: eventType,
+ Protocol: protocol,
+ RuleID: []byte("rule-id-1"),
+ Direction: direction,
+ SourceIP: srcIp,
+ SourcePort: srcPort,
+ DestIP: dstIp,
+ DestPort: dstPort,
+ SourceResourceID: []byte("source-resource-id"),
+ DestResourceID: []byte("dest-resource-id"),
+ RxPackets: random.Uint64(),
+ TxPackets: random.Uint64(),
+ RxBytes: random.Uint64(),
+ TxBytes: random.Uint64(),
+ }}
+ inEvents = append(inEvents, e)
+
+ var start, end, drop uint64
+ switch eventType {
+ case types.TypeStart:
+ start = 1
+ case types.TypeDrop:
+ drop = 1
+ case types.TypeEnd:
+ end = 1
+ }
+
+ expectedEvents = append(expectedEvents, &types.Event{
+ ID: e.ID,
+ Timestamp: e.Timestamp,
+ WindowStart: windowStart,
+ WindowEnd: windowEnd,
+ EventFields: types.EventFields{
+ FlowID: flowId,
+ Type: types.TypeUnknown,
+ Protocol: e.Protocol,
+ RuleID: []byte("rule-id-1"),
+ Direction: e.Direction,
+ SourceIP: srcIp,
+ SourcePort: srcPort,
+ DestIP: dstIp,
+ DestPort: dstPort,
+ SourceResourceID: []byte("source-resource-id"),
+ DestResourceID: []byte("dest-resource-id"),
+ RxPackets: e.RxPackets,
+ TxPackets: e.TxPackets,
+ RxBytes: e.RxBytes,
+ TxBytes: e.TxBytes,
+ NumOfStarts: start,
+ NumOfEnds: end,
+ NumOfDrops: drop,
+ }})
+ }
+
+ return inEvents, expectedEvents
+}
diff --git a/client/internal/netflow/store/memory.go b/client/internal/netflow/store/memory.go
index a44505e96..dfe764032 100644
--- a/client/internal/netflow/store/memory.go
+++ b/client/internal/netflow/store/memory.go
@@ -1,10 +1,15 @@
package store
import (
+ "maps"
+ "math/rand"
+ v2 "math/rand/v2"
+ "net/netip"
+ "slices"
"sync"
+ "time"
"github.com/google/uuid"
-
"github.com/netbirdio/netbird/client/internal/netflow/types"
)
@@ -19,6 +24,14 @@ type Memory struct {
events map[uuid.UUID]*types.Event
}
+type AggregatingMemory struct {
+ Memory
+ WindowStart time.Time
+ WindowEnd time.Time
+ rnd *v2.PCG
+ nowFunc func() time.Time
+}
+
func (m *Memory) StoreEvent(event *types.Event) {
m.mux.Lock()
defer m.mux.Unlock()
@@ -48,3 +61,104 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) {
delete(m.events, id)
}
}
+
+func NewAggregatingMemoryStore() *AggregatingMemory {
+ return NewAggregatingMemoryStoreWithTimeFunc(defaultNowFunc)
+}
+
+// used in tests when deterministic (less random) time intervals are required
+func NewAggregatingMemoryStoreWithTimeFunc(nowFunc func() time.Time) *AggregatingMemory {
+ return &AggregatingMemory{WindowStart: nowFunc(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, nowFunc: nowFunc, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
+}
+
+func (am *AggregatingMemory) ResetAggregationWindow() types.FlowEventAggregator {
+ am.mux.Lock()
+ defer am.mux.Unlock()
+
+ now := am.nowFunc()
+ toret := AggregatingMemory{WindowStart: am.WindowStart, WindowEnd: now, Memory: Memory{events: am.events}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
+
+ am.events = make(map[uuid.UUID]*types.Event)
+ am.WindowStart = now
+
+ return &toret
+}
+
+type aggregationKey struct {
+ srcAddr netip.Addr
+ destAddr netip.Addr
+ destPort uint16
+ direction int
+ protocol uint8
+ icmpType uint8
+ unique uint64 // used to prevent aggregation on non icmp/udp/tcp events
+}
+
+func (am *AggregatingMemory) GetAggregatedEvents() []*types.Event {
+ am.mux.Lock()
+ defer am.mux.Unlock()
+
+ aggregated := make(map[aggregationKey]*types.Event)
+ for _, v := range am.events {
+ lookupKey := aggregationKey{srcAddr: v.SourceIP, destAddr: v.DestIP, destPort: v.DestPort, direction: int(v.Direction), protocol: uint8(v.Protocol), icmpType: v.ICMPType}
+ if _, ok := aggregated[lookupKey]; !ok {
+ event := v.Clone()
+
+ switch event.Type {
+ case types.TypeStart:
+ event.NumOfStarts += 1
+ case types.TypeDrop:
+ event.NumOfDrops += 1
+ case types.TypeEnd:
+ event.NumOfEnds += 1
+ }
+ event.Type = types.TypeUnknown
+
+ // Please note that ICMPCode field isn't propagated by the manager (see flow/proto/flow.pb.go, FlowFields struct)
+ // so the field value in an icmp event in the "aggregated" doesn't matter
+
+ event.WindowStart = am.WindowStart
+ event.WindowEnd = am.WindowEnd
+
+ if event.Protocol != types.ICMP && event.Protocol != types.ICMPv6 && event.Protocol != types.UDP && event.Protocol != types.TCP {
+ lookupKey.unique = am.rnd.Uint64() // to make the lookup key unique so we don't aggregate on it
+ }
+
+ aggregated[lookupKey] = event
+ continue
+ }
+
+ aggregatedEvent := aggregated[lookupKey]
+ if aggregatedEvent.Protocol != types.ICMP && aggregatedEvent.Protocol != types.ICMPv6 && aggregatedEvent.Protocol != types.UDP && aggregatedEvent.Protocol != types.TCP {
+ continue // we don't aggregate this type of events; shouldn't ever get here
+ }
+
+ // track the number of connections, duration?, open and close events?
+ aggregatedEvent.RxBytes += v.RxBytes
+ aggregatedEvent.RxPackets += v.RxPackets
+ aggregatedEvent.TxBytes += v.TxBytes
+ aggregatedEvent.TxPackets += v.TxPackets
+ switch v.Type {
+ case types.TypeStart:
+ aggregatedEvent.NumOfStarts += 1
+ case types.TypeDrop:
+ aggregatedEvent.NumOfDrops += 1
+ case types.TypeEnd:
+ aggregatedEvent.NumOfEnds += 1
+ }
+ if aggregatedEvent.Timestamp.Compare(v.Timestamp) > 0 {
+ aggregatedEvent.Timestamp = v.Timestamp
+ aggregatedEvent.ID = v.ID
+ aggregatedEvent.SourcePort = v.SourcePort
+ }
+ if len(aggregatedEvent.RuleID) == 0 && len(v.RuleID) != 0 {
+ aggregatedEvent.RuleID = slices.Clone(v.RuleID)
+ }
+ }
+
+ return slices.Collect(maps.Values(aggregated)) // could return an iterator instead here
+}
+
+func defaultNowFunc() time.Time {
+ return time.Now()
+}
diff --git a/client/internal/netflow/types/types.go b/client/internal/netflow/types/types.go
index 3f7d0d0ad..ccb2da66b 100644
--- a/client/internal/netflow/types/types.go
+++ b/client/internal/netflow/types/types.go
@@ -2,6 +2,7 @@ package types
import (
"net/netip"
+ "slices"
"strconv"
"time"
@@ -69,8 +70,10 @@ const (
)
type Event struct {
- ID uuid.UUID
- Timestamp time.Time
+ ID uuid.UUID
+ Timestamp time.Time
+ WindowStart time.Time
+ WindowEnd time.Time
EventFields
}
@@ -92,6 +95,17 @@ type EventFields struct {
TxPackets uint64
RxBytes uint64
TxBytes uint64
+ NumOfStarts uint64
+ NumOfEnds uint64
+ NumOfDrops uint64
+}
+
+func (e *Event) Clone() *Event {
+ toret := *e
+ toret.RuleID = slices.Clone(e.RuleID)
+ toret.SourceResourceID = slices.Clone(e.SourceResourceID)
+ toret.DestResourceID = slices.Clone(e.DestResourceID)
+ return &toret
}
type FlowConfig struct {
@@ -114,13 +128,15 @@ type FlowManager interface {
GetLogger() FlowLogger
}
+type FlowEventAggregator interface {
+ ResetAggregationWindow() FlowEventAggregator
+ GetAggregatedEvents() []*Event
+}
+
type FlowLogger interface {
+ ResetAggregationWindow() FlowEventAggregator
// StoreEvent stores a flow event
StoreEvent(flowEvent EventFields)
- // GetEvents returns all stored events
- GetEvents() []*Event
- // DeleteEvents deletes events from the store
- DeleteEvents([]uuid.UUID)
// Close closes the logger
Close()
// Enable enables the flow logger receiver
@@ -140,6 +156,11 @@ type Store interface {
Close()
}
+type AggregatingStore interface {
+ FlowEventAggregator
+ Store
+}
+
// ConnTracker defines the interface for connection tracking functionality
type ConnTracker interface {
// Start begins tracking connections by listening for conntrack events.
diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go
index 79a513956..b84b05671 100644
--- a/client/internal/peer/conn.go
+++ b/client/internal/peer/conn.go
@@ -6,6 +6,7 @@ import (
"net"
"net/netip"
"runtime"
+ "slices"
"sync"
"time"
@@ -25,10 +26,16 @@ import (
"github.com/netbirdio/netbird/client/internal/portforward"
"github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/stdnet"
+ "github.com/netbirdio/netbird/client/netstate"
"github.com/netbirdio/netbird/route"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
)
+// wgTimeoutEscalationThreshold is the number of consecutive WireGuard
+// handshake timeouts after which the rosenpass state for the peer is
+// considered desynced and gets reset.
+const wgTimeoutEscalationThreshold = 3
+
// MetricsRecorder is an interface for recording peer connection metrics
type MetricsRecorder interface {
RecordConnectionStages(
@@ -87,6 +94,10 @@ type ConnConfig struct {
// ICEConfig ICE protocol configuration
ICEConfig icemaker.Config
+
+ // NetworkState gates the reconnection guard on OS-reported network
+ // availability; nil disables gating.
+ NetworkState *netstate.State
}
type Conn struct {
@@ -117,6 +128,9 @@ type Conn struct {
wgWatcher *WGWatcher
wgWatcherWg sync.WaitGroup
wgWatcherCancel context.CancelFunc
+ // wgTimeouts counts consecutive WireGuard handshake timeouts without a
+ // successful handshake in between. Guarded by mu.
+ wgTimeouts int
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
rosenpassRemoteKey []byte
@@ -136,6 +150,39 @@ type Conn struct {
// Connection stage timestamps for metrics
metricsRecorder MetricsRecorder
metricsStages *MetricsStages
+
+ // pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real
+ // transport is up.
+ pendingFirstPacket []byte
+}
+
+// injectPendingFirstPacket replays the captured handshake through the proxy if present, else
+// directly through the ICE conn. The packet is cleared only after a successful write, so a failed
+// or transport-less attempt leaves it available for a later reinjection. Caller must hold conn.mu.
+func (conn *Conn) injectPendingFirstPacket(proxy wgproxy.Proxy, directConn net.Conn) {
+ pkt := conn.pendingFirstPacket
+ if len(pkt) == 0 {
+ return
+ }
+
+ switch {
+ case proxy != nil:
+ if err := proxy.InjectPacket(pkt); err != nil {
+ conn.Log.Debugf("failed to reinject captured first packet via proxy: %v", err)
+ return
+ }
+ case directConn != nil:
+ if _, err := directConn.Write(pkt); err != nil {
+ conn.Log.Debugf("failed to reinject captured first packet via direct conn: %v", err)
+ return
+ }
+ default:
+ conn.Log.Debugf("no transport available to reinject captured first packet")
+ return
+ }
+
+ conn.pendingFirstPacket = nil
+ conn.Log.Debugf("reinjected captured first packet (%d bytes)", len(pkt))
}
// NewConn creates a new not opened Conn to the remote peer.
@@ -161,7 +208,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
statusICE: worker.NewAtomicStatus(),
dumpState: dumpState,
endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)),
- wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState),
metricsRecorder: services.MetricsRecorder,
}
@@ -172,6 +218,16 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
// It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will
// be used.
func (conn *Conn) Open(engineCtx context.Context) error {
+ return conn.open(engineCtx, nil)
+}
+
+// OpenWithFirstPacket opens the connection like Open and stashes firstPacket to be replayed once
+// the real transport is established. The packet is retained only on a successful open.
+func (conn *Conn) OpenWithFirstPacket(engineCtx context.Context, firstPacket []byte) error {
+ return conn.open(engineCtx, firstPacket)
+}
+
+func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
conn.mu.Lock()
defer conn.mu.Unlock()
@@ -203,7 +259,7 @@ func (conn *Conn) Open(engineCtx context.Context) error {
conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer)
}
- conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher)
+ conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetworkState)
conn.wg.Add(1)
go func() {
@@ -227,6 +283,9 @@ func (conn *Conn) Open(engineCtx context.Context) error {
defer conn.wg.Done()
conn.guard.Start(conn.ctx, conn.onGuardEvent)
}()
+ if len(firstPacket) > 0 {
+ conn.pendingFirstPacket = slices.Clone(firstPacket)
+ }
conn.opened = true
return nil
}
@@ -253,6 +312,8 @@ func (conn *Conn) Close(signalToRemote bool) {
if conn.wgWatcherCancel != nil {
conn.wgWatcherCancel()
+ conn.wgWatcher = nil
+ conn.wgWatcherCancel = nil
}
conn.workerRelay.CloseConn()
if conn.workerICE != nil {
@@ -384,7 +445,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
conn.dumpState.NewLocalProxy()
wgProxy, err = conn.newProxy(iceConnInfo.RemoteConn)
if err != nil {
- conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err)
+ conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err)
return
}
ep = wgProxy.EndpointAddr()
@@ -423,6 +484,8 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
conn.wgProxyRelay.RedirectAs(ep)
}
+ conn.injectPendingFirstPacket(wgProxy, iceConnInfo.RemoteConn)
+
conn.currentConnPriority = priority
conn.statusICE.SetConnected()
conn.updateIceState(iceConnInfo, updateTime)
@@ -546,6 +609,8 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
wgConfigWorkaround()
+ conn.injectPendingFirstPacket(wgProxy, nil)
+
conn.rosenpassRemoteKey = rci.rosenpassPubKey
conn.currentConnPriority = conntype.Relay
conn.statusRelay.SetConnected()
@@ -612,11 +677,12 @@ func (conn *Conn) onGuardEvent() {
}
}
-func (conn *Conn) onWGDisconnected() {
+func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
conn.mu.Lock()
defer conn.mu.Unlock()
- if conn.ctx.Err() != nil {
+ // watcherCtx guards against a stale watcher tearing down a connection that already superseded it.
+ if conn.ctx.Err() != nil || watcherCtx.Err() != nil {
return
}
@@ -632,6 +698,29 @@ func (conn *Conn) onWGDisconnected() {
default:
conn.Log.Debugf("No active connection to close on WG timeout")
}
+
+ conn.escalateWGTimeoutLocked()
+}
+
+// escalateWGTimeoutLocked resets the peer's rosenpass state after repeated
+// handshake timeouts. With rosenpass enabled, persistent timeouts mean the
+// preshared keys have desynced; the renewal exchange runs over the dead
+// tunnel and cannot resync them. Reporting the peer disconnected drops its
+// rosenpass state, so the next connection configuration programs the
+// rendezvous key and the tunnel can bootstrap again. Callers must hold mu.
+func (conn *Conn) escalateWGTimeoutLocked() {
+ if conn.config.RosenpassConfig.PubKey == nil {
+ return
+ }
+
+ conn.wgTimeouts++
+ if conn.wgTimeouts < wgTimeoutEscalationThreshold || conn.onDisconnected == nil {
+ return
+ }
+ conn.wgTimeouts = 0
+
+ conn.Log.Warnf("%d consecutive WireGuard handshake timeouts, resetting rosenpass state for peer", wgTimeoutEscalationThreshold)
+ conn.onDisconnected(conn.config.WgConfig.RemoteKey)
}
func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) {
@@ -751,23 +840,39 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) {
})
}
+// enableWgWatcherIfNeeded starts a fresh watcher instance per connection attempt, so its
+// lifecycle stays bound to conn.mu and enable/disable can't race an old goroutine's shutdown.
+// Caller must hold conn.mu.
func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
- if !conn.wgWatcher.IsEnabled() {
- wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
- conn.wgWatcherCancel = wgWatcherCancel
- conn.wgWatcherWg.Add(1)
- go func() {
- defer conn.wgWatcherWg.Done()
- conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess)
- }()
+ if conn.wgWatcher != nil {
+ return
}
+
+ watcher := NewWGWatcher(conn.Log, conn.config.WgConfig.WgInterface, conn.config.Key, conn.dumpState)
+ watcher.PrepareInitialHandshake()
+
+ wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
+ conn.wgWatcher = watcher
+ conn.wgWatcherCancel = wgWatcherCancel
+
+ conn.wgWatcherWg.Add(1)
+ go func() {
+ defer conn.wgWatcherWg.Done()
+ onDisconnected := func() { conn.onWGDisconnected(wgWatcherCtx) }
+ watcher.EnableWgWatcher(wgWatcherCtx, enabledTime, onDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess)
+ }()
}
+// disableWgWatcherIfNeeded cancels and drops the watcher once no transport is active. It never
+// waits for the goroutine: the timeout path reentrantly calls back here under conn.mu, so
+// blocking would deadlock. Caller must hold conn.mu.
func (conn *Conn) disableWgWatcherIfNeeded() {
- if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil {
- conn.wgWatcherCancel()
- conn.wgWatcherCancel = nil
+ if conn.currentConnPriority != conntype.None || conn.wgWatcher == nil {
+ return
}
+ conn.wgWatcherCancel()
+ conn.wgWatcher = nil
+ conn.wgWatcherCancel = nil
}
func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
@@ -778,9 +883,8 @@ func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
}
wgProxy := conn.config.WgConfig.WgInterface.GetProxy()
- if err := wgProxy.AddTurnConn(conn.ctx, udpAddr, remoteConn); err != nil {
- conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err)
- return nil, err
+ if err := wgProxy.AddRelayedConn(conn.ctx, udpAddr, remoteConn); err != nil {
+ return nil, fmt.Errorf("add relayed conn to proxy: %w", err)
}
return wgProxy, nil
}
@@ -790,7 +894,9 @@ func (conn *Conn) resetEndpoint() {
return
}
conn.Log.Infof("reset wg endpoint")
- conn.wgWatcher.Reset()
+ if conn.wgWatcher != nil {
+ conn.wgWatcher.Reset()
+ }
if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil {
conn.Log.Warnf("failed to remove endpoint address before update: %v", err)
}
@@ -839,6 +945,15 @@ func (conn *Conn) onWGHandshakeSuccess(when time.Time) {
conn.recordConnectionMetrics()
}
+// onWGCheckSuccess is called for every watcher check that observed a fresh
+// handshake, including handshakes of connections that were already up when
+// the watcher started.
+func (conn *Conn) onWGCheckSuccess() {
+ conn.mu.Lock()
+ conn.wgTimeouts = 0
+ conn.mu.Unlock()
+}
+
// recordConnectionMetrics records connection stage timestamps as metrics
func (conn *Conn) recordConnectionMetrics() {
if conn.metricsRecorder == nil {
@@ -850,12 +965,9 @@ func (conn *Conn) recordConnectionMetrics() {
priority := conn.currentConnPriority
conn.mu.Unlock()
- var connType metrics.ConnectionType
- switch priority {
- case conntype.Relay:
- connType = metrics.ConnectionTypeRelay
- default:
- connType = metrics.ConnectionTypeICE
+ connType := metricsConnType(priority)
+ if connType == metrics.ConnectionTypeUnknown {
+ return
}
// Record metrics with timestamps - duration calculation happens in metrics package
@@ -956,3 +1068,16 @@ func boolToConnStatus(connected bool) guard.ConnStatus {
}
return guard.ConnStatusDisconnected
}
+
+func metricsConnType(priority conntype.ConnPriority) metrics.ConnectionType {
+ switch priority {
+ case conntype.Relay:
+ return metrics.ConnectionTypeRelay
+ case conntype.ICETurn:
+ return metrics.ConnectionTypeICETurn
+ case conntype.ICEP2P:
+ return metrics.ConnectionTypeICEP2P
+ default:
+ return metrics.ConnectionTypeUnknown
+ }
+}
diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go
index 59216b647..b709d5e40 100644
--- a/client/internal/peer/conn_test.go
+++ b/client/internal/peer/conn_test.go
@@ -7,9 +7,12 @@ import (
"testing"
"time"
+ log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/internal/metrics"
+ "github.com/netbirdio/netbird/client/internal/peer/conntype"
"github.com/netbirdio/netbird/client/internal/peer/dispatcher"
"github.com/netbirdio/netbird/client/internal/peer/guard"
"github.com/netbirdio/netbird/client/internal/peer/ice"
@@ -304,3 +307,114 @@ func TestConn_presharedKey_RosenpassManaged(t *testing.T) {
t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK")
}
}
+
+func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn {
+ cfg := ConnConfig{
+ Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ WgConfig: WgConfig{RemoteKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU="},
+ }
+ if rosenpassEnabled {
+ cfg.RosenpassConfig = RosenpassConfig{PubKey: []byte("dummykey")}
+ }
+
+ conn := &Conn{
+ ctx: context.Background(),
+ config: cfg,
+ Log: log.WithField("peer", cfg.Key),
+ metricsStages: &MetricsStages{},
+ }
+ conn.SetOnDisconnected(func(remotePeer string) {
+ *disconnected = append(*disconnected, remotePeer)
+ })
+ return conn
+}
+
+// TestConn_onWGDisconnected_EscalatesToRosenpassReset: repeated handshake
+// timeouts with rosenpass enabled mean the preshared keys have desynced. The
+// renewal exchange runs over the dead tunnel and cannot resync them, so after
+// wgTimeoutEscalationThreshold consecutive timeouts the conn must report the
+// peer disconnected, dropping its rosenpass state so the next configuration
+// programs the rendezvous key.
+func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) {
+ var disconnected []string
+ conn := newWGTimeoutTestConn(true, &disconnected)
+
+ for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
+ conn.onWGDisconnected(conn.ctx)
+ }
+ assert.Empty(t, disconnected, "escalation must not fire below the threshold")
+
+ conn.onWGDisconnected(conn.ctx)
+ assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected,
+ "reaching the threshold must report the peer disconnected once")
+
+ for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
+ conn.onWGDisconnected(conn.ctx)
+ }
+ assert.Len(t, disconnected, 1, "escalation must restart counting after firing")
+
+ conn.onWGDisconnected(conn.ctx)
+ assert.Len(t, disconnected, 2, "continued timeouts must escalate again")
+}
+
+// TestConn_onWGDisconnected_CheckSuccessResetsEscalation: a successful
+// handshake between timeouts means the tunnel recovered; the counter must
+// start over.
+func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
+ var disconnected []string
+ conn := newWGTimeoutTestConn(true, &disconnected)
+
+ for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
+ conn.onWGDisconnected(conn.ctx)
+ }
+ conn.onWGCheckSuccess()
+
+ for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
+ conn.onWGDisconnected(conn.ctx)
+ }
+ assert.Empty(t, disconnected, "handshake success must reset the timeout count")
+}
+
+// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass
+// there is no per-peer key state to reset; repeated timeouts must not report
+// disconnects.
+func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
+ var disconnected []string
+ conn := newWGTimeoutTestConn(false, &disconnected)
+
+ for i := 0; i < wgTimeoutEscalationThreshold*3; i++ {
+ conn.onWGDisconnected(conn.ctx)
+ }
+ assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
+}
+
+func TestMetricsConnType(t *testing.T) {
+ tests := []struct {
+ name string
+ priority conntype.ConnPriority
+ expected metrics.ConnectionType
+ }{
+ {"relay", conntype.Relay, metrics.ConnectionTypeRelay},
+ {"ice over turn is relayed, not p2p", conntype.ICETurn, metrics.ConnectionTypeICETurn},
+ {"direct p2p", conntype.ICEP2P, metrics.ConnectionTypeICEP2P},
+ {"unset priority is unknown, not p2p", conntype.None, metrics.ConnectionTypeUnknown},
+ {"unrecognised priority is unknown", conntype.ConnPriority(99), metrics.ConnectionTypeUnknown},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.expected, metricsConnType(tc.priority))
+ })
+ }
+}
+
+func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) {
+ for _, priority := range []conntype.ConnPriority{conntype.None, conntype.Relay, conntype.ICETurn, conntype.ICEP2P} {
+ conn := &Conn{currentConnPriority: priority}
+ tag := metricsConnType(priority)
+ relayedTag := tag == metrics.ConnectionTypeRelay || tag == metrics.ConnectionTypeICETurn
+ assert.Equal(t, conn.isRelayed(), relayedTag,
+ "priority %s: isRelayed and the %q metric tag must agree", priority, tag)
+ }
+}
diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go
index 2e5efbcc5..68d77d318 100644
--- a/client/internal/peer/guard/guard.go
+++ b/client/internal/peer/guard/guard.go
@@ -6,6 +6,8 @@ import (
"github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/netstate"
)
// ConnStatus represents the connection state as seen by the guard.
@@ -31,20 +33,26 @@ type connStatusFunc func() ConnStatus
// - Relayed connection disconnected
// - ICE candidate changes
type Guard struct {
- log *log.Entry
- isConnectedOnAllWay connStatusFunc
- timeout time.Duration
- srWatcher *SRWatcher
+ log *log.Entry
+ isConnectedOnAllWay connStatusFunc
+ timeout time.Duration
+ srWatcher *SRWatcher
+ // netState gates reconnect attempts on OS-reported network availability;
+ // nil disables gating.
+ netState *netstate.State
relayedConnDisconnected chan struct{}
iCEConnDisconnected chan struct{}
}
-func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard {
+// NewGuard creates a reconnection guard for a peer connection. A nil netState
+// disables network availability gating.
+func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netState *netstate.State) *Guard {
return &Guard{
log: log,
isConnectedOnAllWay: isConnectedFn,
timeout: timeout,
srWatcher: srWatcher,
+ netState: netState,
relayedConnDisconnected: make(chan struct{}, 1),
iCEConnDisconnected: make(chan struct{}, 1),
}
@@ -85,16 +93,27 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
defer g.srWatcher.RemoveListener(srReconnectedChan)
ticker := g.initialTicker(ctx)
- defer ticker.Stop()
+ defer func() {
+ // If backoff.Ticker.send is blocked, context.Done will not close the Ticker goroutine.
+ // We have to explicitly call Stop, even if we use backoff.WithContext.
+ ticker.Stop()
+ }()
tickerChannel := ticker.C
iceState := &iceRetryState{log: g.log}
defer iceState.reset()
+ netChanged := g.netState.Changed()
+
for {
select {
case <-tickerChannel:
+ // skip attempts while the OS reports no usable network; the
+ // netChanged case below resumes the loop once it returns
+ if !g.netState.IsOnline() {
+ continue
+ }
switch g.isConnectedOnAllWay() {
case ConnStatusConnected:
// all good, nothing to do
@@ -131,6 +150,23 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
tickerChannel = ticker.C
iceState.reset()
+ case <-netChanged:
+ // Re-arm for the next transition before acting on this one.
+ netChanged = g.netState.Changed()
+ if !g.netState.IsOnline() {
+ continue
+ }
+ // Ticks skipped while offline drove the backoff towards its
+ // maximum without ever attempting, and left the ICE budget
+ // frozen — possibly in hourly mode. Recover on our own so the
+ // peer does not depend on a signal or relay event that never
+ // comes when both stayed up across the outage.
+ g.log.Debugf("network is back, reset reconnection ticker")
+ ticker.Stop()
+ ticker = g.newReconnectTicker(ctx)
+ tickerChannel = ticker.C
+ iceState.reset()
+
case <-ctx.Done():
g.log.Debugf("context is done, stop reconnect loop")
return
diff --git a/client/internal/peer/guard/guard_leak_test.go b/client/internal/peer/guard/guard_leak_test.go
new file mode 100644
index 000000000..3d82ec591
--- /dev/null
+++ b/client/internal/peer/guard/guard_leak_test.go
@@ -0,0 +1,92 @@
+package guard
+
+import (
+ "context"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/peer/ice"
+)
+
+func newTestGuard(status connStatusFunc) *Guard {
+ srw := NewSRWatcher(nil, nil, nil, ice.Config{})
+ return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw, nil)
+}
+
+// countBackoffTickerGoroutines returns how many goroutines are currently sitting
+// in backoff/v4.(*Ticker).run (a ticker goroutine that has not exited).
+func countBackoffTickerGoroutines() int {
+ buf := make([]byte, 1<<25) // 32MB
+ n := runtime.Stack(buf, true)
+ return strings.Count(string(buf[:n]), "backoff/v4.(*Ticker).run")
+}
+
+// TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown reproduces a observed
+// leak: after a shutdown burst, ticker run/send goroutines stay parked
+// forever even though every reconnect loop has exited.
+func TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown(t *testing.T) {
+ before := countBackoffTickerGoroutines()
+
+ const peers = 6000
+ cancels := make([]context.CancelFunc, 0, peers)
+ var wg sync.WaitGroup
+
+ // A status check slower than the tick cadence. This models the real
+ // isConnectedOnAllWay/callback doing work: while the loop is busy in the
+ // handler, the ticker fires the next tick and parks in send(), because
+ // send() never selects on ctx.
+ slowStatus := func() ConnStatus {
+ time.Sleep(70 * time.Millisecond)
+ return ConnStatusConnected
+ }
+
+ for range peers {
+ g := newTestGuard(slowStatus)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancels = append(cancels, cancel)
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ g.Start(ctx, func() {})
+ }()
+ // Force the live ticker to be a newReconnectTicker.
+ g.SetRelayedConnDisconnected()
+ }
+
+ // Let the replacement tickers get past their 800ms initial interval, so
+ // many are parked in send() waiting on the (slow) consumer when we tear
+ // everything down.
+ time.Sleep(1500 * time.Millisecond)
+
+ // Shutdown burst: cancel every peer at once, like engine teardown.
+ for _, c := range cancels {
+ c()
+ }
+
+ // Every reconnect loop must return
+ waitCh := make(chan struct{})
+ go func() { wg.Wait(); close(waitCh) }()
+ select {
+ case <-waitCh:
+ case <-time.After(30 * time.Second):
+ t.Fatal("not all reconnect loops returned after ctx cancel")
+ }
+
+ // Give any correctly-stopped ticker goroutines time to unwind.
+ for range 50 {
+ runtime.Gosched()
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ leaked := countBackoffTickerGoroutines() - before
+ t.Logf("backoff Ticker.run goroutines still parked after teardown of %d peers: %d", peers, leaked)
+ if leaked > 0 {
+ t.Errorf("LEAK: %d backoff ticker goroutines parked after all reconnect loops exited "+
+ "(defer ticker.Stop() stops the initial ticker, not the live replacement)", leaked)
+ }
+}
diff --git a/client/internal/peer/guard/guard_netstate_test.go b/client/internal/peer/guard/guard_netstate_test.go
new file mode 100644
index 000000000..2ab736428
--- /dev/null
+++ b/client/internal/peer/guard/guard_netstate_test.go
@@ -0,0 +1,107 @@
+package guard
+
+import (
+ "context"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/peer/ice"
+ "github.com/netbirdio/netbird/client/netstate"
+)
+
+// newTestGuardWithNetState builds a guard with a realistic MaxInterval: the
+// backoff must be able to grow well past the outage, as it does in production
+// where the timeout is seconds to minutes.
+func newTestGuardWithNetState(status connStatusFunc, netState *netstate.State) *Guard {
+ srw := NewSRWatcher(nil, nil, nil, ice.Config{})
+ return NewGuard(log.WithField("test", "guard"), status, 30*time.Second, srw, netState)
+}
+
+// TestGuard_RecoversAfterOfflineToOnline covers a peer that stays disconnected
+// across a network outage while neither signal nor relay reports an event —
+// both stayed up, as on a short airplane mode toggle over Wi-Fi.
+//
+// Every tick taken while offline is skipped, but it still advances the
+// exponential backoff, so by the time the network returns the next tick can be
+// tens of seconds away. Without an explicit reaction to the transition the
+// peer waits out that interval for a recovery that could start immediately.
+func TestGuard_RecoversAfterOfflineToOnline(t *testing.T) {
+ netState := netstate.New()
+
+ var attempts atomic.Int32
+ g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ // Start from the reconnect ticker (800ms initial interval), the state a
+ // peer is in after it loses its connection.
+ go g.Start(ctx, func() { attempts.Add(1) })
+ g.SetRelayedConnDisconnected()
+
+ // Let the backoff climb: 0.8s, 1.6s, 3.2s, 6.4s ... every tick is skipped
+ // while offline, but each one doubles the wait for the next.
+ netState.Set(false)
+ time.Sleep(8 * time.Second)
+
+ offlineAttempts := attempts.Load()
+ if offlineAttempts != 0 {
+ t.Fatalf("callback ran %d times while offline, want 0", offlineAttempts)
+ }
+
+ netState.Set(true)
+
+ // The next organic tick is now several seconds out, so anything within
+ // this window can only come from reacting to the transition itself.
+ pollCtx, stopPolling := context.WithTimeout(ctx, 2*time.Second)
+ defer stopPolling()
+
+ select {
+ case <-pollCtx.Done():
+ t.Fatal("peer was not retried within 2s of the network coming back, " +
+ "with neither a signal nor a relay event to fall back on")
+ case <-pollUntil(pollCtx, func() bool { return attempts.Load() > 0 }):
+ }
+}
+
+// TestGuard_OfflineTransitionDoesNotRetry checks the other direction: going
+// offline must not itself trigger an attempt.
+func TestGuard_OfflineTransitionDoesNotRetry(t *testing.T) {
+ netState := netstate.New()
+
+ var attempts atomic.Int32
+ g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go g.Start(ctx, func() { attempts.Add(1) })
+
+ netState.Set(false)
+ time.Sleep(5 * time.Second)
+
+ if got := attempts.Load(); got != 0 {
+ t.Fatalf("callback ran %d times after going offline, want 0", got)
+ }
+}
+
+// pollUntil closes the returned channel once cond holds. It gives up when ctx
+// is done, so the polling goroutine never outlives the test that started it.
+func pollUntil(ctx context.Context, cond func() bool) <-chan struct{} {
+ done := make(chan struct{})
+ go func() {
+ for {
+ if cond() {
+ close(done)
+ return
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(10 * time.Millisecond):
+ }
+ }
+ }()
+ return done
+}
diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go
index 1d44096b6..6ecb2a947 100644
--- a/client/internal/peer/handshaker.go
+++ b/client/internal/peer/handshaker.go
@@ -81,14 +81,19 @@ type Handshaker struct {
func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker {
h := &Handshaker{
- log: log,
- config: config,
- signaler: signaler,
- ice: ice,
- relay: relay,
- metricsStages: metricsStages,
- remoteOffersCh: make(chan OfferAnswer),
- remoteAnswerCh: make(chan OfferAnswer),
+ log: log,
+ config: config,
+ signaler: signaler,
+ ice: ice,
+ relay: relay,
+ metricsStages: metricsStages,
+ // Buffered by one so an offer or answer that arrives between Open launching
+ // the Listen goroutine and it reaching its receive is held rather than
+ // dropped. A peer activated by an incoming signal receives the remote's
+ // message in that window; an unbuffered channel skips it as "receiver not
+ // ready", and the connection cannot proceed until the remote re-sends.
+ remoteOffersCh: make(chan OfferAnswer, 1),
+ remoteAnswerCh: make(chan OfferAnswer, 1),
}
// assume remote supports ICE until we learn otherwise from received offers
h.remoteICESupported.Store(ice != nil)
@@ -162,29 +167,38 @@ func (h *Handshaker) SendOffer() error {
return h.sendOffer()
}
-// OnRemoteOffer handles an offer from the remote peer and returns true if the message was accepted, false otherwise
-// doesn't block, discards the message if connection wasn't ready
+// OnRemoteOffer hands an offer to Listen without blocking, keeping only the most
+// recent one if several arrive before Listen reads them.
func (h *Handshaker) OnRemoteOffer(offer OfferAnswer) {
- select {
- case h.remoteOffersCh <- offer:
- return
- default:
- h.log.Warnf("skipping remote offer message because receiver not ready")
- // connection might not be ready yet to receive so we ignore the message
- return
- }
+ enqueueLatest(h.remoteOffersCh, offer)
}
-// OnRemoteAnswer handles an offer from the remote peer and returns true if the message was accepted, false otherwise
-// doesn't block, discards the message if connection wasn't ready
+// OnRemoteAnswer hands an answer to Listen without blocking, keeping only the most
+// recent one if several arrive before Listen reads them.
func (h *Handshaker) OnRemoteAnswer(answer OfferAnswer) {
+ enqueueLatest(h.remoteAnswerCh, answer)
+}
+
+// enqueueLatest delivers msg on a one-slot channel without blocking. When the slot
+// already holds an unread message the older one is discarded in favor of msg, so a
+// message arriving before Listen starts reading is held rather than dropped, and
+// the newest wins if several arrive first. Safe because there is a single producer
+// (the engine loop): after draining the stale value the send always has room.
+func enqueueLatest(ch chan OfferAnswer, msg OfferAnswer) {
select {
- case h.remoteAnswerCh <- answer:
+ case ch <- msg:
return
default:
- // connection might not be ready yet to receive so we ignore the message
- h.log.Warnf("skipping remote answer message because receiver not ready")
- return
+ }
+
+ select {
+ case <-ch:
+ default:
+ }
+
+ select {
+ case ch <- msg:
+ default:
}
}
@@ -195,14 +209,14 @@ func (h *Handshaker) sendOffer() error {
}
offer := h.buildOfferAnswer()
- h.log.Infof("sending offer with serial: %s", offer.SessionIDString())
+ h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
answer := h.buildOfferAnswer()
- h.log.Infof("sending answer with serial: %s", answer.SessionIDString())
+ h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
return h.signaler.SignalAnswer(answer, h.config.Key)
}
diff --git a/client/internal/peer/handshaker_test.go b/client/internal/peer/handshaker_test.go
new file mode 100644
index 000000000..5e203d78b
--- /dev/null
+++ b/client/internal/peer/handshaker_test.go
@@ -0,0 +1,63 @@
+package peer
+
+import (
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+)
+
+func newTestHandshaker(t *testing.T) *Handshaker {
+ t.Helper()
+ // The tests exercise the answer path, whose Listen branch dispatches to the
+ // relay listener without sending an answer, so no signaler/ICE/relay is needed.
+ return NewHandshaker(log.WithField("test", t.Name()), ConnConfig{}, nil, nil, nil, nil)
+}
+
+// TestHandshakerHoldsSignalArrivingBeforeListen covers the case where a peer is
+// activated by an incoming signal: the remote's offer/answer arrives in the same
+// step that opens the connection, before the Listen loop starts reading. The
+// message must be held rather than dropped, or the connection cannot proceed until
+// the remote re-sends. This is the path taken when an eager peer connects to a
+// lazily-managed one.
+func TestHandshakerHoldsSignalArrivingBeforeListen(t *testing.T) {
+ h := newTestHandshaker(t)
+
+ processed := make(chan *OfferAnswer, 4)
+ h.AddRelayListener(func(o *OfferAnswer) { processed <- o })
+
+ // Delivered before Listen is reading, as when the peer is woken by the remote's
+ // signal and the message is delivered right after Open.
+ h.OnRemoteAnswer(OfferAnswer{WgListenPort: 51820})
+
+ go h.Listen(t.Context())
+
+ select {
+ case <-processed:
+ case <-time.After(2 * time.Second):
+ assert.Fail(t, "remote-answer dispatch: signal delivered before Listen was ready was dropped")
+ }
+}
+
+// TestHandshakerKeepsLatestSignalBeforeListen covers several signals arriving
+// before Listen reads: the newest must win (matching the latest-offer contract),
+// rather than the first being kept and later ones discarded.
+func TestHandshakerKeepsLatestSignalBeforeListen(t *testing.T) {
+ h := newTestHandshaker(t)
+
+ processed := make(chan *OfferAnswer, 4)
+ h.AddRelayListener(func(o *OfferAnswer) { processed <- o })
+
+ h.OnRemoteAnswer(OfferAnswer{WgListenPort: 1111})
+ h.OnRemoteAnswer(OfferAnswer{WgListenPort: 2222})
+
+ go h.Listen(t.Context())
+
+ select {
+ case got := <-processed:
+ assert.Equal(t, 2222, got.WgListenPort, "remote-answer dispatch: the latest queued signal should be processed")
+ case <-time.After(2 * time.Second):
+ assert.Fail(t, "remote-answer dispatch: queued signal was dropped")
+ }
+}
diff --git a/client/internal/peer/listener.go b/client/internal/peer/listener.go
index c601fe534..2bb7fcf32 100644
--- a/client/internal/peer/listener.go
+++ b/client/internal/peer/listener.go
@@ -1,11 +1,40 @@
package peer
+// ClientState identifies the client connection state delivered via
+// Listener.OnStateChanged.
+type ClientState int
+
+// Client states. The numeric values cross the gomobile boundary (the mobile
+// bindings re-export them as integer constants), so they are a wire format:
+// append new states at the end, never reorder or insert.
+const (
+ ClientStateDisconnected ClientState = iota
+ ClientStateConnected
+ ClientStateConnecting
+ ClientStateDisconnecting
+ // ClientStateNoNetwork is an overlay state: it is never stored as the
+ // last notification, only derived from ClientStateConnecting while the
+ // OS reports no usable network (see notifier.effectiveState).
+ ClientStateNoNetwork
+)
+
// Listener is a callback type about the NetBird network connection state
type Listener interface {
+ // OnStateChanged reports every client state transition. New states are
+ // delivered only through this callback; the per-state callbacks below
+ // are kept for compatibility and will be removed once all consumers
+ // have migrated.
+ OnStateChanged(state ClientState)
+
+ // Deprecated: consume OnStateChanged instead.
OnConnected()
+ // Deprecated: consume OnStateChanged instead.
OnDisconnected()
+ // Deprecated: consume OnStateChanged instead.
OnConnecting()
+ // Deprecated: consume OnStateChanged instead.
OnDisconnecting()
+
OnAddressChanged(string, string)
OnPeersListChanged(int)
}
diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go
index 8d1954fe5..1ee1d32ea 100644
--- a/client/internal/peer/notifier.go
+++ b/client/internal/peer/notifier.go
@@ -4,31 +4,64 @@ import (
"sync"
)
-const (
- stateDisconnected = iota
- stateConnected
- stateConnecting
- stateDisconnecting
-)
-
type notifier struct {
+ // publishLock orders state publication: it is held across computing the
+ // effective state and handing it to the listener, so a transition cannot
+ // overtake a newer one and leave the listener on a stale state.
+ publishLock sync.Mutex
serverStateLock sync.Mutex
listenersLock sync.Mutex
listener Listener
currentClientState bool
- lastNotification int
+ lastNotification ClientState
lastNumberOfPeers int
lastFqdnAddress string
lastIPAddress string
+ networkAvailable bool
}
func newNotifier() *notifier {
- return ¬ifier{}
+ return ¬ifier{
+ networkAvailable: true,
+ }
+}
+
+// effectiveState maps the computed state to what listeners should see:
+// while the OS reports no usable network, "Connecting" would be a lie —
+// connection attempts are suspended — so it is reported as NoNetwork.
+// Caller must hold serverStateLock.
+func (n *notifier) effectiveState(state ClientState) ClientState {
+ if !n.networkAvailable && state == ClientStateConnecting {
+ return ClientStateNoNetwork
+ }
+ return state
+}
+
+// setNetworkAvailable records the OS network availability and re-notifies
+// the listener when the flag flips the effective state (Connecting <->
+// NoNetwork).
+func (n *notifier) setNetworkAvailable(available bool) {
+ n.publishLock.Lock()
+ defer n.publishLock.Unlock()
+
+ n.serverStateLock.Lock()
+ if n.networkAvailable == available {
+ n.serverStateLock.Unlock()
+ return
+ }
+ previous := n.effectiveState(n.lastNotification)
+ n.networkAvailable = available
+ current := n.effectiveState(n.lastNotification)
+ n.serverStateLock.Unlock()
+
+ if previous != current {
+ n.notify(current)
+ }
}
func (n *notifier) setListener(listener Listener) {
n.serverStateLock.Lock()
- lastNotification := n.lastNotification
+ lastNotification := n.effectiveState(n.lastNotification)
numOfPeers := n.lastNumberOfPeers
fqdnAddress := n.lastFqdnAddress
address := n.lastIPAddress
@@ -52,6 +85,9 @@ func (n *notifier) removeListener() {
}
func (n *notifier) updateServerStates(mgmState bool, signalState bool) {
+ n.publishLock.Lock()
+ defer n.publishLock.Unlock()
+
n.serverStateLock.Lock()
calculatedState := n.calculateState(mgmState, signalState)
@@ -61,43 +97,54 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) {
}
n.lastNotification = calculatedState
+ effective := n.effectiveState(calculatedState)
n.serverStateLock.Unlock()
- n.notify(calculatedState)
+ n.notify(effective)
}
func (n *notifier) clientStart() {
+ n.publishLock.Lock()
+ defer n.publishLock.Unlock()
+
n.serverStateLock.Lock()
n.currentClientState = true
- n.lastNotification = stateConnecting
+ n.lastNotification = ClientStateConnecting
+ effective := n.effectiveState(ClientStateConnecting)
n.serverStateLock.Unlock()
- n.notify(stateConnecting)
+ n.notify(effective)
}
func (n *notifier) clientStop() {
+ n.publishLock.Lock()
+ defer n.publishLock.Unlock()
+
n.serverStateLock.Lock()
n.currentClientState = false
- n.lastNotification = stateDisconnected
+ n.lastNotification = ClientStateDisconnected
n.serverStateLock.Unlock()
- n.notify(stateDisconnected)
+ n.notify(ClientStateDisconnected)
}
func (n *notifier) clientTearDown() {
+ n.publishLock.Lock()
+ defer n.publishLock.Unlock()
+
n.serverStateLock.Lock()
n.currentClientState = false
- n.lastNotification = stateDisconnecting
+ n.lastNotification = ClientStateDisconnecting
n.serverStateLock.Unlock()
- n.notify(stateDisconnecting)
+ n.notify(ClientStateDisconnecting)
}
-func (n *notifier) isServerStateChanged(newState int) bool {
+func (n *notifier) isServerStateChanged(newState ClientState) bool {
return n.lastNotification != newState
}
-func (n *notifier) notify(state int) {
+func (n *notifier) notify(state ClientState) {
n.listenersLock.Lock()
listener := n.listener
n.listenersLock.Unlock()
@@ -109,20 +156,20 @@ func (n *notifier) notify(state int) {
notifyListener(listener, state)
}
-func (n *notifier) calculateState(managementConn, signalConn bool) int {
+func (n *notifier) calculateState(managementConn, signalConn bool) ClientState {
if managementConn && signalConn {
- return stateConnected
+ return ClientStateConnected
}
if !managementConn && !signalConn && !n.currentClientState {
- return stateDisconnected
+ return ClientStateDisconnected
}
- if n.lastNotification == stateDisconnecting {
- return stateDisconnecting
+ if n.lastNotification == ClientStateDisconnecting {
+ return ClientStateDisconnecting
}
- return stateConnecting
+ return ClientStateConnecting
}
func (n *notifier) peerListChanged(numOfPeers int) {
@@ -159,15 +206,19 @@ func (n *notifier) localAddressChanged(fqdn, address string) {
listener.OnAddressChanged(fqdn, address)
}
-func notifyListener(l Listener, state int) {
+func notifyListener(l Listener, state ClientState) {
+ // legacy per-state callbacks; NoNetwork is delivered only via
+ // OnStateChanged below
switch state {
- case stateDisconnected:
+ case ClientStateDisconnected:
l.OnDisconnected()
- case stateConnected:
+ case ClientStateConnected:
l.OnConnected()
- case stateConnecting:
+ case ClientStateConnecting:
l.OnConnecting()
- case stateDisconnecting:
+ case ClientStateDisconnecting:
l.OnDisconnecting()
}
+
+ l.OnStateChanged(state)
}
diff --git a/client/internal/peer/notifier_concurrent_test.go b/client/internal/peer/notifier_concurrent_test.go
new file mode 100644
index 000000000..fcaaaad3b
--- /dev/null
+++ b/client/internal/peer/notifier_concurrent_test.go
@@ -0,0 +1,108 @@
+package peer
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+type recordingListener struct {
+ mu sync.Mutex
+ states []ClientState
+ onState func(ClientState)
+}
+
+func (l *recordingListener) OnStateChanged(state ClientState) {
+ l.mu.Lock()
+ l.states = append(l.states, state)
+ hook := l.onState
+ l.mu.Unlock()
+
+ if hook != nil {
+ hook(state)
+ }
+}
+
+func (l *recordingListener) last() (ClientState, bool) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if len(l.states) == 0 {
+ return 0, false
+ }
+ return l.states[len(l.states)-1], true
+}
+
+func (l *recordingListener) snapshot() []ClientState {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return append([]ClientState(nil), l.states...)
+}
+
+func (l *recordingListener) OnConnected() {}
+func (l *recordingListener) OnDisconnected() {}
+func (l *recordingListener) OnConnecting() {}
+func (l *recordingListener) OnDisconnecting() {}
+func (l *recordingListener) OnAddressChanged(string, string) {}
+func (l *recordingListener) OnPeersListChanged(int) {}
+
+// TestNotifier_ConcurrentAvailabilityFlipOrdersPublication holds the first
+// transition inside the listener callback and flips availability again from
+// another goroutine while it is parked. The second flip must not publish
+// ahead of the one in flight, otherwise the listener ends up on a state the
+// notifier already superseded.
+func TestNotifier_ConcurrentAvailabilityFlipOrdersPublication(t *testing.T) {
+ n := newNotifier()
+ n.currentClientState = true
+ n.lastNotification = ClientStateConnecting
+
+ entered := make(chan struct{})
+ release := make(chan struct{})
+
+ l := &recordingListener{}
+ l.onState = func(state ClientState) {
+ if state != ClientStateNoNetwork {
+ return
+ }
+ l.mu.Lock()
+ l.onState = nil
+ l.mu.Unlock()
+ close(entered)
+ <-release
+ }
+ n.listener = l
+
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ n.setNetworkAvailable(false)
+ }()
+
+ <-entered
+
+ flipped := make(chan struct{})
+ go func() {
+ defer close(flipped)
+ n.setNetworkAvailable(true)
+ }()
+
+ select {
+ case <-flipped:
+ t.Fatal("the online transition published while the offline one was " +
+ "still in flight; publication is not serialized")
+ case <-time.After(200 * time.Millisecond):
+ }
+
+ close(release)
+ <-flipped
+ wg.Wait()
+
+ got, ok := l.last()
+ if !ok {
+ t.Fatal("listener never observed a state")
+ }
+ if got != ClientStateConnecting {
+ t.Fatalf("listener holds %v after the network came back, want Connecting; sequence: %v",
+ got, l.snapshot())
+ }
+}
diff --git a/client/internal/peer/notifier_test.go b/client/internal/peer/notifier_test.go
index 0b7722b0c..a73016b05 100644
--- a/client/internal/peer/notifier_test.go
+++ b/client/internal/peer/notifier_test.go
@@ -6,29 +6,32 @@ import (
)
type mocListener struct {
- lastState int
+ lastState ClientState
wg sync.WaitGroup
peersWg sync.WaitGroup
peers int
}
func (l *mocListener) OnConnected() {
- l.lastState = stateConnected
+ l.lastState = ClientStateConnected
l.wg.Done()
}
func (l *mocListener) OnDisconnected() {
- l.lastState = stateDisconnected
+ l.lastState = ClientStateDisconnected
l.wg.Done()
}
func (l *mocListener) OnConnecting() {
- l.lastState = stateConnecting
+ l.lastState = ClientStateConnecting
l.wg.Done()
}
func (l *mocListener) OnDisconnecting() {
- l.lastState = stateDisconnecting
+ l.lastState = ClientStateDisconnecting
l.wg.Done()
}
+func (l *mocListener) OnStateChanged(state ClientState) {
+
+}
func (l *mocListener) OnAddressChanged(host, addr string) {
}
@@ -57,15 +60,15 @@ func Test_notifier_serverState(t *testing.T) {
type scenario struct {
name string
- expected int
+ expected ClientState
mgmState bool
signalState bool
}
scenarios := []scenario{
- {"connected", stateConnected, true, true},
- {"mgm down", stateConnecting, false, true},
- {"signal down", stateConnecting, true, false},
- {"disconnected", stateDisconnected, false, false},
+ {"connected", ClientStateConnected, true, true},
+ {"mgm down", ClientStateConnecting, false, true},
+ {"signal down", ClientStateConnecting, true, false},
+ {"disconnected", ClientStateDisconnected, false, false},
}
for _, tt := range scenarios {
@@ -85,7 +88,7 @@ func Test_notifier_SetListener(t *testing.T) {
listener.setPeersWaiter()
n := newNotifier()
- n.lastNotification = stateConnecting
+ n.lastNotification = ClientStateConnecting
n.setListener(listener)
listener.wait()
listener.waitPeers()
@@ -99,7 +102,7 @@ func Test_notifier_RemoveListener(t *testing.T) {
listener.setWaiter()
listener.setPeersWaiter()
n := newNotifier()
- n.lastNotification = stateConnecting
+ n.lastNotification = ClientStateConnecting
n.setListener(listener)
// setListener replays cached state on a goroutine; wait for both the state
// and peers callbacks to finish so we don't race on listener.peers.
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index 3e5c56dd2..24e3e7fac 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -7,6 +7,7 @@ import (
"net/netip"
"slices"
"sync"
+ "sync/atomic"
"time"
"github.com/google/uuid"
@@ -191,22 +192,30 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
// every private-service request) don't contend against each other.
// Pure read methods take RLock; anything that mutates state takes Lock.
type Status struct {
- mux sync.RWMutex
- peers map[string]State
- ipToKey map[string]string
- changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
- signalState bool
- signalError error
- managementState bool
- managementError error
- relayStates []relay.ProbeResult
- localPeer LocalPeerState
- offlinePeers []State
- mgmAddress string
- signalAddress string
- notifier *notifier
- rosenpassEnabled bool
- rosenpassPermissive bool
+ mux sync.RWMutex
+ muxRelays sync.RWMutex
+ peers map[string]State
+ ipToKey map[string]string
+ changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
+ signalState bool
+ signalError error
+ managementState bool
+ managementError error
+ relayStates []relay.ProbeResult
+ localPeer LocalPeerState
+ offlinePeers []State
+ mgmAddress string
+ signalAddress string
+ notifier *notifier
+ rosenpassEnabled bool
+ rosenpassPermissive bool
+ // sessionExpiresAt is the absolute UTC instant at which the peer's SSO
+ // session expires. Zero when the peer is not SSO-tracked or login
+ // expiration is disabled. Populated from management LoginResponse /
+ // SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC
+ // so the UI can show remaining time without itself talking to mgm.
+ sessionExpiresAt time.Time
+
nsGroupStates []NSGroupState
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
lazyConnectionEnabled bool
@@ -222,6 +231,21 @@ type Status struct {
eventStreams map[string]chan *proto.SystemEvent
eventQueue *EventQueue
+ // stateChangeStreams fan-out connection-state changes (connected /
+ // disconnected / connecting / address change / peers list change) to
+ // every active SubscribeStatus gRPC stream. Each subscriber gets a
+ // buffered chan; the notifier non-blockingly pings them so a slow
+ // consumer can never stall the daemon.
+ stateChangeMux sync.Mutex
+ stateChangeStreams map[string]chan struct{}
+
+ // networksRevision bumps whenever the routed-networks set or their
+ // selected state changes (driven by the route manager). Surfaced in the
+ // status snapshot so the UI can fingerprint on it and re-fetch
+ // ListNetworks only on a real change. Atomic so the snapshot builder can
+ // read it without taking mux.
+ networksRevision atomic.Uint64
+
ingressGwMgr *ingressgw.Manager
routeIDLookup routeIDLookup
@@ -236,6 +260,7 @@ func NewRecorder(mgmAddress string) *Status {
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
eventStreams: make(map[string]chan *proto.SystemEvent),
eventQueue: NewEventQueue(eventQueueSize),
+ stateChangeStreams: make(map[string]chan struct{}),
offlinePeers: make([]State, 0),
notifier: newNotifier(),
mgmAddress: mgmAddress,
@@ -244,8 +269,8 @@ func NewRecorder(mgmAddress string) *Status {
}
func (d *Status) SetRelayMgr(manager *relayClient.Manager) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.muxRelays.Lock()
+ defer d.muxRelays.Unlock()
d.relayMgr = manager
}
@@ -400,6 +425,7 @@ func (d *Status) UpdatePeerState(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -425,6 +451,7 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R
// todo: consider to make sense of this notification or not
d.notifier.peerListChanged(numPeers)
+ d.notifyStateChange()
return nil
}
@@ -450,6 +477,7 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error {
// todo: consider to make sense of this notification or not
d.notifier.peerListChanged(numPeers)
+ d.notifyStateChange()
return nil
}
@@ -499,6 +527,7 @@ func (d *Status) UpdatePeerICEState(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -535,6 +564,7 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -570,6 +600,7 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -608,6 +639,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -701,6 +733,7 @@ func (d *Status) FinishPeerListModifications() {
for _, rd := range dispatches {
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
}
+ d.notifyStateChange()
}
func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
@@ -759,6 +792,36 @@ func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) {
d.mux.Unlock()
d.notifier.localAddressChanged(fqdn, ip)
+ d.notifyStateChange()
+}
+
+// SetSessionExpiresAt records the absolute UTC instant at which the peer's
+// SSO session is set to expire. Pass the zero value to clear (e.g. when the
+// management server stops publishing a deadline because login expiration was
+// disabled or the peer is not SSO-tracked). Same-value updates are no-ops;
+// real changes fan out via notifyStateChange so SubscribeStatus consumers
+// pick up the new deadline on their next read.
+func (d *Status) SetSessionExpiresAt(deadline time.Time) {
+ d.mux.Lock()
+ if d.sessionExpiresAt.Equal(deadline) {
+ d.mux.Unlock()
+ return
+ }
+ d.sessionExpiresAt = deadline
+ d.mux.Unlock()
+ d.notifyStateChange()
+}
+
+// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
+// or the zero value when no deadline is tracked. A deadline in the past is
+// returned as-is: it means the session has expired, and consumers (tray row,
+// CLI status) render it as "expired" rather than hiding it — masking it as
+// "none" would blank the UI at the exact moment it should say the session
+// ended.
+func (d *Status) GetSessionExpiresAt() time.Time {
+ d.mux.Lock()
+ defer d.mux.Unlock()
+ return d.sessionExpiresAt
}
// AddLocalPeerStateRoute adds a route to the local peer state
@@ -827,11 +890,19 @@ func (d *Status) CleanLocalPeerState() {
d.mux.Unlock()
d.notifier.localAddressChanged(fqdn, ip)
+ d.notifyStateChange()
}
// MarkManagementDisconnected sets ManagementState to disconnected
func (d *Status) MarkManagementDisconnected(err error) {
d.mux.Lock()
+ // Health checks re-mark the same state on every probe; skip the fan-out
+ // when nothing actually changed so we don't flood SubscribeStatus
+ // consumers with identical snapshots.
+ if !d.managementState && errors.Is(d.managementError, err) {
+ d.mux.Unlock()
+ return
+ }
d.managementState = false
d.managementError = err
mgm := d.managementState
@@ -839,11 +910,16 @@ func (d *Status) MarkManagementDisconnected(err error) {
d.mux.Unlock()
d.notifier.updateServerStates(mgm, sig)
+ d.notifyStateChange()
}
// MarkManagementConnected sets ManagementState to connected
func (d *Status) MarkManagementConnected() {
d.mux.Lock()
+ if d.managementState && d.managementError == nil {
+ d.mux.Unlock()
+ return
+ }
d.managementState = true
d.managementError = nil
mgm := d.managementState
@@ -851,6 +927,7 @@ func (d *Status) MarkManagementConnected() {
d.mux.Unlock()
d.notifier.updateServerStates(mgm, sig)
+ d.notifyStateChange()
}
// UpdateSignalAddress update the address of the signal server
@@ -884,6 +961,10 @@ func (d *Status) UpdateLazyConnection(enabled bool) {
// MarkSignalDisconnected sets SignalState to disconnected
func (d *Status) MarkSignalDisconnected(err error) {
d.mux.Lock()
+ if !d.signalState && errors.Is(d.signalError, err) {
+ d.mux.Unlock()
+ return
+ }
d.signalState = false
d.signalError = err
mgm := d.managementState
@@ -891,11 +972,16 @@ func (d *Status) MarkSignalDisconnected(err error) {
d.mux.Unlock()
d.notifier.updateServerStates(mgm, sig)
+ d.notifyStateChange()
}
// MarkSignalConnected sets SignalState to connected
func (d *Status) MarkSignalConnected() {
d.mux.Lock()
+ if d.signalState && d.signalError == nil {
+ d.mux.Unlock()
+ return
+ }
d.signalState = true
d.signalError = nil
mgm := d.managementState
@@ -903,11 +989,12 @@ func (d *Status) MarkSignalConnected() {
d.mux.Unlock()
d.notifier.updateServerStates(mgm, sig)
+ d.notifyStateChange()
}
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.muxRelays.Lock()
+ defer d.muxRelays.Unlock()
d.relayStates = relayResults
}
@@ -1018,24 +1105,26 @@ func (d *Status) GetSignalState() SignalState {
// GetRelayStates returns the stun/turn/permanent relay states
func (d *Status) GetRelayStates() []relay.ProbeResult {
- d.mux.RLock()
- defer d.mux.RUnlock()
+ d.muxRelays.RLock()
if d.relayMgr == nil {
- return d.relayStates
+ defer d.muxRelays.RUnlock()
+ return slices.Clone(d.relayStates)
}
+ relayMgr := d.relayMgr
// extend the list of stun, turn servers with the relay server connections
relayStates := slices.Clone(d.relayStates)
+ d.muxRelays.RUnlock()
- states := d.relayMgr.RelayStates()
+ states := relayMgr.RelayStates()
if len(states) == 0 {
// no relay connection tracked yet; surface configured servers as
// unavailable with the real reconnect error when known
err := relayClient.ErrRelayClientNotConnected
- if connErr := d.relayMgr.RelayConnectError(); connErr != nil {
+ if connErr := relayMgr.RelayConnectError(); connErr != nil {
err = connErr
}
- for _, r := range d.relayMgr.ServerURLs() {
+ for _, r := range relayMgr.ServerURLs() {
relayStates = append(relayStates, relay.ProbeResult{
URI: r,
Err: err,
@@ -1107,16 +1196,25 @@ func (d *Status) GetFullStatus() FullStatus {
// ClientStart will notify all listeners about the new service state
func (d *Status) ClientStart() {
d.notifier.clientStart()
+ d.notifyStateChange()
}
// ClientStop will notify all listeners about the new service state
func (d *Status) ClientStop() {
d.notifier.clientStop()
+ d.notifyStateChange()
}
// ClientTeardown will notify all listeners about the service is under teardown
func (d *Status) ClientTeardown() {
d.notifier.clientTearDown()
+ d.notifyStateChange()
+}
+
+// SetNetworkAvailable records the OS-reported network availability; while
+// unavailable, listeners see NoNetwork instead of Connecting.
+func (d *Status) SetNetworkAvailable(available bool) {
+ d.notifier.setNetworkAvailable(available)
}
// SetConnectionListener set a listener to the notifier
@@ -1258,6 +1356,79 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent {
return d.eventQueue.GetAll()
}
+// SubscribeToStateChanges hands back a channel that receives a tick on
+// every connection-state change (connected / disconnected / connecting /
+// address change / peers-list change). The channel is buffered to one
+// pending tick so a coalesced burst still wakes the consumer exactly
+// once. Pass the returned id to UnsubscribeFromStateChanges to detach.
+func (d *Status) SubscribeToStateChanges() (string, <-chan struct{}) {
+ d.stateChangeMux.Lock()
+ defer d.stateChangeMux.Unlock()
+
+ id := uuid.New().String()
+ ch := make(chan struct{}, 1)
+ d.stateChangeStreams[id] = ch
+ return id, ch
+}
+
+// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel
+// and closes it so any consumer goroutine selecting on the channel
+// unblocks cleanly.
+func (d *Status) UnsubscribeFromStateChanges(id string) {
+ d.stateChangeMux.Lock()
+ defer d.stateChangeMux.Unlock()
+
+ if ch, ok := d.stateChangeStreams[id]; ok {
+ close(ch)
+ delete(d.stateChangeStreams, id)
+ }
+}
+
+// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops
+// the tick if a subscriber's buffer is full — by definition the consumer
+// is already going to fetch the latest snapshot, so multiple pending ticks
+// would be redundant.
+func (d *Status) notifyStateChange() {
+ d.stateChangeMux.Lock()
+ defer d.stateChangeMux.Unlock()
+
+ for _, ch := range d.stateChangeStreams {
+ select {
+ case ch <- struct{}{}:
+ default:
+ }
+ }
+}
+
+// NotifyStateChange is the public wake-the-subscribers entry point used by
+// callers that mutate state outside the peer recorder — most importantly
+// the connect-state machine, which writes StatusNeedsLogin into the
+// shared contextState (client/internal/state.go) without touching any
+// recorder field. Without this push the SubscribeStatus stream stays on
+// the previous snapshot until an unrelated peer/management/signal
+// change happens to fire notifyStateChange, leaving the UI's status
+// out of sync with the daemon.
+func (d *Status) NotifyStateChange() {
+ d.notifyStateChange()
+}
+
+// BumpNetworksRevision increments the routed-networks revision and wakes every
+// SubscribeStatus subscriber. The route manager calls it when a network map
+// changes the available routes or when a selection is applied — the peer
+// status itself only records actively-routed (chosen) networks, so without
+// this bump a candidate route appearing/disappearing would never reach the UI.
+func (d *Status) BumpNetworksRevision() {
+ d.networksRevision.Add(1)
+ d.notifyStateChange()
+}
+
+// GetNetworksRevision returns the current routed-networks revision, surfaced in
+// the status snapshot so the UI can detect route/selection changes (see
+// BumpNetworksRevision).
+func (d *Status) GetNetworksRevision() uint64 {
+ return d.networksRevision.Load()
+}
+
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
d.mux.Lock()
defer d.mux.Unlock()
diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go
index 17ed47cd3..29404d413 100644
--- a/client/internal/peer/status_test.go
+++ b/client/internal/peer/status_test.go
@@ -314,3 +314,39 @@ func TestGetFullStatus(t *testing.T) {
assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal")
assert.ElementsMatch(t, []State{peerState1, peerState2}, fullStatus.Peers, "peers states should match")
}
+
+// notified reports whether a state-change tick is pending on ch, draining it.
+func notified(ch <-chan struct{}) bool {
+ select {
+ case <-ch:
+ return true
+ default:
+ return false
+ }
+}
+
+func TestMarkServerStateDoesNotNotifyWhenUnchanged(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ _, ch := status.SubscribeToStateChanges()
+
+ // First transition is a real change and must notify.
+ status.MarkManagementConnected()
+ require.True(t, notified(ch), "first connect should notify")
+
+ // Re-marking the same state must not notify again.
+ status.MarkManagementConnected()
+ assert.False(t, notified(ch), "redundant connect should not notify")
+
+ // Same for signal.
+ status.MarkSignalConnected()
+ require.True(t, notified(ch), "first signal connect should notify")
+ status.MarkSignalConnected()
+ assert.False(t, notified(ch), "redundant signal connect should not notify")
+
+ // A genuine change (disconnect with an error) notifies again.
+ err := errors.New("boom")
+ status.MarkManagementDisconnected(err)
+ require.True(t, notified(ch), "disconnect should notify")
+ status.MarkManagementDisconnected(err)
+ assert.False(t, notified(ch), "redundant disconnect should not notify")
+}
diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go
index 805a6f24a..39e3d3264 100644
--- a/client/internal/peer/wg_watcher.go
+++ b/client/internal/peer/wg_watcher.go
@@ -3,7 +3,6 @@ package peer
import (
"context"
"fmt"
- "sync"
"time"
log "github.com/sirupsen/logrus"
@@ -24,14 +23,16 @@ type WGInterfaceStater interface {
GetStats() (map[string]configurer.WGStats, error)
}
+// WGWatcher is single-shot: one instance per connection attempt, run once, then discarded.
+// Lifecycle is owned by Conn under conn.mu, so it keeps no "enabled" state to go stale.
type WGWatcher struct {
log *log.Entry
wgIfaceStater WGInterfaceStater
peerKey string
stateDump *stateDump
- enabled bool
- muEnabled sync.RWMutex
+ // initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
+ initialHandshake time.Time
resetCh chan struct{}
}
@@ -46,36 +47,23 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin
}
}
-// EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing.
-// The watcher runs until ctx is cancelled. Caller is responsible for context lifecycle management.
-func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
- w.muEnabled.Lock()
- if w.enabled {
- w.muEnabled.Unlock()
- return
- }
-
+// PrepareInitialHandshake reads the peer's current WireGuard handshake time. It must be
+// called before the peer is (re)configured on the WireGuard interface, so the captured
+// baseline reflects the state prior to this connection attempt instead of racing with
+// that configuration.
+func (w *WGWatcher) PrepareInitialHandshake() {
w.log.Debugf("enable WireGuard watcher")
- w.enabled = true
- w.muEnabled.Unlock()
-
- initialHandshake, err := w.wgState()
- if err != nil {
- w.log.Warnf("failed to read initial wg stats: %v", err)
- }
-
- w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, initialHandshake)
-
- w.muEnabled.Lock()
- w.enabled = false
- w.muEnabled.Unlock()
+ handshake, _ := w.wgState()
+ w.initialHandshake = handshake
}
-// IsEnabled returns true if the WireGuard watcher is currently enabled
-func (w *WGWatcher) IsEnabled() bool {
- w.muEnabled.RLock()
- defer w.muEnabled.RUnlock()
- return w.enabled
+// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by
+// PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible
+// for context lifecycle management. onHandshakeSuccessFn is called only for the first
+// handshake observed by this run, onCheckSuccessFn for every check that observed a fresh
+// handshake, including the first.
+func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) {
+ w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake)
}
// Reset signals the watcher that the WireGuard peer has been reset and a new
@@ -88,7 +76,7 @@ func (w *WGWatcher) Reset() {
}
// wgStateCheck help to check the state of the WireGuard handshake and relay connection
-func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) {
+func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func(), enabledTime time.Time, initialHandshake time.Time) {
w.log.Infof("WireGuard watcher started")
timer := time.NewTimer(wgHandshakeOvertime)
@@ -101,17 +89,25 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
case <-timer.C:
handshake, ok := w.handshakeCheck(lastHandshake)
if !ok {
+ // early ctx cancel check return
+ if ctx.Err() != nil {
+ return
+ }
onDisconnectedFn()
return
}
if lastHandshake.IsZero() {
elapsed := calcElapsed(enabledTime, *handshake)
w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake)
- if onHandshakeSuccessFn != nil {
+ if onHandshakeSuccessFn != nil && ctx.Err() == nil {
onHandshakeSuccessFn(*handshake)
}
}
+ if onCheckSuccessFn != nil && ctx.Err() == nil {
+ onCheckSuccessFn()
+ }
+
lastHandshake = *handshake
resetTime := time.Until(handshake.Add(checkPeriod))
@@ -142,9 +138,9 @@ func (w *WGWatcher) handshakeCheck(lastHandshake time.Time) (*time.Time, bool) {
w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake)
- // the current know handshake did not change
+ // the current known handshake did not change
if handshake.Equal(lastHandshake) {
- w.log.Warnf("WireGuard handshake timed out: %v", handshake)
+ w.log.Warnf("WireGuard handshake not updated: %v", handshake)
return nil, false
}
diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go
index 3ce91cd46..6a5a9acfe 100644
--- a/client/internal/peer/wg_watcher_test.go
+++ b/client/internal/peer/wg_watcher_test.go
@@ -23,6 +23,72 @@ func (m *MocWgIface) disconnect() {
m.stop = true
}
+type mockHandshakeStats struct {
+ mu sync.Mutex
+ handshake time.Time
+}
+
+func (m *mockHandshakeStats) GetStats() (map[string]configurer.WGStats, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return map[string]configurer.WGStats{"": {LastHandshake: m.handshake}}, nil
+}
+
+func (m *mockHandshakeStats) advance() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.handshake = time.Now()
+}
+
+// TestWGWatcher_CheckSuccessCallback: onCheckSuccessFn must fire for a fresh
+// handshake even when the watcher started with an existing handshake baseline,
+// the case where onHandshakeSuccessFn stays silent.
+func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
+ // checkPeriod bounds how stale a handshake may be before the watcher treats it
+ // as a suspended-machine timeout. The first check fires after wgHandshakeOvertime,
+ // so keep checkPeriod well above any scheduling jitter to avoid a false timeout
+ // converting the expected success into a disconnect on a loaded runner.
+ checkPeriod = 1 * time.Minute
+ wgHandshakeOvertime = 1 * time.Second
+
+ mlog := log.WithField("peer", "tet")
+ // Use an old baseline so advance() yields a strictly newer handshake even on
+ // platforms with coarse clock resolution (Windows), where two time.Now() calls
+ // microseconds apart can return the same instant and read as a timed-out handshake.
+ stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)}
+ watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{}))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ watcher.PrepareInitialHandshake()
+
+ firstHandshake := make(chan struct{}, 1)
+ checkSuccess := make(chan struct{}, 1)
+ go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {
+ firstHandshake <- struct{}{}
+ }, func() {
+ select {
+ case checkSuccess <- struct{}{}:
+ default:
+ }
+ })
+
+ stats.advance()
+
+ select {
+ case <-checkSuccess:
+ case <-time.After(10 * time.Second):
+ t.Errorf("timeout waiting for check success callback")
+ }
+
+ select {
+ case <-firstHandshake:
+ t.Errorf("first-handshake callback must not fire for a non-zero baseline")
+ default:
+ }
+}
+
func TestWGWatcher_EnableWgWatcher(t *testing.T) {
checkPeriod = 5 * time.Second
wgHandshakeOvertime = 1 * time.Second
@@ -34,13 +100,15 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
+ watcher.PrepareInitialHandshake()
+
onDisconnected := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
mlog.Infof("onDisconnectedFn")
onDisconnected <- struct{}{}
}, func(when time.Time) {
mlog.Infof("onHandshakeSuccess: %v", when)
- })
+ }, nil)
// wait for initial reading
time.Sleep(2 * time.Second)
@@ -62,11 +130,13 @@ func TestWGWatcher_ReEnable(t *testing.T) {
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
ctx, cancel := context.WithCancel(context.Background())
+ watcher.PrepareInitialHandshake()
+
wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
- watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {})
+ watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}, nil)
}()
cancel()
@@ -76,10 +146,12 @@ func TestWGWatcher_ReEnable(t *testing.T) {
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
+ watcher.PrepareInitialHandshake()
+
onDisconnected := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
onDisconnected <- struct{}{}
- }, func(when time.Time) {})
+ }, func(when time.Time) {}, nil)
time.Sleep(2 * time.Second)
mocWgIface.disconnect()
diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go
index b1aa3e0f9..83cac13f5 100644
--- a/client/internal/peer/worker_ice.go
+++ b/client/internal/peer/worker_ice.go
@@ -255,8 +255,8 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent
return
}
- w.log.Debugf("turn agent dial")
- remoteConn, err := w.turnAgentDial(ctx, agent, remoteOfferAnswer)
+ w.log.Debugf("agent dial")
+ remoteConn, err := w.agentDial(ctx, agent, remoteOfferAnswer)
if err != nil {
w.log.Debugf("failed to dial the remote peer: %s", err)
w.closeAgent(agent, w.agentDialerCancel)
@@ -389,6 +389,17 @@ func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
return
}
+ // A forwarded candidate only makes sense for an IPv4 mapping, which
+ // translates a port on the gateway's address. An IPv6 pinhole translates
+ // nothing: it unblocks the address ICE already gathers as a host candidate,
+ // so there is no second address to advertise. Injecting one here would also
+ // paste an IPv6 address onto whichever server-reflexive candidate arrived
+ // first, which is usually IPv4.
+ if mapping.ExternalIP != nil && mapping.ExternalIP.To4() == nil {
+ w.log.Debugf("skipping port-forwarded candidate: %s mapping is IPv6-only", mapping.NATType)
+ return
+ }
+
w.muxAgent.Lock()
if w.portForwardAttempted {
w.muxAgent.Unlock()
@@ -517,8 +528,8 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia
w.logSuccessfulPaths(agent)
return
case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed:
- // ice.ConnectionStateClosed happens when we recreate the agent. For the P2P to TURN switch important to
- // notify the conn.onICEStateDisconnected changes to update the current used priority
+ // ice.ConnectionStateClosed happens when we recreate the agent. The P2P to relay switch requires
+ // notifying conn.onICEStateDisconnected so it can update the currently used priority.
sessionChanged := w.closeAgent(agent, dialerCancel)
@@ -532,7 +543,7 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia
}
}
-func (w *WorkerICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
+func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
if isController(w.config) {
return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd)
} else {
diff --git a/client/internal/peerstore/store.go b/client/internal/peerstore/store.go
index 099fe4528..112caa101 100644
--- a/client/internal/peerstore/store.go
+++ b/client/internal/peerstore/store.go
@@ -88,11 +88,24 @@ func (s *Store) PeerConnOpen(ctx context.Context, pubKey string) {
if !ok {
return
}
- // this can be blocked because of the connect open limiter semaphore
if err := p.Open(ctx); err != nil {
p.Log.Errorf("failed to open peer connection: %v", err)
}
+}
+// PeerConnOpenWithFirstPacket opens the peer connection and stashes a first packet to be
+// reinjected once the real transport is established.
+func (s *Store) PeerConnOpenWithFirstPacket(ctx context.Context, pubKey string, firstPacket []byte) {
+ s.peerConnsMu.RLock()
+ defer s.peerConnsMu.RUnlock()
+
+ p, ok := s.peerConns[pubKey]
+ if !ok {
+ return
+ }
+ if err := p.OpenWithFirstPacket(ctx, firstPacket); err != nil {
+ p.Log.Errorf("failed to open peer connection: %v", err)
+ }
}
func (s *Store) PeerConnIdle(pubKey string) {
diff --git a/client/internal/portforward/manager.go b/client/internal/portforward/manager.go
index b0680160c..7d5a4cb9e 100644
--- a/client/internal/portforward/manager.go
+++ b/client/internal/portforward/manager.go
@@ -10,10 +10,8 @@ import (
"sync"
"time"
- "github.com/libp2p/go-nat"
+ "github.com/netbirdio/go-nat"
log "github.com/sirupsen/logrus"
-
- "github.com/netbirdio/netbird/client/internal/portforward/pcp"
)
const (
@@ -168,6 +166,11 @@ func (m *Manager) setup(ctx context.Context) (nat.NAT, *Mapping, error) {
if err != nil {
return nil, nil, fmt.Errorf("create port mapping: %w", err)
}
+
+ // Only meaningful once a mapping has been attempted: that is what opens the
+ // pinhole and records its outcome.
+ logIPv6Pinhole(gateway)
+
return gateway, mapping, nil
}
@@ -265,7 +268,9 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b
return false
}
- pcpNAT, ok := gateway.(*pcp.NAT)
+ // Assert on the interface, not on a concrete type: a dual-stack gateway is
+ // a wrapper around the IPv4 NAT, so a type assertion misses it.
+ checker, ok := gateway.(nat.HealthChecker)
if !ok {
return false
}
@@ -273,7 +278,7 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
- epoch, serverRestarted, err := pcpNAT.CheckServerHealth(ctx)
+ epoch, serverRestarted, err := checker.CheckServerHealth(ctx)
if err != nil {
log.Debugf("PCP health check failed: %v", err)
return false
@@ -340,3 +345,18 @@ func (m *Manager) startTearDown(ctx context.Context) {
func isPermanentLeaseRequired(err error) bool {
return err != nil && upnpErrPermanentLeaseOnly.MatchString(err.Error())
}
+
+// logIPv6Pinhole reports the outcome of the IPv6 pinhole. Pinholes are best
+// effort and never fail a mapping on their own, so this is the only way to see
+// whether one was actually opened.
+func logIPv6Pinhole(gateway nat.NAT) {
+ reporter, ok := gateway.(nat.IPv6PinholeReporter)
+ if !ok {
+ return
+ }
+ if err := reporter.IPv6PinholeError(); err != nil {
+ log.Warnf("IPv6 pinhole: %v", err)
+ return
+ }
+ log.Infof("IPv6 pinhole open")
+}
diff --git a/client/internal/portforward/pcp/client.go b/client/internal/portforward/pcp/client.go
deleted file mode 100644
index f6d243ef9..000000000
--- a/client/internal/portforward/pcp/client.go
+++ /dev/null
@@ -1,408 +0,0 @@
-package pcp
-
-import (
- "context"
- "crypto/rand"
- "errors"
- "fmt"
- "net"
- "net/netip"
- "sync"
- "time"
-
- log "github.com/sirupsen/logrus"
-)
-
-const (
- defaultTimeout = 3 * time.Second
- responseBufferSize = 128
-
- // RFC 6887 Section 8.1.1 retry timing
- initialRetryDelay = 3 * time.Second
- maxRetryDelay = 1024 * time.Second
- maxRetries = 4 // 3s + 6s + 12s + 24s = 45s total worst case
-)
-
-// Client is a PCP protocol client.
-// All methods are safe for concurrent use.
-type Client struct {
- gateway netip.Addr
- timeout time.Duration
-
- mu sync.Mutex
- // localIP caches the resolved local IP address.
- localIP netip.Addr
- // lastEpoch is the last observed server epoch value.
- lastEpoch uint32
- // epochTime tracks when lastEpoch was received for state loss detection.
- epochTime time.Time
- // externalIP caches the external IP from the last successful MAP response.
- externalIP netip.Addr
- // epochStateLost is set when epoch indicates server restart.
- epochStateLost bool
-}
-
-// NewClient creates a new PCP client for the gateway at the given IP.
-func NewClient(gateway net.IP) *Client {
- addr, ok := netip.AddrFromSlice(gateway)
- if !ok {
- log.Debugf("invalid gateway IP: %v", gateway)
- }
- return &Client{
- gateway: addr.Unmap(),
- timeout: defaultTimeout,
- }
-}
-
-// NewClientWithTimeout creates a new PCP client with a custom timeout.
-func NewClientWithTimeout(gateway net.IP, timeout time.Duration) *Client {
- addr, ok := netip.AddrFromSlice(gateway)
- if !ok {
- log.Debugf("invalid gateway IP: %v", gateway)
- }
- return &Client{
- gateway: addr.Unmap(),
- timeout: timeout,
- }
-}
-
-// SetLocalIP sets the local IP address to use in PCP requests.
-func (c *Client) SetLocalIP(ip net.IP) {
- addr, ok := netip.AddrFromSlice(ip)
- if !ok {
- log.Debugf("invalid local IP: %v", ip)
- }
- c.mu.Lock()
- c.localIP = addr.Unmap()
- c.mu.Unlock()
-}
-
-// Gateway returns the gateway IP address.
-func (c *Client) Gateway() net.IP {
- return c.gateway.AsSlice()
-}
-
-// Announce sends a PCP ANNOUNCE request to discover PCP support.
-// Returns the server's epoch time on success.
-func (c *Client) Announce(ctx context.Context) (epoch uint32, err error) {
- localIP, err := c.getLocalIP()
- if err != nil {
- return 0, fmt.Errorf("get local IP: %w", err)
- }
-
- req := buildAnnounceRequest(localIP)
- resp, err := c.sendRequest(ctx, req)
- if err != nil {
- return 0, fmt.Errorf("send announce: %w", err)
- }
-
- parsed, err := parseResponse(resp)
- if err != nil {
- return 0, fmt.Errorf("parse announce response: %w", err)
- }
-
- if parsed.ResultCode != ResultSuccess {
- return 0, fmt.Errorf("PCP ANNOUNCE failed: %s", ResultCodeString(parsed.ResultCode))
- }
-
- c.mu.Lock()
- if c.updateEpochLocked(parsed.Epoch) {
- log.Warnf("PCP server epoch indicates state loss - mappings may need refresh")
- }
- c.mu.Unlock()
- return parsed.Epoch, nil
-}
-
-// AddPortMapping requests a port mapping from the PCP server.
-func (c *Client) AddPortMapping(ctx context.Context, protocol string, internalPort int, lifetime time.Duration) (*MapResponse, error) {
- return c.addPortMappingWithHint(ctx, protocol, internalPort, internalPort, netip.Addr{}, lifetime)
-}
-
-// AddPortMappingWithHint requests a port mapping with suggested external port and IP.
-// Use lifetime <= 0 to delete a mapping.
-func (c *Client) AddPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP net.IP, lifetime time.Duration) (*MapResponse, error) {
- var extIP netip.Addr
- if suggestedExtIP != nil {
- var ok bool
- extIP, ok = netip.AddrFromSlice(suggestedExtIP)
- if !ok {
- log.Debugf("invalid suggested external IP: %v", suggestedExtIP)
- }
- extIP = extIP.Unmap()
- }
- return c.addPortMappingWithHint(ctx, protocol, internalPort, suggestedExtPort, extIP, lifetime)
-}
-
-func (c *Client) addPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP netip.Addr, lifetime time.Duration) (*MapResponse, error) {
- localIP, err := c.getLocalIP()
- if err != nil {
- return nil, fmt.Errorf("get local IP: %w", err)
- }
-
- proto, err := protocolNumber(protocol)
- if err != nil {
- return nil, fmt.Errorf("parse protocol: %w", err)
- }
-
- var nonce [12]byte
- if _, err := rand.Read(nonce[:]); err != nil {
- return nil, fmt.Errorf("generate nonce: %w", err)
- }
-
- // Convert lifetime to seconds. Lifetime 0 means delete, so only apply
- // default for positive durations that round to 0 seconds.
- var lifetimeSec uint32
- if lifetime > 0 {
- lifetimeSec = uint32(lifetime.Seconds())
- if lifetimeSec == 0 {
- lifetimeSec = DefaultLifetime
- }
- }
-
- req := buildMapRequest(localIP, nonce, proto, uint16(internalPort), uint16(suggestedExtPort), suggestedExtIP, lifetimeSec)
-
- resp, err := c.sendRequest(ctx, req)
- if err != nil {
- return nil, fmt.Errorf("send map request: %w", err)
- }
-
- mapResp, err := parseMapResponse(resp)
- if err != nil {
- return nil, fmt.Errorf("parse map response: %w", err)
- }
-
- if mapResp.Nonce != nonce {
- return nil, fmt.Errorf("nonce mismatch in response")
- }
-
- if mapResp.Protocol != proto {
- return nil, fmt.Errorf("protocol mismatch: requested %d, got %d", proto, mapResp.Protocol)
- }
- if mapResp.InternalPort != uint16(internalPort) {
- return nil, fmt.Errorf("internal port mismatch: requested %d, got %d", internalPort, mapResp.InternalPort)
- }
-
- if mapResp.ResultCode != ResultSuccess {
- return nil, &Error{
- Code: mapResp.ResultCode,
- Message: ResultCodeString(mapResp.ResultCode),
- }
- }
-
- c.mu.Lock()
- if c.updateEpochLocked(mapResp.Epoch) {
- log.Warnf("PCP server epoch indicates state loss - mappings may need refresh")
- }
- c.cacheExternalIPLocked(mapResp.ExternalIP)
- c.mu.Unlock()
- return mapResp, nil
-}
-
-// DeletePortMapping removes a port mapping by requesting zero lifetime.
-func (c *Client) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error {
- if _, err := c.addPortMappingWithHint(ctx, protocol, internalPort, 0, netip.Addr{}, 0); err != nil {
- var pcpErr *Error
- if errors.As(err, &pcpErr) && pcpErr.Code == ResultNotAuthorized {
- return nil
- }
- return fmt.Errorf("delete mapping: %w", err)
- }
- return nil
-}
-
-// GetExternalAddress returns the external IP address.
-// First checks for a cached value from previous MAP responses.
-// If not cached, creates a short-lived mapping to discover the external IP.
-func (c *Client) GetExternalAddress(ctx context.Context) (net.IP, error) {
- c.mu.Lock()
- if c.externalIP.IsValid() {
- ip := c.externalIP.AsSlice()
- c.mu.Unlock()
- return ip, nil
- }
- c.mu.Unlock()
-
- // Use an ephemeral port in the dynamic range (49152-65535).
- // Port 0 is not valid with UDP/TCP protocols per RFC 6887.
- ephemeralPort := 49152 + int(uint16(time.Now().UnixNano()))%(65535-49152)
-
- // Use minimal lifetime (1 second) for discovery.
- resp, err := c.AddPortMapping(ctx, "udp", ephemeralPort, time.Second)
- if err != nil {
- return nil, fmt.Errorf("create temporary mapping: %w", err)
- }
-
- if err := c.DeletePortMapping(ctx, "udp", ephemeralPort); err != nil {
- log.Debugf("cleanup temporary PCP mapping: %v", err)
- }
-
- return resp.ExternalIP.AsSlice(), nil
-}
-
-// LastEpoch returns the last observed server epoch value.
-// A decrease in epoch indicates the server may have restarted and mappings may be lost.
-func (c *Client) LastEpoch() uint32 {
- c.mu.Lock()
- defer c.mu.Unlock()
- return c.lastEpoch
-}
-
-// EpochStateLost returns true if epoch state loss was detected and clears the flag.
-func (c *Client) EpochStateLost() bool {
- c.mu.Lock()
- defer c.mu.Unlock()
- lost := c.epochStateLost
- c.epochStateLost = false
- return lost
-}
-
-// updateEpoch updates the epoch tracking and detects potential state loss.
-// Returns true if state loss was detected (server likely restarted).
-// Caller must hold c.mu.
-func (c *Client) updateEpochLocked(newEpoch uint32) bool {
- now := time.Now()
- stateLost := false
-
- // RFC 6887 Section 8.5: Detect invalid epoch indicating server state loss.
- // client_delta = time since last response
- // server_delta = epoch change since last response
- // Invalid if: client_delta+2 < server_delta - server_delta/16
- // OR: server_delta+2 < client_delta - client_delta/16
- // The +2 handles quantization, /16 (6.25%) handles clock drift.
- if !c.epochTime.IsZero() && c.lastEpoch > 0 {
- clientDelta := uint32(now.Sub(c.epochTime).Seconds())
- serverDelta := newEpoch - c.lastEpoch
-
- // Check for epoch going backwards or jumping unexpectedly.
- // Subtraction is safe: serverDelta/16 is always <= serverDelta.
- if clientDelta+2 < serverDelta-(serverDelta/16) ||
- serverDelta+2 < clientDelta-(clientDelta/16) {
- stateLost = true
- c.epochStateLost = true
- }
- }
-
- c.lastEpoch = newEpoch
- c.epochTime = now
- return stateLost
-}
-
-// cacheExternalIP stores the external IP from a successful MAP response.
-// Caller must hold c.mu.
-func (c *Client) cacheExternalIPLocked(ip netip.Addr) {
- if ip.IsValid() && !ip.IsUnspecified() {
- c.externalIP = ip
- }
-}
-
-// sendRequest sends a PCP request with retries per RFC 6887 Section 8.1.1.
-func (c *Client) sendRequest(ctx context.Context, req []byte) ([]byte, error) {
- addr := &net.UDPAddr{IP: c.gateway.AsSlice(), Port: Port}
-
- var lastErr error
- delay := initialRetryDelay
-
- for range maxRetries {
- resp, err := c.sendOnce(ctx, addr, req)
- if err == nil {
- return resp, nil
- }
- lastErr = err
-
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
-
- // RFC 6887 Section 8.1.1: RT = (1 + RAND) * MIN(2 * RTprev, MRT)
- // RAND is random between -0.1 and +0.1
- select {
- case <-ctx.Done():
- return nil, ctx.Err()
- case <-time.After(retryDelayWithJitter(delay)):
- }
- delay = min(delay*2, maxRetryDelay)
- }
-
- return nil, fmt.Errorf("PCP request failed after %d retries: %w", maxRetries, lastErr)
-}
-
-// retryDelayWithJitter applies RFC 6887 jitter: multiply by (1 + RAND) where RAND is [-0.1, +0.1].
-func retryDelayWithJitter(d time.Duration) time.Duration {
- var b [1]byte
- _, _ = rand.Read(b[:])
- // Convert byte to range [-0.1, +0.1]: (b/255 * 0.2) - 0.1
- jitter := (float64(b[0])/255.0)*0.2 - 0.1
- return time.Duration(float64(d) * (1 + jitter))
-}
-
-func (c *Client) sendOnce(ctx context.Context, addr *net.UDPAddr, req []byte) ([]byte, error) {
- // Use ListenUDP instead of DialUDP to validate response source address per RFC 6887 §8.3.
- conn, err := net.ListenUDP("udp", nil)
- if err != nil {
- return nil, fmt.Errorf("listen: %w", err)
- }
- defer func() {
- if err := conn.Close(); err != nil {
- log.Debugf("close UDP connection: %v", err)
- }
- }()
-
- timeout := c.timeout
- if deadline, ok := ctx.Deadline(); ok {
- if remaining := time.Until(deadline); remaining < timeout {
- timeout = remaining
- }
- }
-
- if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
- return nil, fmt.Errorf("set deadline: %w", err)
- }
-
- if _, err := conn.WriteToUDP(req, addr); err != nil {
- return nil, fmt.Errorf("write: %w", err)
- }
-
- resp := make([]byte, responseBufferSize)
- n, from, err := conn.ReadFromUDP(resp)
- if err != nil {
- return nil, fmt.Errorf("read: %w", err)
- }
-
- // RFC 6887 §8.3: Validate response came from expected PCP server.
- if !from.IP.Equal(addr.IP) {
- return nil, fmt.Errorf("response from unexpected source %s (expected %s)", from.IP, addr.IP)
- }
-
- return resp[:n], nil
-}
-
-func (c *Client) getLocalIP() (netip.Addr, error) {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- if !c.localIP.IsValid() {
- return netip.Addr{}, fmt.Errorf("local IP not set for gateway %s", c.gateway)
- }
- return c.localIP, nil
-}
-
-func protocolNumber(protocol string) (uint8, error) {
- switch protocol {
- case "udp", "UDP":
- return ProtoUDP, nil
- case "tcp", "TCP":
- return ProtoTCP, nil
- default:
- return 0, fmt.Errorf("unsupported protocol: %s", protocol)
- }
-}
-
-// Error represents a PCP error response.
-type Error struct {
- Code uint8
- Message string
-}
-
-func (e *Error) Error() string {
- return fmt.Sprintf("PCP error: %s (%d)", e.Message, e.Code)
-}
diff --git a/client/internal/portforward/pcp/client_test.go b/client/internal/portforward/pcp/client_test.go
deleted file mode 100644
index 79f44a426..000000000
--- a/client/internal/portforward/pcp/client_test.go
+++ /dev/null
@@ -1,187 +0,0 @@
-package pcp
-
-import (
- "context"
- "net"
- "net/netip"
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestAddrConversion(t *testing.T) {
- tests := []struct {
- name string
- addr netip.Addr
- }{
- {"IPv4", netip.MustParseAddr("192.168.1.100")},
- {"IPv4 loopback", netip.MustParseAddr("127.0.0.1")},
- {"IPv6", netip.MustParseAddr("2001:db8::1")},
- {"IPv6 loopback", netip.MustParseAddr("::1")},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- b16 := addrTo16(tt.addr)
-
- recovered := addrFrom16(b16)
- assert.Equal(t, tt.addr, recovered, "address should round-trip")
- })
- }
-}
-
-func TestBuildAnnounceRequest(t *testing.T) {
- clientIP := netip.MustParseAddr("192.168.1.100")
- req := buildAnnounceRequest(clientIP)
-
- require.Len(t, req, headerSize)
- assert.Equal(t, byte(Version), req[0], "version")
- assert.Equal(t, byte(OpAnnounce), req[1], "opcode")
-
- // Check client IP is properly encoded as IPv4-mapped IPv6
- assert.Equal(t, byte(0xff), req[18], "IPv4-mapped prefix byte 10")
- assert.Equal(t, byte(0xff), req[19], "IPv4-mapped prefix byte 11")
- assert.Equal(t, byte(192), req[20], "IP octet 1")
- assert.Equal(t, byte(168), req[21], "IP octet 2")
- assert.Equal(t, byte(1), req[22], "IP octet 3")
- assert.Equal(t, byte(100), req[23], "IP octet 4")
-}
-
-func TestBuildMapRequest(t *testing.T) {
- clientIP := netip.MustParseAddr("192.168.1.100")
- nonce := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
- req := buildMapRequest(clientIP, nonce, ProtoUDP, 51820, 51820, netip.Addr{}, 3600)
-
- require.Len(t, req, mapRequestSize)
- assert.Equal(t, byte(Version), req[0], "version")
- assert.Equal(t, byte(OpMap), req[1], "opcode")
-
- // Lifetime at bytes 4-7
- assert.Equal(t, uint32(3600), (uint32(req[4])<<24)|(uint32(req[5])<<16)|(uint32(req[6])<<8)|uint32(req[7]), "lifetime")
-
- // Nonce at bytes 24-35
- assert.Equal(t, nonce[:], req[24:36], "nonce")
-
- // Protocol at byte 36
- assert.Equal(t, byte(ProtoUDP), req[36], "protocol")
-
- // Internal port at bytes 40-41
- assert.Equal(t, uint16(51820), (uint16(req[40])<<8)|uint16(req[41]), "internal port")
-
- // External port at bytes 42-43
- assert.Equal(t, uint16(51820), (uint16(req[42])<<8)|uint16(req[43]), "external port")
-}
-
-func TestParseResponse(t *testing.T) {
- // Construct a valid ANNOUNCE response
- resp := make([]byte, headerSize)
- resp[0] = Version
- resp[1] = OpAnnounce | OpReply
- // Result code = 0 (success)
- // Lifetime = 0
- // Epoch = 12345
- resp[8] = 0
- resp[9] = 0
- resp[10] = 0x30
- resp[11] = 0x39
-
- parsed, err := parseResponse(resp)
- require.NoError(t, err)
- assert.Equal(t, uint8(Version), parsed.Version)
- assert.Equal(t, uint8(OpAnnounce|OpReply), parsed.Opcode)
- assert.Equal(t, uint8(ResultSuccess), parsed.ResultCode)
- assert.Equal(t, uint32(12345), parsed.Epoch)
-}
-
-func TestParseResponseErrors(t *testing.T) {
- t.Run("too short", func(t *testing.T) {
- _, err := parseResponse([]byte{1, 2, 3})
- assert.Error(t, err)
- })
-
- t.Run("wrong version", func(t *testing.T) {
- resp := make([]byte, headerSize)
- resp[0] = 1 // Wrong version
- resp[1] = OpReply
- _, err := parseResponse(resp)
- assert.Error(t, err)
- })
-
- t.Run("missing reply bit", func(t *testing.T) {
- resp := make([]byte, headerSize)
- resp[0] = Version
- resp[1] = OpAnnounce // Missing OpReply bit
- _, err := parseResponse(resp)
- assert.Error(t, err)
- })
-}
-
-func TestResultCodeString(t *testing.T) {
- assert.Equal(t, "SUCCESS", ResultCodeString(ResultSuccess))
- assert.Equal(t, "NOT_AUTHORIZED", ResultCodeString(ResultNotAuthorized))
- assert.Equal(t, "ADDRESS_MISMATCH", ResultCodeString(ResultAddressMismatch))
- assert.Contains(t, ResultCodeString(255), "UNKNOWN")
-}
-
-func TestProtocolNumber(t *testing.T) {
- proto, err := protocolNumber("udp")
- require.NoError(t, err)
- assert.Equal(t, uint8(ProtoUDP), proto)
-
- proto, err = protocolNumber("tcp")
- require.NoError(t, err)
- assert.Equal(t, uint8(ProtoTCP), proto)
-
- proto, err = protocolNumber("UDP")
- require.NoError(t, err)
- assert.Equal(t, uint8(ProtoUDP), proto)
-
- _, err = protocolNumber("icmp")
- assert.Error(t, err)
-}
-
-func TestClientCreation(t *testing.T) {
- gateway := netip.MustParseAddr("192.168.1.1").AsSlice()
-
- client := NewClient(gateway)
- assert.Equal(t, net.IP(gateway), client.Gateway())
- assert.Equal(t, defaultTimeout, client.timeout)
-
- clientWithTimeout := NewClientWithTimeout(gateway, 5*time.Second)
- assert.Equal(t, 5*time.Second, clientWithTimeout.timeout)
-}
-
-func TestNATType(t *testing.T) {
- n := NewNAT(netip.MustParseAddr("192.168.1.1").AsSlice(), netip.MustParseAddr("192.168.1.100").AsSlice())
- assert.Equal(t, "PCP", n.Type())
-}
-
-// Integration test - skipped unless PCP_TEST_GATEWAY env is set
-func TestClientIntegration(t *testing.T) {
- t.Skip("Integration test - run manually with PCP_TEST_GATEWAY=")
-
- gateway := netip.MustParseAddr("10.0.1.1").AsSlice() // Change to your test gateway
- localIP := netip.MustParseAddr("10.0.1.100").AsSlice() // Change to your local IP
-
- client := NewClient(gateway)
- client.SetLocalIP(localIP)
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
-
- // Test ANNOUNCE
- epoch, err := client.Announce(ctx)
- require.NoError(t, err)
- t.Logf("Server epoch: %d", epoch)
-
- // Test MAP
- resp, err := client.AddPortMapping(ctx, "udp", 51820, 1*time.Hour)
- require.NoError(t, err)
- t.Logf("Mapping: internal=%d external=%d externalIP=%s",
- resp.InternalPort, resp.ExternalPort, resp.ExternalIP)
-
- // Cleanup
- err = client.DeletePortMapping(ctx, "udp", 51820)
- require.NoError(t, err)
-}
diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go
deleted file mode 100644
index 0e635b6c8..000000000
--- a/client/internal/portforward/pcp/nat.go
+++ /dev/null
@@ -1,222 +0,0 @@
-package pcp
-
-import (
- "context"
- "fmt"
- "net"
- "net/netip"
- "runtime"
- "sync"
- "time"
-
- log "github.com/sirupsen/logrus"
-
- "github.com/libp2p/go-nat"
- "github.com/libp2p/go-netroute"
-)
-
-var _ nat.NAT = (*NAT)(nil)
-
-// NAT implements the go-nat NAT interface using PCP.
-// Supports dual-stack (IPv4 and IPv6) when available.
-// All methods are safe for concurrent use.
-//
-// TODO: IPv6 pinholes use the local IPv6 address. If the address changes
-// (e.g., due to SLAAC rotation or network change), the pinhole becomes stale
-// and needs to be recreated with the new address.
-type NAT struct {
- client *Client
-
- mu sync.RWMutex
- // client6 is the IPv6 PCP client, nil if IPv6 is unavailable.
- client6 *Client
- // localIP6 caches the local IPv6 address used for PCP requests.
- localIP6 netip.Addr
-}
-
-// NewNAT creates a new NAT instance backed by PCP.
-func NewNAT(gateway, localIP net.IP) *NAT {
- client := NewClient(gateway)
- client.SetLocalIP(localIP)
- return &NAT{
- client: client,
- }
-}
-
-// Type returns "PCP" as the NAT type.
-func (n *NAT) Type() string {
- return "PCP"
-}
-
-// GetDeviceAddress returns the gateway IP address.
-func (n *NAT) GetDeviceAddress() (net.IP, error) {
- return n.client.Gateway(), nil
-}
-
-// GetExternalAddress returns the external IP address.
-func (n *NAT) GetExternalAddress() (net.IP, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- return n.client.GetExternalAddress(ctx)
-}
-
-// GetInternalAddress returns the local IP address used to communicate with the gateway.
-func (n *NAT) GetInternalAddress() (net.IP, error) {
- addr, err := n.client.getLocalIP()
- if err != nil {
- return nil, err
- }
- return addr.AsSlice(), nil
-}
-
-// AddPortMapping creates a port mapping on both IPv4 and IPv6 (if available).
-func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) {
- resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout)
- if err != nil {
- return 0, fmt.Errorf("add mapping: %w", err)
- }
-
- n.mu.RLock()
- client6 := n.client6
- localIP6 := n.localIP6
- n.mu.RUnlock()
-
- if client6 == nil {
- return int(resp.ExternalPort), nil
- }
-
- if _, err := client6.AddPortMapping(ctx, protocol, internalPort, timeout); err != nil {
- log.Warnf("IPv6 PCP mapping failed (continuing with IPv4): %v", err)
- return int(resp.ExternalPort), nil
- }
-
- log.Infof("created IPv6 PCP pinhole: %s:%d", localIP6, internalPort)
- return int(resp.ExternalPort), nil
-}
-
-// DeletePortMapping removes a port mapping from both IPv4 and IPv6.
-func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error {
- err := n.client.DeletePortMapping(ctx, protocol, internalPort)
-
- n.mu.RLock()
- client6 := n.client6
- n.mu.RUnlock()
-
- if client6 != nil {
- if err6 := client6.DeletePortMapping(ctx, protocol, internalPort); err6 != nil {
- log.Warnf("IPv6 PCP delete mapping failed: %v", err6)
- }
- }
-
- if err != nil {
- return fmt.Errorf("delete mapping: %w", err)
- }
- return nil
-}
-
-// CheckServerHealth sends an ANNOUNCE to verify the server is still responsive.
-// Returns the current epoch and whether the server may have restarted (epoch state loss detected).
-func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) {
- epoch, err = n.client.Announce(ctx)
- if err != nil {
- return 0, false, fmt.Errorf("announce: %w", err)
- }
- return epoch, n.client.EpochStateLost(), nil
-}
-
-// DiscoverPCP attempts to discover a PCP-capable gateway.
-// Returns a NAT interface if PCP is supported, or an error otherwise.
-// Discovers both IPv4 and IPv6 gateways when available.
-func DiscoverPCP(ctx context.Context) (nat.NAT, error) {
- gateway, localIP, err := getDefaultGateway()
- if err != nil {
- return nil, fmt.Errorf("get default gateway: %w", err)
- }
-
- client := NewClient(gateway)
- client.SetLocalIP(localIP)
- if _, err := client.Announce(ctx); err != nil {
- return nil, fmt.Errorf("PCP announce: %w", err)
- }
-
- result := &NAT{client: client}
- discoverIPv6(ctx, result)
-
- return result, nil
-}
-
-func discoverIPv6(ctx context.Context, result *NAT) {
- gateway6, localIP6, err := getDefaultGateway6()
- if err != nil {
- log.Debugf("IPv6 gateway discovery failed: %v", err)
- return
- }
-
- client6 := NewClient(gateway6)
- client6.SetLocalIP(localIP6)
- if _, err := client6.Announce(ctx); err != nil {
- log.Debugf("PCP IPv6 announce failed: %v", err)
- return
- }
-
- addr, ok := netip.AddrFromSlice(localIP6)
- if !ok {
- log.Debugf("invalid IPv6 local IP: %v", localIP6)
- return
- }
- result.mu.Lock()
- result.client6 = client6
- result.localIP6 = addr
- result.mu.Unlock()
- log.Debugf("PCP IPv6 gateway discovered: %s (local: %s)", gateway6, localIP6)
-}
-
-// getDefaultGateway returns the default IPv4 gateway and local IP using the system routing table.
-func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
- router, err := netroute.New()
- if err != nil {
- return nil, nil, err
- }
-
- dst := net.IPv4zero
- if runtime.GOOS == "linux" || runtime.GOOS == "android" {
- // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
- // TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties /
- // NWPathMonitor) when netlink-based lookup is restricted or unavailable.
- dst = net.IPv4(0, 0, 0, 1)
- }
- _, gateway, localIP, err = router.Route(dst)
- if err != nil {
- return nil, nil, err
- }
-
- if gateway == nil {
- return nil, nil, nat.ErrNoNATFound
- }
-
- return gateway, localIP, nil
-}
-
-// getDefaultGateway6 returns the default IPv6 gateway IP address using the system routing table.
-func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
- router, err := netroute.New()
- if err != nil {
- return nil, nil, err
- }
-
- dst := net.IPv6zero
- if runtime.GOOS == "linux" || runtime.GOOS == "android" {
- // ::2
- dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
- }
- _, gateway, localIP, err = router.Route(dst)
- if err != nil {
- return nil, nil, err
- }
-
- if gateway == nil {
- return nil, nil, nat.ErrNoNATFound
- }
-
- return gateway, localIP, nil
-}
diff --git a/client/internal/portforward/pcp/protocol.go b/client/internal/portforward/pcp/protocol.go
deleted file mode 100644
index d81c50c8c..000000000
--- a/client/internal/portforward/pcp/protocol.go
+++ /dev/null
@@ -1,225 +0,0 @@
-// Package pcp implements the Port Control Protocol (RFC 6887).
-//
-// # Implemented Features
-//
-// - ANNOUNCE opcode: Discovers PCP server support
-// - MAP opcode: Creates/deletes port mappings (IPv4 NAT) and firewall pinholes (IPv6)
-// - Dual-stack: Simultaneous IPv4 and IPv6 support via separate clients
-// - Nonce validation: Prevents response spoofing
-// - Epoch tracking: Detects server restarts per Section 8.5
-// - RFC-compliant retry timing: 3s initial, exponential backoff to 1024s max (Section 8.1.1)
-//
-// # Not Implemented
-//
-// - PEER opcode: For outbound peer connections (not needed for inbound NAT traversal)
-// - THIRD_PARTY option: For managing mappings on behalf of other devices
-// - PREFER_FAILURE option: Requires exact external port or fail (IPv4 NAT only, not needed for IPv6 pinholing)
-// - FILTER option: To restrict remote peer addresses
-//
-// These optional features are omitted because the primary use case is simple
-// port forwarding for WireGuard, which only requires MAP with default behavior.
-package pcp
-
-import (
- "encoding/binary"
- "fmt"
- "net/netip"
-)
-
-const (
- // Version is the PCP protocol version (RFC 6887).
- Version = 2
-
- // Port is the standard PCP server port.
- Port = 5351
-
- // DefaultLifetime is the default requested mapping lifetime in seconds.
- DefaultLifetime = 7200 // 2 hours
-
- // Header sizes
- headerSize = 24
- mapPayloadSize = 36
- mapRequestSize = headerSize + mapPayloadSize // 60 bytes
-)
-
-// Opcodes
-const (
- OpAnnounce = 0
- OpMap = 1
- OpPeer = 2
- OpReply = 0x80 // OR'd with opcode in responses
-)
-
-// Protocol numbers for MAP requests
-const (
- ProtoUDP = 17
- ProtoTCP = 6
-)
-
-// Result codes (RFC 6887 Section 7.4)
-const (
- ResultSuccess = 0
- ResultUnsuppVersion = 1
- ResultNotAuthorized = 2
- ResultMalformedRequest = 3
- ResultUnsuppOpcode = 4
- ResultUnsuppOption = 5
- ResultMalformedOption = 6
- ResultNetworkFailure = 7
- ResultNoResources = 8
- ResultUnsuppProtocol = 9
- ResultUserExQuota = 10
- ResultCannotProvideExt = 11
- ResultAddressMismatch = 12
- ResultExcessiveRemotePeers = 13
-)
-
-// ResultCodeString returns a human-readable string for a result code.
-func ResultCodeString(code uint8) string {
- switch code {
- case ResultSuccess:
- return "SUCCESS"
- case ResultUnsuppVersion:
- return "UNSUPP_VERSION"
- case ResultNotAuthorized:
- return "NOT_AUTHORIZED"
- case ResultMalformedRequest:
- return "MALFORMED_REQUEST"
- case ResultUnsuppOpcode:
- return "UNSUPP_OPCODE"
- case ResultUnsuppOption:
- return "UNSUPP_OPTION"
- case ResultMalformedOption:
- return "MALFORMED_OPTION"
- case ResultNetworkFailure:
- return "NETWORK_FAILURE"
- case ResultNoResources:
- return "NO_RESOURCES"
- case ResultUnsuppProtocol:
- return "UNSUPP_PROTOCOL"
- case ResultUserExQuota:
- return "USER_EX_QUOTA"
- case ResultCannotProvideExt:
- return "CANNOT_PROVIDE_EXTERNAL"
- case ResultAddressMismatch:
- return "ADDRESS_MISMATCH"
- case ResultExcessiveRemotePeers:
- return "EXCESSIVE_REMOTE_PEERS"
- default:
- return fmt.Sprintf("UNKNOWN(%d)", code)
- }
-}
-
-// Response represents a parsed PCP response header.
-type Response struct {
- Version uint8
- Opcode uint8
- ResultCode uint8
- Lifetime uint32
- Epoch uint32
-}
-
-// MapResponse contains the full response to a MAP request.
-type MapResponse struct {
- Response
- Nonce [12]byte
- Protocol uint8
- InternalPort uint16
- ExternalPort uint16
- ExternalIP netip.Addr
-}
-
-// addrTo16 converts an address to its 16-byte IPv4-mapped IPv6 representation.
-func addrTo16(addr netip.Addr) [16]byte {
- if addr.Is4() {
- return netip.AddrFrom4(addr.As4()).As16()
- }
- return addr.As16()
-}
-
-// addrFrom16 extracts an address from a 16-byte representation, unmapping IPv4.
-func addrFrom16(b [16]byte) netip.Addr {
- return netip.AddrFrom16(b).Unmap()
-}
-
-// buildAnnounceRequest creates a PCP ANNOUNCE request packet.
-func buildAnnounceRequest(clientIP netip.Addr) []byte {
- req := make([]byte, headerSize)
- req[0] = Version
- req[1] = OpAnnounce
- mapped := addrTo16(clientIP)
- copy(req[8:24], mapped[:])
- return req
-}
-
-// buildMapRequest creates a PCP MAP request packet.
-func buildMapRequest(clientIP netip.Addr, nonce [12]byte, protocol uint8, internalPort, suggestedExtPort uint16, suggestedExtIP netip.Addr, lifetime uint32) []byte {
- req := make([]byte, mapRequestSize)
-
- // Header
- req[0] = Version
- req[1] = OpMap
- binary.BigEndian.PutUint32(req[4:8], lifetime)
- mapped := addrTo16(clientIP)
- copy(req[8:24], mapped[:])
-
- // MAP payload
- copy(req[24:36], nonce[:])
- req[36] = protocol
- binary.BigEndian.PutUint16(req[40:42], internalPort)
- binary.BigEndian.PutUint16(req[42:44], suggestedExtPort)
- if suggestedExtIP.IsValid() {
- extMapped := addrTo16(suggestedExtIP)
- copy(req[44:60], extMapped[:])
- }
-
- return req
-}
-
-// parseResponse parses the common PCP response header.
-func parseResponse(data []byte) (*Response, error) {
- if len(data) < headerSize {
- return nil, fmt.Errorf("response too short: %d bytes", len(data))
- }
-
- resp := &Response{
- Version: data[0],
- Opcode: data[1],
- ResultCode: data[3], // Byte 2 is reserved, byte 3 is result code (RFC 6887 §7.2)
- Lifetime: binary.BigEndian.Uint32(data[4:8]),
- Epoch: binary.BigEndian.Uint32(data[8:12]),
- }
-
- if resp.Version != Version {
- return nil, fmt.Errorf("unsupported PCP version: %d", resp.Version)
- }
-
- if resp.Opcode&OpReply == 0 {
- return nil, fmt.Errorf("response missing reply bit: opcode=0x%02x", resp.Opcode)
- }
-
- return resp, nil
-}
-
-// parseMapResponse parses a complete MAP response.
-func parseMapResponse(data []byte) (*MapResponse, error) {
- if len(data) < mapRequestSize {
- return nil, fmt.Errorf("MAP response too short: %d bytes", len(data))
- }
-
- resp, err := parseResponse(data)
- if err != nil {
- return nil, fmt.Errorf("parse header: %w", err)
- }
-
- mapResp := &MapResponse{
- Response: *resp,
- Protocol: data[36],
- InternalPort: binary.BigEndian.Uint16(data[40:42]),
- ExternalPort: binary.BigEndian.Uint16(data[42:44]),
- ExternalIP: addrFrom16([16]byte(data[44:60])),
- }
- copy(mapResp.Nonce[:], data[24:36])
-
- return mapResp, nil
-}
diff --git a/client/internal/portforward/pinhole_test.go b/client/internal/portforward/pinhole_test.go
new file mode 100644
index 000000000..46b07a9e7
--- /dev/null
+++ b/client/internal/portforward/pinhole_test.go
@@ -0,0 +1,116 @@
+//go:build !js
+
+package portforward
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/netbirdio/go-nat"
+ log "github.com/sirupsen/logrus"
+ "github.com/sirupsen/logrus/hooks/test"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// mockPinholeNAT is a gateway that also reports an IPv6 pinhole outcome, the
+// shape a dual-stack gateway has.
+type mockPinholeNAT struct {
+ *mockNAT
+ pinholeErr error
+}
+
+func (m *mockPinholeNAT) IPv6PinholeError() error {
+ return m.pinholeErr
+}
+
+func TestSetupLogsPinholeOutcome(t *testing.T) {
+ pinholeErr := errors.New("pcp ipv6: NOT_AUTHORIZED")
+
+ tests := []struct {
+ name string
+ pinholeErr error
+ mappingErr error
+ wantLevel log.Level
+ wantText string
+ }{
+ {
+ name: "an open pinhole is reported",
+ wantLevel: log.InfoLevel,
+ wantText: "IPv6 pinhole open",
+ },
+ {
+ name: "a failed pinhole is reported without failing the mapping",
+ // The IPv4 mapping is what the caller asked for, so the pinhole
+ // failure surfaces only in the log.
+ pinholeErr: pinholeErr,
+ wantLevel: log.WarnLevel,
+ wantText: pinholeErr.Error(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gateway := &mockPinholeNAT{mockNAT: newMockNAT(), pinholeErr: tt.pinholeErr}
+ hook := stubGatewayDiscovery(t, gateway)
+
+ m := NewManager()
+ m.wgPort = 51820
+
+ _, mapping, err := m.setup(context.Background())
+
+ require.NoError(t, err)
+ require.NotNil(t, mapping)
+
+ entry := findEntry(hook, tt.wantText)
+ require.NotNil(t, entry, "no log entry mentioning %q", tt.wantText)
+ assert.Equal(t, tt.wantLevel, entry.Level)
+ })
+ }
+
+ t.Run("a failed mapping reports no pinhole outcome", func(t *testing.T) {
+ // Nothing opened the pinhole, so whatever it currently reports says
+ // nothing about this attempt.
+ gateway := &mockPinholeNAT{mockNAT: newMockNAT()}
+ gateway.addMappingErr = errors.New("gateway refused")
+ hook := stubGatewayDiscovery(t, gateway)
+
+ m := NewManager()
+ m.wgPort = 51820
+
+ _, _, err := m.setup(context.Background())
+
+ require.Error(t, err)
+ assert.Nil(t, findEntry(hook, "IPv6 pinhole"))
+ })
+}
+
+// stubGatewayDiscovery makes discovery return gateway and captures log output.
+func stubGatewayDiscovery(t *testing.T, gateway nat.NAT) *test.Hook {
+ t.Helper()
+
+ orig := discoverGateway
+ discoverGateway = func(context.Context) (nat.NAT, error) { return gateway, nil }
+ t.Cleanup(func() { discoverGateway = orig })
+
+ hook := test.NewGlobal()
+ origLevel := log.GetLevel()
+ log.SetLevel(log.DebugLevel)
+ t.Cleanup(func() {
+ hook.Reset()
+ log.SetLevel(origLevel)
+ })
+
+ return hook
+}
+
+func findEntry(hook *test.Hook, substr string) *log.Entry {
+ for _, entry := range hook.AllEntries() {
+ if strings.Contains(entry.Message, substr) {
+ return entry
+ }
+ }
+ return nil
+}
diff --git a/client/internal/portforward/state.go b/client/internal/portforward/state.go
index b1315cdc0..a21368e58 100644
--- a/client/internal/portforward/state.go
+++ b/client/internal/portforward/state.go
@@ -4,27 +4,94 @@ package portforward
import (
"context"
+ "errors"
"fmt"
+ "time"
- "github.com/libp2p/go-nat"
+ "github.com/netbirdio/go-nat"
+ "github.com/netbirdio/go-nat/pcp"
log "github.com/sirupsen/logrus"
-
- "github.com/netbirdio/netbird/client/internal/portforward/pcp"
)
// discoverGateway is the function used for NAT gateway discovery.
// It can be replaced in tests to avoid real network operations.
-// Tries PCP first, then falls back to NAT-PMP/UPnP.
var discoverGateway = defaultDiscoverGateway
-func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) {
- pcpGateway, err := pcp.DiscoverPCP(ctx)
- if err == nil {
- return pcpGateway, nil
- }
- log.Debugf("PCP discovery failed: %v, trying NAT-PMP/UPnP", err)
+// pinholeDiscoveryTimeout is the slice of the discovery budget held back for
+// the IPv6 pinhole probe.
+//
+// Sizing it is coarser than it looks: PCP retransmits on a 3s socket timeout
+// and a 3s first backoff, so a second attempt needs about 9s. Anything from
+// roughly 1s to 8s therefore buys exactly one attempt, and this only sets how
+// long that attempt waits. A PCP server sits on the local link and answers in
+// milliseconds, so 3s is margin rather than need, and the rest is left to
+// gateway discovery, whose multicast SSDP search alone takes 5s. A probe lost
+// to a dropped packet is retried by the next discovery round.
+//
+// It is a variable so tests can shorten it.
+var pinholeDiscoveryTimeout = 3 * time.Second
- return nat.DiscoverGateway(ctx)
+// Discovery entry points, as variables so tests can drive the fallback without
+// touching the network.
+var (
+ discoverNATGateway = nat.DiscoverGateway
+
+ discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) {
+ pinhole, err := pcp.DiscoverPCP(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return pinhole, nil
+ }
+)
+
+// defaultDiscoverGateway finds a gateway that can make the WireGuard port
+// reachable. DiscoverGateway prefers PCP for IPv4, races UPnP and NAT-PMP
+// behind it, and attaches an IPv6 pinhole independently of which IPv4 protocol
+// wins.
+//
+// It reports no gateway on a network offering only IPv6, having no IPv4 mapping
+// to attach a pinhole to. Such a network still needs one: there is no
+// translation to traverse, but the router drops inbound IPv6 until something
+// opens it. Fall back to PCP alone, which yields a gateway holding just the
+// pinhole.
+func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) {
+ gatewayCtx, cancel := reserveForPinhole(ctx)
+ defer cancel()
+
+ gateway, err := discoverNATGateway(gatewayCtx)
+ if err == nil {
+ return gateway, nil
+ }
+ if !errors.Is(err, nat.ErrNoNATFound) {
+ return nil, err
+ }
+
+ pinhole, pinholeErr := discoverPCPPinhole(ctx)
+ if pinholeErr != nil {
+ log.Debugf("no IPv6 pinhole after %v: %v", err, pinholeErr)
+ return nil, err
+ }
+
+ log.Infof("no IPv4 gateway, continuing with an IPv6 pinhole only")
+ return pinhole, nil
+}
+
+// reserveForPinhole shortens ctx so that a pinhole probe still has time to run
+// afterwards. Finding nothing takes gateway discovery everything it is given,
+// so on the unshortened context the probe would start already expired. A budget
+// too small to divide is left to gateway discovery, which is the likelier win.
+func reserveForPinhole(ctx context.Context) (context.Context, context.CancelFunc) {
+ deadline, ok := ctx.Deadline()
+ if !ok {
+ return context.WithCancel(ctx)
+ }
+
+ remaining := time.Until(deadline)
+ if remaining <= pinholeDiscoveryTimeout {
+ return context.WithCancel(ctx)
+ }
+ return context.WithTimeout(ctx, remaining-pinholeDiscoveryTimeout)
}
// State is persisted only for crash recovery cleanup
diff --git a/client/internal/portforward/state_test.go b/client/internal/portforward/state_test.go
new file mode 100644
index 000000000..8a584eecb
--- /dev/null
+++ b/client/internal/portforward/state_test.go
@@ -0,0 +1,140 @@
+//go:build !js
+
+package portforward
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/netbirdio/go-nat"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// stubDiscovery replaces both discovery entry points for the duration of a
+// test. gatewayDelay simulates gateway discovery spending everything it is
+// given before reporting that it found nothing.
+func stubDiscovery(t *testing.T, gateway nat.NAT, gatewayErr error, gatewayDelay time.Duration, pinhole nat.NAT, pinholeErr error) {
+ t.Helper()
+
+ origGateway, origPinhole := discoverNATGateway, discoverPCPPinhole
+ discoverNATGateway = func(ctx context.Context) (nat.NAT, error) {
+ if gatewayDelay > 0 {
+ select {
+ case <-time.After(gatewayDelay):
+ case <-ctx.Done():
+ }
+ }
+ return gateway, gatewayErr
+ }
+ discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ return pinhole, pinholeErr
+ }
+
+ t.Cleanup(func() { discoverNATGateway, discoverPCPPinhole = origGateway, origPinhole })
+}
+
+func TestDefaultDiscoverGateway(t *testing.T) {
+ ipv4Gateway := &mockNAT{natType: "PCP+PCPv6"}
+ ipv6Pinhole := &mockNAT{natType: "PCP"}
+ otherErr := errors.New("routing table unavailable")
+
+ t.Run("an IPv4 gateway is used as is", func(t *testing.T) {
+ stubDiscovery(t, ipv4Gateway, nil, 0, ipv6Pinhole, nil)
+
+ got, err := defaultDiscoverGateway(context.Background())
+
+ require.NoError(t, err)
+ assert.Same(t, ipv4Gateway, got)
+ })
+
+ t.Run("no IPv4 gateway still opens an IPv6 pinhole", func(t *testing.T) {
+ stubDiscovery(t, nil, nat.ErrNoNATFound, 0, ipv6Pinhole, nil)
+
+ got, err := defaultDiscoverGateway(context.Background())
+
+ require.NoError(t, err)
+ assert.Same(t, ipv6Pinhole, got)
+ })
+
+ t.Run("no gateway and no pinhole reports the original failure", func(t *testing.T) {
+ stubDiscovery(t, nil, nat.ErrNoNATFound, 0, nil, errors.New("no IPv6 route"))
+
+ got, err := defaultDiscoverGateway(context.Background())
+
+ assert.Nil(t, got)
+ assert.ErrorIs(t, err, nat.ErrNoNATFound, "the pinhole failure must not mask why no gateway was found")
+ })
+
+ t.Run("a failure other than no-gateway is reported as is", func(t *testing.T) {
+ stubDiscovery(t, nil, otherErr, 0, ipv6Pinhole, nil)
+
+ got, err := defaultDiscoverGateway(context.Background())
+
+ assert.Nil(t, got)
+ assert.ErrorIs(t, err, otherErr)
+ })
+
+ t.Run("the pinhole survives gateway discovery using its whole budget", func(t *testing.T) {
+ // On one shared context the probe would start already expired, which is
+ // how this failed against a real gateway.
+ reserve := 50 * time.Millisecond
+ origReserve := pinholeDiscoveryTimeout
+ pinholeDiscoveryTimeout = reserve
+ t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve })
+
+ budget := 4 * reserve
+ ctx, cancel := context.WithTimeout(context.Background(), budget)
+ defer cancel()
+
+ stubDiscovery(t, nil, nat.ErrNoNATFound, budget, ipv6Pinhole, nil)
+
+ got, err := defaultDiscoverGateway(ctx)
+
+ require.NoError(t, err)
+ assert.Same(t, ipv6Pinhole, got)
+ })
+}
+
+func TestReserveForPinhole(t *testing.T) {
+ origReserve := pinholeDiscoveryTimeout
+ pinholeDiscoveryTimeout = time.Second
+ t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve })
+
+ t.Run("a budget is divided", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ gatewayCtx, cancelGateway := reserveForPinhole(ctx)
+ defer cancelGateway()
+
+ deadline, ok := gatewayCtx.Deadline()
+ require.True(t, ok)
+ assert.InDelta(t, 9*time.Second, time.Until(deadline), float64(500*time.Millisecond))
+ })
+
+ t.Run("a budget too small to divide is left whole", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+
+ gatewayCtx, cancelGateway := reserveForPinhole(ctx)
+ defer cancelGateway()
+
+ deadline, ok := gatewayCtx.Deadline()
+ require.True(t, ok)
+ assert.InDelta(t, 500*time.Millisecond, time.Until(deadline), float64(100*time.Millisecond))
+ })
+
+ t.Run("no deadline stays unbounded", func(t *testing.T) {
+ gatewayCtx, cancelGateway := reserveForPinhole(context.Background())
+ defer cancelGateway()
+
+ _, ok := gatewayCtx.Deadline()
+ assert.False(t, ok)
+ })
+}
diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go
index a77f0ff32..e1668238e 100644
--- a/client/internal/profilemanager/config.go
+++ b/client/internal/profilemanager/config.go
@@ -96,13 +96,12 @@ type ConfigInput struct {
BlockLANAccess *bool
BlockInbound *bool
DisableIPv6 *bool
+ SyncMessageVersion *int
DisableNotifications *bool
DNSLabels domain.List
- LazyConnectionEnabled *bool
-
MTU *uint16
}
@@ -139,6 +138,7 @@ type Config struct {
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
+ SyncMessageVersion *int
DisableNotifications *bool
@@ -180,7 +180,9 @@ type Config struct {
ClientCertKeyPair *tls.Certificate `json:"-"`
- LazyConnectionEnabled bool
+ // LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/"").
+ // Runtime-only: re-derived from MDM policy on each load, never persisted.
+ LazyConnection string `json:"-"`
MTU uint16
@@ -386,7 +388,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.NetworkMonitor != nil && input.NetworkMonitor != config.NetworkMonitor {
+ if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) {
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
config.NetworkMonitor = input.NetworkMonitor
updated = true
@@ -433,7 +435,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed {
+ if input.ServerSSHAllowed != nil && (config.ServerSSHAllowed == nil || *input.ServerSSHAllowed != *config.ServerSSHAllowed) {
if *input.ServerSSHAllowed {
log.Infof("enabling SSH server")
} else {
@@ -454,7 +456,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot {
+ if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) {
if *input.EnableSSHRoot {
log.Infof("enabling SSH root login")
} else {
@@ -464,7 +466,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.EnableSSHSFTP != nil && input.EnableSSHSFTP != config.EnableSSHSFTP {
+ if input.EnableSSHSFTP != nil && (config.EnableSSHSFTP == nil || *input.EnableSSHSFTP != *config.EnableSSHSFTP) {
if *input.EnableSSHSFTP {
log.Infof("enabling SSH SFTP subsystem")
} else {
@@ -474,7 +476,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.EnableSSHLocalPortForwarding != nil && input.EnableSSHLocalPortForwarding != config.EnableSSHLocalPortForwarding {
+ if input.EnableSSHLocalPortForwarding != nil && (config.EnableSSHLocalPortForwarding == nil || *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding) {
if *input.EnableSSHLocalPortForwarding {
log.Infof("enabling SSH local port forwarding")
} else {
@@ -484,7 +486,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.EnableSSHRemotePortForwarding != nil && input.EnableSSHRemotePortForwarding != config.EnableSSHRemotePortForwarding {
+ if input.EnableSSHRemotePortForwarding != nil && (config.EnableSSHRemotePortForwarding == nil || *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding) {
if *input.EnableSSHRemotePortForwarding {
log.Infof("enabling SSH remote port forwarding")
} else {
@@ -494,7 +496,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.DisableSSHAuth != nil && input.DisableSSHAuth != config.DisableSSHAuth {
+ if input.DisableSSHAuth != nil && (config.DisableSSHAuth == nil || *input.DisableSSHAuth != *config.DisableSSHAuth) {
if *input.DisableSSHAuth {
log.Infof("disabling SSH authentication")
} else {
@@ -504,7 +506,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.SSHJWTCacheTTL != nil && input.SSHJWTCacheTTL != config.SSHJWTCacheTTL {
+ if input.SSHJWTCacheTTL != nil && (config.SSHJWTCacheTTL == nil || *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL) {
log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL)
config.SSHJWTCacheTTL = input.SSHJWTCacheTTL
updated = true
@@ -587,7 +589,13 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.DisableNotifications != nil && input.DisableNotifications != config.DisableNotifications {
+ if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion {
+ log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion)
+ *config.SyncMessageVersion = *input.SyncMessageVersion
+ updated = true
+ }
+
+ if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) {
if *input.DisableNotifications {
log.Infof("disabling notifications")
} else {
@@ -632,12 +640,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.LazyConnectionEnabled != nil && *input.LazyConnectionEnabled != config.LazyConnectionEnabled {
- log.Infof("switching lazy connection to %t", *input.LazyConnectionEnabled)
- config.LazyConnectionEnabled = *input.LazyConnectionEnabled
- updated = true
- }
-
if input.MTU != nil && *input.MTU != config.MTU {
log.Infof("updating MTU to %d (old value %d)", *input.MTU, config.MTU)
config.MTU = *input.MTU
@@ -728,6 +730,15 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v)
}
}
+
+ if v, ok := policy.GetBool(mdm.KeyLazyConnection); ok {
+ state := "off"
+ if v {
+ state = "on"
+ }
+ config.LazyConnection = state
+ logApplied(mdm.KeyLazyConnection, state)
+ }
}
// parseURL parses and validates the URL for the named service. The URL
@@ -735,6 +746,13 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
// appended for https or ":80" for http. The serviceName parameter is
// used to contextualise error messages. On success returns the parsed
// *url.URL; on failure returns a non-nil error.
+// ParseServiceURL normalises a service URL exactly as the config layer does when
+// it stores one, so callers comparing a requested URL against a stored one do not
+// have to reimplement the scheme validation and default-port handling.
+func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) {
+ return parseURL(serviceName, serviceURL)
+}
+
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
if err != nil {
diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go
index 6a201235e..c6a688ab2 100644
--- a/client/internal/profilemanager/config_mdm_test.go
+++ b/client/internal/profilemanager/config_mdm_test.go
@@ -130,6 +130,37 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
}
+func TestApply_MDMLazyConnection(t *testing.T) {
+ cases := []struct {
+ name string
+ raw any
+ want string
+ }{
+ {"native true", true, "on"},
+ {"native false", false, "off"},
+ {"string on", "on", "on"},
+ {"string off", "off", "off"},
+ {"string yes", "yes", "on"},
+ {"string no", "no", "off"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyLazyConnection: c.raw,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.Equal(t, c.want, cfg.LazyConnection)
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
+ })
+ }
+}
+
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
const maskSentinel = "**********"
diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go
index 5216f2423..736ff3412 100644
--- a/client/internal/profilemanager/config_test.go
+++ b/client/internal/profilemanager/config_test.go
@@ -242,6 +242,35 @@ func TestWireguardPortDefaultVsExplicit(t *testing.T) {
}
}
+func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) {
+ // Configs written before ServerSSHAllowed was introduced lack the field and
+ // unmarshal to nil. Supplying the SSH server flag on top of such a config must
+ // apply the value instead of panicking on a nil pointer dereference.
+ tests := []struct {
+ name string
+ input *bool
+ want bool
+ }{
+ {"enable", util.True(), true},
+ {"disable", util.False(), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600))
+
+ config, err := UpdateConfig(ConfigInput{
+ ConfigPath: configPath,
+ ServerSSHAllowed: tt.input,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, config.ServerSSHAllowed, "ServerSSHAllowed should be set from input")
+ assert.Equal(t, tt.want, *config.ServerSSHAllowed)
+ })
+ }
+}
+
func TestUpdateOldManagementURL(t *testing.T) {
origProber := newMgmProber
newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) {
diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go
index 7d5b1674c..ec287f01a 100644
--- a/client/internal/profilemanager/service.go
+++ b/client/internal/profilemanager/service.go
@@ -11,6 +11,7 @@ import (
"runtime"
"sort"
"strings"
+ "syscall"
log "github.com/sirupsen/logrus"
@@ -444,7 +445,11 @@ func (s *ServiceManager) GetStatePath() string {
activeProf, err := s.GetActiveProfileState()
if err != nil {
- log.Warnf("failed to get active profile state: %v", err)
+ if errors.Is(err, syscall.ENOSYS) {
+ log.Debugf("active profile state unavailable on this platform: %v", err)
+ } else {
+ log.Warnf("failed to get active profile state: %v", err)
+ }
return defaultStatePath
}
diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go
index 1bf3318af..ddb5dd056 100644
--- a/client/internal/profilemanager/state.go
+++ b/client/internal/profilemanager/state.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "os"
"path/filepath"
"github.com/netbirdio/netbird/util"
@@ -44,12 +45,35 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
return &state, nil
}
-func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
+// SetProfileState writes the state file of the profile identified by id. Prefer
+// it over SetActiveProfileState whenever the caller knows which profile the data
+// belongs to: an SSO login spans seconds of user interaction, and the active
+// profile can change during it, which would file the account email under
+// whichever profile happened to be active when the flow returned.
+func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
configDir, err := getConfigDir()
if err != nil {
return fmt.Errorf("get config directory: %w", err)
}
+ if id == "" {
+ return fmt.Errorf("empty profile ID")
+ }
+ if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid profile ID: %q", id)
+ }
+
+ stateFile := filepath.Join(configDir, id.String()+".state.json")
+ if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil {
+ return fmt.Errorf("write profile state: %w", err)
+ }
+
+ return nil
+}
+
+// SetActiveProfileState writes the state file of whichever profile is active at
+// call time. Use SetProfileState when the target profile is known.
+func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
activeProf, err := pm.GetActiveProfile()
if err != nil {
if errors.Is(err, ErrNoActiveProfile) {
@@ -58,15 +82,24 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return fmt.Errorf("get active profile: %w", err)
}
- id := activeProf.ID
- if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
- return fmt.Errorf("invalid active profile ID: %q", id)
+ return pm.SetProfileState(activeProf.ID, state)
+}
+
+// RemoveProfileState deletes the per-profile state file (which holds the
+// account email used for the SSO login hint and the UI display). Called after
+// profile removal; logout keeps the file so the next login can pass the email
+// as the login_hint. The state file only stores the email, so deleting it is
+// equivalent to clearing it; the next SSO login recreates it. A missing file
+// is not an error.
+func (pm *ProfileManager) RemoveProfileState(profileName string) error {
+ configDir, err := getConfigDir()
+ if err != nil {
+ return fmt.Errorf("get config directory: %w", err)
}
- stateFile := filepath.Join(configDir, id.String()+".state.json")
- err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
- if err != nil {
- return fmt.Errorf("write profile state: %w", err)
+ stateFile := filepath.Join(configDir, profileName+".state.json")
+ if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("remove profile state: %w", err)
}
return nil
diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go
index 903753753..21dd751df 100644
--- a/client/internal/rosenpass/manager.go
+++ b/client/internal/rosenpass/manager.go
@@ -39,6 +39,7 @@ type rpServer interface {
type Manager struct {
ifaceName string
+ localWgKey wgtypes.Key
spk []byte
ssk []byte
rpKeyHash string
@@ -51,8 +52,9 @@ type Manager struct {
wgIface PresharedKeySetter
}
-// NewManager creates a new Rosenpass manager
-func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) {
+// NewManager creates a new Rosenpass manager. localWgKey is the local
+// WireGuard public key, used to derive the per-peer rendezvous key.
+func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) {
public, secret, err := rp.GenerateKeyPair()
if err != nil {
return nil, err
@@ -62,6 +64,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
return &Manager{
ifaceName: wgIfaceName,
+ localWgKey: localWgKey,
rpKeyHash: rpKeyHash,
spk: public,
ssk: secret,
@@ -73,7 +76,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
// nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
// replace it with a fresh handler on each Run() to clear stale peer
// state from previous engine sessions.
- rpWgHandler: NewNetbirdHandler(),
+ rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey),
lock: sync.Mutex{},
}, nil
}
@@ -161,7 +164,7 @@ func (m *Manager) generateConfig() (rp.Config, error) {
cfg.Peers = []rp.PeerConfig{}
m.lock.Lock()
- m.rpWgHandler = NewNetbirdHandler()
+ m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey)
if m.wgIface != nil {
m.rpWgHandler.SetInterface(m.wgIface)
}
diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go
index d74960d0d..69e18ac88 100644
--- a/client/internal/rosenpass/manager_test.go
+++ b/client/internal/rosenpass/manager_test.go
@@ -85,7 +85,7 @@ func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
ssk: make([]byte, 32),
rpKeyHash: "test-hash",
rpPeerIDs: make(map[string]*rp.PeerID),
- rpWgHandler: NewNetbirdHandler(),
+ rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}),
server: mock,
}
}
@@ -255,7 +255,7 @@ func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
// issue #4341 cannot occur in the window between NewManager and Run().
func TestNewManager_PreInitializesHandler(t *testing.T) {
psk := wgtypes.Key{}
- m, err := NewManager(&psk, "wt0")
+ m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01})
require.NoError(t, err)
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
}
@@ -329,10 +329,10 @@ func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing
require.False(t, m.IsPresharedKeyInitialized(wgKey))
}
-// --- NetbirdHandler.outputKey ----------------------------------------------
+// --- NetbirdHandler.applyKey ----------------------------------------------
-func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
- h := NewNetbirdHandler()
+func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -348,8 +348,8 @@ func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
}
-func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
- h := NewNetbirdHandler()
+func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -364,8 +364,8 @@ func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
}
-func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
- h := NewNetbirdHandler()
+func TestHandler_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) {
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
// no SetInterface — iface remains nil
pid := rp.PeerID{0x03}
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
@@ -374,8 +374,8 @@ func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
h.HandshakeCompleted(pid, rp.Key{})
}
-func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
- h := NewNetbirdHandler()
+func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) {
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -384,7 +384,7 @@ func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
}
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
- h := NewNetbirdHandler()
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -398,7 +398,7 @@ func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
}
func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
- h := NewNetbirdHandler()
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
pid := rp.PeerID{0x05}
wgKey := wgtypes.Key{0xEE}
h.AddPeer(pid, "wt0", rp.Key(wgKey))
diff --git a/client/internal/rosenpass/netbird_handler.go b/client/internal/rosenpass/netbird_handler.go
index 9de2409ef..672650ca7 100644
--- a/client/internal/rosenpass/netbird_handler.go
+++ b/client/internal/rosenpass/netbird_handler.go
@@ -18,19 +18,34 @@ type PresharedKeySetter interface {
type wireGuardPeer struct {
Interface string
PublicKey rp.Key
+ // initialized is true once a completed exchange has set a
+ // Rosenpass-managed PSK for this peer.
+ initialized bool
+ // chainKey is the key output by the last completed exchange, advanced by
+ // one ratchet step on expiry. Nil until the first exchange completes and
+ // after the peer has fallen back to the rendezvous key.
+ chainKey *wgtypes.Key
+ // expiries counts failed renewals since the last completed exchange.
+ expiries int
}
type NetbirdHandler struct {
- mu sync.Mutex
- iface PresharedKeySetter
- peers map[rp.PeerID]wireGuardPeer
- initializedPeers map[rp.PeerID]bool
+ mu sync.Mutex
+ iface PresharedKeySetter
+ // preSharedKey is the account-level preshared key, used as the rendezvous
+ // key when set. Nil means the deterministic seed key is used instead.
+ preSharedKey *[32]byte
+ // localWgKey is the local WireGuard public key, one of the two inputs to
+ // the deterministic seed key.
+ localWgKey wgtypes.Key
+ peers map[rp.PeerID]*wireGuardPeer
}
-func NewNetbirdHandler() *NetbirdHandler {
+func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler {
return &NetbirdHandler{
- peers: map[rp.PeerID]wireGuardPeer{},
- initializedPeers: map[rp.PeerID]bool{},
+ preSharedKey: preSharedKey,
+ localWgKey: localWgKey,
+ peers: map[rp.PeerID]*wireGuardPeer{},
}
}
@@ -42,10 +57,16 @@ func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) {
h.iface = iface
}
+// AddPeer registers a peer with the handler. Re-adding a known peer (every
+// reconnection does) keeps its key recovery state.
func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) {
h.mu.Lock()
defer h.mu.Unlock()
- h.peers[pid] = wireGuardPeer{
+ if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk {
+ existing.Interface = intf
+ return
+ }
+ h.peers[pid] = &wireGuardPeer{
Interface: intf,
PublicKey: pk,
}
@@ -55,7 +76,6 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.peers, pid)
- delete(h.initializedPeers, pid)
}
// IsPeerInitialized returns true if Rosenpass has completed a handshake
@@ -63,50 +83,120 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool {
h.mu.Lock()
defer h.mu.Unlock()
- return h.initializedPeers[pid]
+ peer, ok := h.peers[pid]
+ return ok && peer.initialized
}
+// HandshakeCompleted programs the freshly exchanged output key and resets the
+// peer's key recovery state.
func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) {
- h.outputKey(rp.KeyOutputReasonStale, pid, key)
-}
+ psk := wgtypes.Key(key)
-func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
- key, _ := rp.GeneratePresharedKey()
- h.outputKey(rp.KeyOutputReasonStale, pid, key)
-}
-
-func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) {
h.mu.Lock()
- iface := h.iface
- wg, ok := h.peers[pid]
- isInitialized := h.initializedPeers[pid]
- h.mu.Unlock()
+ defer h.mu.Unlock()
- if iface == nil {
- log.Warn("rosenpass: interface not set, cannot update preshared key")
+ peer, ok := h.peers[pid]
+ if !ok {
return
}
+ if peer.expiries > 0 {
+ log.Infof("rosenpass exchange completed for peer %s after %d expired renewals", wgtypes.Key(peer.PublicKey), peer.expiries)
+ }
+ // chainKey tracks the shared exchange output regardless of the local write
+ // outcome, so both ends still converge on the next expiry.
+ peer.chainKey = &psk
+ peer.expiries = 0
+ if !h.applyKeyLocked(pid, psk, peer.initialized) {
+ return
+ }
+ peer.initialized = true
+}
+// HandshakeExpired replaces the expired key. The renewal exchange runs over
+// the tunnel keyed by the PSK itself, so the replacement must be derivable on
+// both ends without communication: the first expiry ratchets the last shared
+// key forward, repeated expiries (and expiries without a completed exchange)
+// fall back to the rendezvous key and drop the peer out of the initialized
+// state so connection reconfigurations reprogram the rendezvous key as well.
+func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ peer, ok := h.peers[pid]
if !ok {
return
}
- peerKey := wgtypes.Key(wg.PublicKey).String()
- pskKey := wgtypes.Key(psk)
+ peer.expiries++
- // Use updateOnly=true for later rotations (peer already has Rosenpass PSK)
- // Use updateOnly=false for first rotation (peer has original/empty PSK)
- if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil {
+ var psk wgtypes.Key
+ if peer.chainKey != nil && peer.expiries == 1 {
+ log.Infof("rosenpass key for peer %s expired without renewal, advancing to ratcheted key", wgtypes.Key(peer.PublicKey))
+ psk = RatchetKey(*peer.chainKey)
+ peer.chainKey = &psk
+ } else {
+ rendezvous, err := h.rendezvousKey(peer)
+ if err != nil {
+ // Fail closed: without a rendezvous key the expired key must
+ // still be rotated out, even if the replacement is unusable.
+ log.Errorf("failed to derive rendezvous key, replacing expired key with a random one: %v", err)
+ h.applyRandomKeyLocked(pid)
+ return
+ }
+ log.Warnf("rosenpass key for peer %s expired %d times without renewal, falling back to the rendezvous key", wgtypes.Key(peer.PublicKey), peer.expiries)
+ psk = rendezvous
+ peer.chainKey = nil
+ peer.initialized = false
+ }
+
+ h.applyKeyLocked(pid, psk, true)
+}
+
+// rendezvousKey returns the key both ends converge on without communication:
+// the account-level preshared key when configured, the deterministic seed key
+// otherwise. It mirrors the key that peer connections program when Rosenpass
+// does not manage the peer yet.
+func (h *NetbirdHandler) rendezvousKey(peer *wireGuardPeer) (wgtypes.Key, error) {
+ if h.preSharedKey != nil {
+ return *h.preSharedKey, nil
+ }
+
+ seed, err := DeterministicSeedKey(h.localWgKey.String(), wgtypes.Key(peer.PublicKey).String())
+ if err != nil {
+ return wgtypes.Key{}, err
+ }
+ return *seed, nil
+}
+
+// applyKeyLocked writes the preshared key for the peer to the WireGuard
+// interface and reports whether the write succeeded. Callers must hold h.mu
+// for the whole state-mutation-plus-write so that a concurrent completion and
+// expiry cannot reorder their writes relative to the in-memory chain key.
+func (h *NetbirdHandler) applyKeyLocked(pid rp.PeerID, psk wgtypes.Key, updateOnly bool) bool {
+ peer, ok := h.peers[pid]
+ if !ok {
+ return false
+ }
+
+ if h.iface == nil {
+ log.Warn("rosenpass: interface not set, cannot update preshared key")
+ return false
+ }
+
+ peerKey := wgtypes.Key(peer.PublicKey).String()
+ if err := h.iface.SetPresharedKey(peerKey, psk, updateOnly); err != nil {
log.Errorf("Failed to apply rosenpass key: %v", err)
+ return false
+ }
+
+ return true
+}
+
+func (h *NetbirdHandler) applyRandomKeyLocked(pid rp.PeerID) {
+ key, err := rp.GeneratePresharedKey()
+ if err != nil {
+ log.Errorf("failed to generate random preshared key: %v", err)
return
}
-
- // Mark peer as isInitialized after the successful first rotation
- if !isInitialized {
- h.mu.Lock()
- if _, exists := h.peers[pid]; exists {
- h.initializedPeers[pid] = true
- }
- h.mu.Unlock()
- }
+ h.applyKeyLocked(pid, wgtypes.Key(key), true)
}
diff --git a/client/internal/rosenpass/netbird_handler_test.go b/client/internal/rosenpass/netbird_handler_test.go
new file mode 100644
index 000000000..9d91ba93b
--- /dev/null
+++ b/client/internal/rosenpass/netbird_handler_test.go
@@ -0,0 +1,250 @@
+package rosenpass
+
+import (
+ "testing"
+
+ rp "cunicu.li/go-rosenpass"
+ "github.com/stretchr/testify/require"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+// handlerTestLink wires two NetbirdHandlers as the two ends of a single
+// tunnel: handler A manages the rosenpass peer B and vice versa, the way two
+// NetBird clients see each other.
+type handlerTestLink struct {
+ handlerA, handlerB *NetbirdHandler
+ ifaceA, ifaceB *mockIface
+ pidA, pidB rp.PeerID
+ wgKeyA, wgKeyB wgtypes.Key
+}
+
+func newHandlerTestLink(t *testing.T, preSharedKey *[32]byte) *handlerTestLink {
+ t.Helper()
+
+ link := &handlerTestLink{
+ ifaceA: &mockIface{},
+ ifaceB: &mockIface{},
+ }
+ link.pidA[0] = 0xaa
+ link.pidB[0] = 0xbb
+ link.wgKeyA[31] = 1
+ link.wgKeyB[31] = 2
+
+ link.handlerA = NewNetbirdHandler(preSharedKey, link.wgKeyA)
+ link.handlerB = NewNetbirdHandler(preSharedKey, link.wgKeyB)
+
+ link.handlerA.SetInterface(link.ifaceA)
+ link.handlerB.SetInterface(link.ifaceB)
+
+ link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB))
+ link.handlerB.AddPeer(link.pidA, "wt0", rp.Key(link.wgKeyA))
+
+ return link
+}
+
+// complete simulates a completed rosenpass exchange: both ends derive the
+// same output key.
+func (l *handlerTestLink) complete(osk rp.Key) {
+ l.handlerA.HandshakeCompleted(l.pidB, osk)
+ l.handlerB.HandshakeCompleted(l.pidA, osk)
+}
+
+// expire simulates a failed key renewal on both ends.
+func (l *handlerTestLink) expire() {
+ l.handlerA.HandshakeExpired(l.pidB)
+ l.handlerB.HandshakeExpired(l.pidA)
+}
+
+func lastPSK(t *testing.T, m *mockIface) wgtypes.Key {
+ t.Helper()
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ require.NotEmpty(t, m.calls, "expected at least one SetPresharedKey call")
+ return m.calls[len(m.calls)-1].psk
+}
+
+func TestHandshakeCompleted_SetsKeyAndInitializes(t *testing.T) {
+ link := newHandlerTestLink(t, nil)
+
+ var osk rp.Key
+ osk[0] = 0x42
+ link.complete(osk)
+
+ require.Equal(t, wgtypes.Key(osk), lastPSK(t, link.ifaceA), "completed exchange must program the osk")
+ require.False(t, link.ifaceA.calls[0].updateOnly, "first rotation must not be update-only")
+ require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized after first completed exchange")
+
+ link.complete(osk)
+ require.True(t, link.ifaceA.calls[1].updateOnly, "later rotations must be update-only")
+}
+
+// TestHandshakeExpired_BothSidesConverge encodes the core recovery invariant:
+// rosenpass renewals run over the tunnel that the PSK itself keys, so when a
+// renewal fails on both ends, both ends must fall back to the same key or the
+// tunnel can never handshake again.
+func TestHandshakeExpired_BothSidesConverge(t *testing.T) {
+ link := newHandlerTestLink(t, nil)
+
+ var osk rp.Key
+ osk[0] = 0x42
+ link.complete(osk)
+
+ link.expire()
+ keyA := lastPSK(t, link.ifaceA)
+ keyB := lastPSK(t, link.ifaceB)
+ require.NotEqual(t, wgtypes.Key(osk), keyA, "expired key must be rotated out")
+ require.Equal(t, keyA, keyB, "both ends must converge on the same key after expiry")
+
+ link.expire()
+ require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
+ "both ends must still converge after repeated expiries")
+}
+
+// TestHandshakeExpired_ExpiryWithoutCompletionConverges covers the bootstrap
+// case: the initial exchange never completed (the tunnel ran on the rendezvous
+// key), so an expiry must not replace the working key with an unrecoverable
+// one on either end.
+func TestHandshakeExpired_ExpiryWithoutCompletionConverges(t *testing.T) {
+ link := newHandlerTestLink(t, nil)
+
+ link.expire()
+ require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
+ "both ends must converge when the exchange never completed")
+}
+
+// TestHandshakeExpired_RepeatedExpiryClearsInitialized: once renewals keep
+// failing, the peer must drop out of the initialized state so the next
+// connection reconfiguration reprograms the rendezvous key instead of
+// preserving a poisoned rosenpass-managed key.
+func TestHandshakeExpired_RepeatedExpiryClearsInitialized(t *testing.T) {
+ link := newHandlerTestLink(t, nil)
+
+ var osk rp.Key
+ osk[0] = 0x42
+ link.complete(osk)
+
+ link.expire()
+ link.expire()
+
+ require.False(t, link.handlerA.IsPeerInitialized(link.pidB),
+ "repeated expiries must clear the initialized state")
+ require.False(t, link.handlerB.IsPeerInitialized(link.pidA),
+ "repeated expiries must clear the initialized state")
+}
+
+// TestHandshakeCompleted_AfterExpiryRecovers: a completed exchange after a
+// desync must fully reset the recovery state.
+func TestHandshakeCompleted_AfterExpiryRecovers(t *testing.T) {
+ link := newHandlerTestLink(t, nil)
+
+ var osk1, osk2 rp.Key
+ osk1[0] = 1
+ osk2[0] = 2
+
+ link.complete(osk1)
+ link.expire()
+ link.expire()
+
+ link.complete(osk2)
+ require.Equal(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "new exchange must program the fresh osk")
+ require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized again after recovery")
+
+ link.expire()
+ require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
+ "recovered link must converge again on the next expiry")
+ require.NotEqual(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "expired key must be rotated out")
+}
+
+// TestHandshakeExpired_FirstExpiryRatchetsLastKey: the first expiry must
+// derive the replacement from the last shared key, so an attacker who only
+// blocks the renewal exchange gains nothing over the previous key.
+func TestHandshakeExpired_FirstExpiryRatchetsLastKey(t *testing.T) {
+ link := newHandlerTestLink(t, nil)
+
+ var osk rp.Key
+ osk[0] = 0x42
+ link.complete(osk)
+
+ link.expire()
+ require.Equal(t, RatchetKey(wgtypes.Key(osk)), lastPSK(t, link.ifaceA),
+ "first expiry must program the ratcheted key")
+ require.True(t, link.handlerA.IsPeerInitialized(link.pidB),
+ "ratchet step must keep the peer initialized so reconfigurations preserve the key")
+}
+
+// TestHandshakeExpired_RepeatedExpiryFallsBackToSeed: once the ratchet key
+// also fails, both ends must land on the same key that peer connections
+// program for uninitialized peers, so a reconnect completes the recovery.
+func TestHandshakeExpired_RepeatedExpiryFallsBackToSeed(t *testing.T) {
+ link := newHandlerTestLink(t, nil)
+
+ var osk rp.Key
+ osk[0] = 0x42
+ link.complete(osk)
+
+ link.expire()
+ link.expire()
+
+ seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String())
+ require.NoError(t, err)
+ require.Equal(t, *seed, lastPSK(t, link.ifaceA), "repeated expiry must fall back to the seed key")
+ require.Equal(t, *seed, lastPSK(t, link.ifaceB), "repeated expiry must fall back to the seed key")
+}
+
+// TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous: with an account-level
+// preshared key configured, the fallback must be that key, matching what peer
+// connections program for uninitialized peers.
+func TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous(t *testing.T) {
+ psk := &[32]byte{0x77}
+ link := newHandlerTestLink(t, psk)
+
+ var osk rp.Key
+ osk[0] = 0x42
+ link.complete(osk)
+
+ link.expire()
+ link.expire()
+
+ require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceA),
+ "fallback must be the configured preshared key")
+ require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceB),
+ "fallback must be the configured preshared key on both ends")
+}
+
+// TestHandshakeExpired_ExpiryWritesAreUpdateOnly: expiry replacements must
+// never create a WireGuard peer that connection management has removed.
+func TestHandshakeExpired_ExpiryWritesAreUpdateOnly(t *testing.T) {
+ link := newHandlerTestLink(t, nil)
+
+ var osk rp.Key
+ osk[0] = 0x42
+ link.complete(osk)
+
+ link.expire()
+ link.expire()
+
+ for _, call := range link.ifaceA.calls[1:] {
+ require.True(t, call.updateOnly, "expiry writes must be update-only")
+ }
+}
+
+// TestAddPeer_ReAddKeepsRecoveryState: reconnections re-add the peer on every
+// OnConnected; that must not reset the expiry chain state.
+func TestAddPeer_ReAddKeepsRecoveryState(t *testing.T) {
+ link := newHandlerTestLink(t, nil)
+
+ var osk rp.Key
+ osk[0] = 0x42
+ link.complete(osk)
+ link.expire()
+
+ link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB))
+ require.True(t, link.handlerA.IsPeerInitialized(link.pidB),
+ "re-adding a known peer must keep its state")
+
+ link.expire()
+ seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String())
+ require.NoError(t, err)
+ require.Equal(t, *seed, lastPSK(t, link.ifaceA),
+ "second expiry after re-add must continue to the seed fallback")
+}
diff --git a/client/internal/rosenpass/seed.go b/client/internal/rosenpass/seed.go
index 83aba1e0e..052c11ed4 100644
--- a/client/internal/rosenpass/seed.go
+++ b/client/internal/rosenpass/seed.go
@@ -1,11 +1,28 @@
package rosenpass
import (
+ "crypto/sha256"
"fmt"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
+// ratchetLabel domain-separates the expiry ratchet from other uses of the
+// rosenpass output key.
+const ratchetLabel = "netbird-rosenpass-expiry-ratchet"
+
+// RatchetKey derives the successor preshared key from the previous Rosenpass
+// output key. When a key expires without a completed renewal, both peers
+// advance their last shared key by one ratchet step: the expired key is
+// rotated out while both ends still converge on an identical, non-public
+// replacement without communicating.
+func RatchetKey(prev wgtypes.Key) wgtypes.Key {
+ input := make([]byte, 0, len(ratchetLabel)+len(prev))
+ input = append(input, ratchetLabel...)
+ input = append(input, prev[:]...)
+ return sha256.Sum256(input)
+}
+
// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
// of peer public keys. Both peers, given the same key pair, produce the same
// output regardless of which side runs the function: the inputs are ordered
diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go
index e25cc2a5c..d20f4b944 100644
--- a/client/internal/routemanager/dnsinterceptor/handler.go
+++ b/client/internal/routemanager/dnsinterceptor/handler.go
@@ -95,7 +95,7 @@ func (d *DnsInterceptor) RemoveRoute() error {
// AllowedIPs should use real IPs
if d.currentPeerKey != "" {
- if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil {
+ if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err))
}
}
@@ -172,7 +172,7 @@ func (d *DnsInterceptor) removeAllowedIP(realPrefix netip.Prefix) error {
}
// AllowedIPs use real IPs
- if _, err := d.allowedIPsRefcounter.Decrement(realPrefix); err != nil {
+ if _, err := d.allowedIPsRefcounter.Decrement(realPrefix, d.currentPeerKey); err != nil {
return fmt.Errorf("remove allowed IP %s: %v", realPrefix, err)
}
@@ -205,7 +205,7 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error {
for _, prefixes := range d.interceptedDomains {
for _, prefix := range prefixes {
// AllowedIPs use real IPs
- if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil {
+ if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err))
}
}
@@ -226,12 +226,11 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
return
}
- // pass if non A/AAAA query
- if r.Question[0].Qtype != dns.TypeA && r.Question[0].Qtype != dns.TypeAAAA {
- d.continueToNextHandler(w, r, logger, "non A/AAAA query")
- return
- }
-
+ // All query types for an intercepted domain are forwarded to the peer's
+ // DNS forwarder, which owns the name. Falling through to the system
+ // resolver would let it answer NXDOMAIN for a name it isn't authoritative
+ // for, poisoning the whole name (including the A/AAAA records the route
+ // does serve). The forwarder answers NODATA for types it cannot resolve.
d.mu.RLock()
peerKey := d.currentPeerKey
d.mu.RUnlock()
@@ -251,6 +250,14 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
r.MsgHdr.AuthenticatedData = true
}
+ // Advertise EDNS0 to the forwarder so it may return an Extended DNS Error
+ // describing why a lookup failed. The OPT is stripped from the reply when
+ // the original client did not request EDNS0.
+ hadEdns := r.IsEdns0() != nil
+ if !hadEdns {
+ r.SetEdns0(dns.DefaultMsgSize, false)
+ }
+
upstream := net.JoinHostPort(upstreamIP.String(), strconv.FormatUint(uint64(d.forwarderPort.Load()), 10))
ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout)
defer cancel()
@@ -260,6 +267,13 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
return
}
+ if ede, ok := resutil.ExtractEDE(reply); ok {
+ resutil.SetMeta(w, "ede", fmt.Sprintf("%d %s", ede.InfoCode, ede.ExtraText))
+ }
+ if !hadEdns {
+ resutil.StripOPT(reply)
+ }
+
resutil.SetMeta(w, "peer", peerKey)
reply.Id = r.Id
@@ -278,19 +292,6 @@ func (d *DnsInterceptor) writeDNSError(w dns.ResponseWriter, r *dns.Msg, logger
}
}
-// continueToNextHandler signals the handler chain to try the next handler
-func (d *DnsInterceptor) continueToNextHandler(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry, reason string) {
- logger.Tracef("continuing to next handler for domain=%s reason=%s", r.Question[0].Name, reason)
-
- resp := new(dns.Msg)
- resp.SetRcode(r, dns.RcodeNameError)
- // Set Zero bit to signal handler chain to continue
- resp.MsgHdr.Zero = true
- if err := w.WriteMsg(resp); err != nil {
- logger.Errorf("failed writing DNS continue response: %v", err)
- }
-}
-
func (d *DnsInterceptor) getUpstreamIP(peerKey string) (netip.Addr, error) {
peerAllowedIP, exists := d.peerStore.AllowedIP(peerKey)
if !exists {
@@ -478,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger
// internalDnatFw checks if the firewall supports internal DNAT
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
- if d.firewall == nil || runtime.GOOS != "android" {
+ if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" {
return nil, false
}
fw, ok := d.firewall.(internalDNATer)
diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go
index f0efd7b22..bb3b1c59c 100644
--- a/client/internal/routemanager/dynamic/route.go
+++ b/client/internal/routemanager/dynamic/route.go
@@ -135,7 +135,7 @@ func (r *Route) RemoveAllowedIPs() error {
var merr *multierror.Error
for _, domainPrefixes := range r.dynamicDomains {
for _, prefix := range domainPrefixes {
- if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil {
+ if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err))
}
}
@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
}
func (r *Route) update(ctx context.Context) error {
- resolved, err := r.resolveDomains()
+ resolved, err := r.resolveDomains(ctx)
if err != nil {
if len(resolved) == 0 {
return fmt.Errorf("resolve domains: %w", err)
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
return nil
}
-func (r *Route) resolveDomains() (domainMap, error) {
+func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
results := make(chan resolveResult)
- go r.resolve(results)
+ go r.resolve(ctx, results)
resolved := domainMap{}
var merr *multierror.Error
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) {
return resolved, nberrors.FormatErrorOrNil(merr)
}
-func (r *Route) resolve(results chan resolveResult) {
+func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
var wg sync.WaitGroup
for _, d := range r.route.Domains {
@@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) {
go func(domain domain.Domain) {
defer wg.Done()
- ips, err := r.getIPsFromResolver(domain)
+ ips, err := r.getIPsFromResolver(ctx, domain)
if err != nil {
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
- ips, err = net.LookupIP(domain.PunycodeString())
+ ips, err = lookupHostIPs(ctx, domain)
if err != nil {
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
return
@@ -320,7 +320,7 @@ func (r *Route) removeRoutes(prefixes []netip.Prefix) ([]netip.Prefix, error) {
merr = multierror.Append(merr, fmt.Errorf("remove dynamic route for IP %s: %w", prefix, err))
}
if r.currentPeerKey != "" {
- if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil {
+ if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err))
}
}
@@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
return
}
+// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
+func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
+ if err != nil {
+ return nil, err
+ }
+
+ ips := make([]net.IP, 0, len(addrs))
+ for _, addr := range addrs {
+ ips = append(ips, addr.IP)
+ }
+ return ips, nil
+}
+
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
prefixSet := make(map[netip.Prefix]struct{})
for _, prefix := range oldPrefixes {
diff --git a/client/internal/routemanager/dynamic/route_generic.go b/client/internal/routemanager/dynamic/route_generic.go
index 56fd63fba..8bc2dd3df 100644
--- a/client/internal/routemanager/dynamic/route_generic.go
+++ b/client/internal/routemanager/dynamic/route_generic.go
@@ -3,11 +3,12 @@
package dynamic
import (
+ "context"
"net"
"github.com/netbirdio/netbird/shared/management/domain"
)
-func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
- return net.LookupIP(domain.PunycodeString())
+func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
+ return lookupHostIPs(ctx, domain)
}
diff --git a/client/internal/routemanager/dynamic/route_ios.go b/client/internal/routemanager/dynamic/route_ios.go
index 1ae281d56..6a3d262b8 100644
--- a/client/internal/routemanager/dynamic/route_ios.go
+++ b/client/internal/routemanager/dynamic/route_ios.go
@@ -3,6 +3,7 @@
package dynamic
import (
+ "context"
"fmt"
"net"
"time"
@@ -16,7 +17,7 @@ import (
const dialTimeout = 10 * time.Second
-func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
+func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
if err != nil {
return nil, fmt.Errorf("error while creating private client: %s", err)
@@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
msg := new(dns.Msg)
msg.SetQuestion(fqdn, qtype)
- response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
+ response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
if err != nil {
if queryErr == nil {
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
diff --git a/client/internal/routemanager/exit_node_selection_test.go b/client/internal/routemanager/exit_node_selection_test.go
new file mode 100644
index 000000000..28dd0a640
--- /dev/null
+++ b/client/internal/routemanager/exit_node_selection_test.go
@@ -0,0 +1,191 @@
+package routemanager
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/routeselector"
+ "github.com/netbirdio/netbird/route"
+)
+
+func newExitNodeTestManager() *DefaultManager {
+ return &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+}
+
+func exitRoute(netID, peer string, skipAutoApply bool) *route.Route {
+ return &route.Route{
+ NetID: route.NetID(netID),
+ Network: netip.MustParsePrefix("0.0.0.0/0"),
+ Peer: peer,
+ SkipAutoApply: skipAutoApply,
+ }
+}
+
+func TestPickPreferredExitNode(t *testing.T) {
+ tests := []struct {
+ name string
+ info exitNodeInfo
+ want route.NetID
+ }{
+ {
+ name: "persisted user selection wins over management",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"a", "b", "c"},
+ userSelected: []route.NetID{"b"},
+ selectedByManagement: []route.NetID{"a"},
+ },
+ want: "b",
+ },
+ {
+ name: "multiple user-selected self-heal to deterministic min",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"a", "b", "c"},
+ userSelected: []route.NetID{"c", "a"},
+ },
+ want: "a",
+ },
+ {
+ name: "explicit opt-out keeps none",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"a", "b"},
+ userDeselected: []route.NetID{"a", "b"},
+ },
+ want: "",
+ },
+ {
+ name: "fresh defaults to management auto-apply pick",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"a", "b", "c"},
+ selectedByManagement: []route.NetID{"b"},
+ },
+ want: "b",
+ },
+ {
+ name: "no user pick and no management auto-apply selects none",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"c", "a", "b"},
+ },
+ want: "",
+ },
+ {
+ name: "user-deselect does not block a management auto-apply sibling",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"a", "b"},
+ userDeselected: []route.NetID{"a"},
+ selectedByManagement: []route.NetID{"b"},
+ },
+ want: "b",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, pickPreferredExitNode(tt.info), "preferred exit node")
+ })
+ }
+}
+
+func TestEnforceSingleExitNode(t *testing.T) {
+ m := newExitNodeTestManager()
+ all := []route.NetID{"a", "b", "c"}
+
+ m.enforceSingleExitNode("b", all)
+ assert.False(t, m.routeSelector.IsSelected("a"), "a should be deselected")
+ assert.True(t, m.routeSelector.IsSelected("b"), "b should be the only selected exit node")
+ assert.False(t, m.routeSelector.IsSelected("c"), "c should be deselected")
+
+ // Switching the preferred node moves the single selection.
+ m.enforceSingleExitNode("c", all)
+ assert.False(t, m.routeSelector.IsSelected("a"), "a stays deselected")
+ assert.False(t, m.routeSelector.IsSelected("b"), "b should now be deselected")
+ assert.True(t, m.routeSelector.IsSelected("c"), "c should now be selected")
+
+ // Empty preferred turns every exit node off.
+ m.enforceSingleExitNode("", all)
+ for _, id := range all {
+ assert.False(t, m.routeSelector.IsSelected(id), "no exit node should be selected")
+ }
+}
+
+func TestEnforceSingleExitNode_RespectsDeselectAll(t *testing.T) {
+ m := newExitNodeTestManager()
+ m.routeSelector.DeselectAllRoutes()
+
+ m.enforceSingleExitNode("b", []route.NetID{"a", "b"})
+
+ assert.True(t, m.routeSelector.IsDeselectAll(), "global deselect-all must stay in effect")
+ assert.False(t, m.routeSelector.IsSelected("b"), "no exit node should be forced on while deselect-all is set")
+}
+
+func TestUpdateRouteSelectorFromManagement_FreshSelectsOne(t *testing.T) {
+ m := newExitNodeTestManager()
+ routes := route.HAMap{
+ "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
+ "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
+ "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
+ "exitC|0.0.0.0/0": {exitRoute("exitC", "p4", false)},
+ }
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ // Exactly one exit node (the deterministic first) is selected.
+ assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA is the deterministic default")
+ assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB must not also be selected")
+ assert.False(t, m.routeSelector.IsSelected("exitC"), "exitC must not also be selected")
+ // Non-exit routes are left at their default-on state.
+ assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
+}
+
+func TestUpdateRouteSelectorFromManagement_HonorsPersistedPick(t *testing.T) {
+ m := newExitNodeTestManager()
+ routes := route.HAMap{
+ "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
+ "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
+ }
+ all := []route.NetID{"exitA", "exitB"}
+
+ // Simulate the state the runtime select path leaves behind: exactly one
+ // exit node explicitly selected, its sibling deselected.
+ require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exitB"}, true, all))
+ require.NoError(t, m.routeSelector.DeselectRoutes([]route.NetID{"exitA"}, all))
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ assert.True(t, m.routeSelector.IsSelected("exitB"), "persisted pick must stay selected")
+ assert.False(t, m.routeSelector.IsSelected("exitA"), "the other exit node stays deselected")
+}
+
+func TestUpdateRouteSelectorFromManagement_OptOutKeepsNone(t *testing.T) {
+ m := newExitNodeTestManager()
+ routes := route.HAMap{
+ "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
+ "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
+ }
+ all := []route.NetID{"exitA", "exitB"}
+
+ // User deselected exit nodes and selected none.
+ require.NoError(t, m.routeSelector.DeselectRoutes(all, all))
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ assert.False(t, m.routeSelector.IsSelected("exitA"), "opt-out keeps exitA off")
+ assert.False(t, m.routeSelector.IsSelected("exitB"), "opt-out keeps exitB off")
+}
+
+func TestUpdateRouteSelectorFromManagement_NoAutoApplySelectsNone(t *testing.T) {
+ m := newExitNodeTestManager()
+ // SkipAutoApply=true: management offers the exit nodes but doesn't request
+ // auto-activation, so none should be selected until the user picks one.
+ routes := route.HAMap{
+ "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)},
+ "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)},
+ }
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ assert.False(t, m.routeSelector.IsSelected("exitA"), "no auto-apply keeps exitA off")
+ assert.False(t, m.routeSelector.IsSelected("exitB"), "no auto-apply keeps exitB off")
+}
diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate.go b/client/internal/routemanager/ipfwdstate/ipfwdstate.go
index 2be1c2ae7..3d571e16b 100644
--- a/client/internal/routemanager/ipfwdstate/ipfwdstate.go
+++ b/client/internal/routemanager/ipfwdstate/ipfwdstate.go
@@ -2,54 +2,183 @@ package ipfwdstate
import (
"fmt"
+ "sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
)
-// IPForwardingState is a struct that keeps track of the IP forwarding state.
-// todo: read initial state of the IP forwarding from the system and reset the state based on it.
-// todo: separate v4/v6 forwarding state, since the sysctls are independent
-// (net.ipv4.ip_forward vs net.ipv6.conf.all.forwarding). Currently the nftables
-// manager shares one instance between both routers, which works only because
-// EnableIPForwarding enables both sysctls in a single call.
+// IPForwardingState tracks v4 and v6 IP-forwarding sysctl enables with
+// independent refcounts so a v4-only routing setup doesn't flip v6 sysctls.
type IPForwardingState struct {
- enabledCounter int
+ mu sync.Mutex
+
+ v4Count int
+ v6Count int
+
+ // routingV4/routingV6 track whether the routing path currently holds a
+ // reference, so repeated EnableRouting calls (one per network-map update)
+ // hold at most one reference per family and an unpaired DisableRouting
+ // can't release references held by DNAT rules.
+ routingV4 bool
+ routingV6 bool
+
+ wgIfaceName string
+ v6Saved map[string]int
}
-func NewIPForwardingState() *IPForwardingState {
- return &IPForwardingState{}
+// NewIPForwardingState returns a state tracker for the IP-forwarding sysctls.
+// wgIfaceName is excluded from the per-interface accept_ra handling.
+func NewIPForwardingState(wgIfaceName string) *IPForwardingState {
+ return &IPForwardingState{wgIfaceName: wgIfaceName}
}
-func (f *IPForwardingState) RequestForwarding() error {
- if f.enabledCounter != 0 {
- f.enabledCounter++
+// Counts returns the current v4 and v6 refcounts. Intended for diagnostics
+// and tests.
+func (f *IPForwardingState) Counts() (v4, v6 int) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.v4Count, f.v6Count
+}
+
+// RequestRouting takes the forwarding references for the routing path. It is
+// idempotent: while routing already holds a reference, further calls don't
+// increment the refcounts, and a v4-only request releases a previously held v6
+// reference. A v6 sysctl failure is logged and not returned so it can't take
+// down v4 routing (the sysctl may be unwritable, e.g. read-only /proc/sys or
+// IPv6 disabled on the kernel command line); v6 is retried on the next call.
+func (f *IPForwardingState) RequestRouting(v6 bool) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ if !f.routingV4 {
+ if err := f.requestV4(); err != nil {
+ return err
+ }
+ f.routingV4 = true
+ }
+
+ if !v6 {
+ if !f.routingV6 {
+ return nil
+ }
+ f.routingV6 = false
+ return f.releaseV6()
+ }
+
+ if f.routingV6 {
return nil
}
-
- if err := systemops.EnableIPForwarding(); err != nil {
- return fmt.Errorf("failed to enable IP forwarding with sysctl: %w", err)
+ if err := f.requestV6(); err != nil {
+ log.Warnf("enable IPv6 forwarding for routing: %v", err)
+ return nil
}
- f.enabledCounter = 1
- log.Info("IP forwarding enabled")
-
+ f.routingV6 = true
return nil
}
-func (f *IPForwardingState) ReleaseForwarding() error {
- if f.enabledCounter == 0 {
- return nil
+// ReleaseRouting releases the references RequestRouting holds. Calls without a
+// held reference are no-ops.
+func (f *IPForwardingState) ReleaseRouting() error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ if f.routingV4 {
+ f.routingV4 = false
+ f.releaseV4()
}
-
- if f.enabledCounter > 1 {
- f.enabledCounter--
- return nil
+ if f.routingV6 {
+ f.routingV6 = false
+ return f.releaseV6()
}
-
- // if failed to disable IP forwarding we anyway decrement the counter
- f.enabledCounter = 0
-
- // todo call systemops.DisableIPForwarding()
+ return nil
+}
+
+// RequestForwarding enables the family's forwarding sysctl on first request.
+func (f *IPForwardingState) RequestForwarding(v6 bool) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ if v6 {
+ return f.requestV6()
+ }
+ return f.requestV4()
+}
+
+// ReleaseForwarding decrements the family counter. The last v6 release restores
+// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other
+// tooling (docker, k8s, libvirt).
+func (f *IPForwardingState) ReleaseForwarding(v6 bool) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ if v6 {
+ return f.releaseV6()
+ }
+ f.releaseV4()
+ return nil
+}
+
+func (f *IPForwardingState) requestV4() error {
+ if f.v4Count == 0 {
+ if err := systemops.EnableV4IPForwarding(); err != nil {
+ return fmt.Errorf("enable IPv4 forwarding: %w", err)
+ }
+ log.Info("IPv4 forwarding enabled")
+ }
+ f.v4Count++
+ return nil
+}
+
+func (f *IPForwardingState) releaseV4() {
+ if f.v4Count > 0 {
+ f.v4Count--
+ }
+}
+
+func (f *IPForwardingState) requestV6() error {
+ if f.v6Count == 0 {
+ saved, err := systemops.EnableV6IPForwarding(f.wgIfaceName)
+ if err != nil {
+ if rerr := systemops.DisableV6IPForwarding(saved); rerr != nil {
+ log.Warnf("rollback partial v6 sysctls: %v", rerr)
+ }
+ return fmt.Errorf("enable IPv6 forwarding: %w", err)
+ }
+ // A failed restore on a previous release keeps its saved values; those
+ // are the true originals, so keep them over what this enable captured.
+ if f.v6Saved == nil {
+ f.v6Saved = saved
+ } else {
+ for k, v := range saved {
+ if _, ok := f.v6Saved[k]; !ok {
+ f.v6Saved[k] = v
+ }
+ }
+ }
+ log.Info("IPv6 forwarding enabled")
+ }
+ f.v6Count++
+ return nil
+}
+
+func (f *IPForwardingState) releaseV6() error {
+ if f.v6Count == 0 {
+ return nil
+ }
+ f.v6Count--
+ if f.v6Count > 0 {
+ return nil
+ }
+
+ // Keep the saved values on failure so a later release or enable/release
+ // cycle can still restore them; re-restoring an already-restored key is a
+ // no-op since the sysctl already holds the desired value.
+ if err := systemops.DisableV6IPForwarding(f.v6Saved); err != nil {
+ return fmt.Errorf("disable IPv6 forwarding: %w", err)
+ }
+ f.v6Saved = nil
+ log.Info("IPv6 forwarding disabled")
return nil
}
diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go
new file mode 100644
index 000000000..b4615ff02
--- /dev/null
+++ b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go
@@ -0,0 +1,39 @@
+//go:build privileged
+
+package ipfwdstate
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request
+// releases a previously held routing-owned v6 reference without touching
+// references held by DNAT rules.
+func TestRequestRoutingV6ToV4Transition(t *testing.T) {
+ f := NewIPForwardingState("wt-fwd-test")
+
+ require.NoError(t, f.RequestRouting(true), "request routing with v6")
+ v4, v6 := f.Counts()
+ assert.Equal(t, 1, v4, "v4 reference held")
+ assert.Equal(t, 1, v6, "v6 reference held")
+
+ require.NoError(t, f.RequestRouting(false), "request routing v4-only")
+ v4, v6 = f.Counts()
+ assert.Equal(t, 1, v4, "v4 reference kept")
+ assert.Equal(t, 0, v6, "routing-owned v6 reference released")
+
+ // A DNAT-held reference survives a v4-only routing request.
+ require.NoError(t, f.RequestForwarding(true), "dnat v6 reference")
+ require.NoError(t, f.RequestRouting(false), "repeat v4-only request")
+ _, v6 = f.Counts()
+ assert.Equal(t, 1, v6, "dnat-held v6 reference survives")
+ require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference")
+
+ require.NoError(t, f.ReleaseRouting(), "release routing")
+ v4, v6 = f.Counts()
+ assert.Equal(t, 0, v4, "all v4 references released")
+ assert.Equal(t, 0, v6, "all v6 references released")
+}
diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go
index 22458d575..0ccfa83ac 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -8,13 +8,13 @@ import (
"net/netip"
"net/url"
"runtime"
- "slices"
+ "sort"
"strings"
"sync"
"sync/atomic"
+ "syscall"
"time"
- "github.com/google/uuid"
"github.com/hashicorp/go-multierror"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
@@ -51,15 +51,20 @@ type Manager interface {
UpdateRoutes(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error
ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]*route.Route, route.HAMap)
TriggerSelection(route.HAMap)
+ SelectRoutes(ids []route.NetID, appendRoute bool) error
+ DeselectRoutes(ids []route.NetID) error
+ SelectAllRoutes()
+ DeselectAllRoutes()
GetRouteSelector() *routeselector.RouteSelector
GetClientRoutes() route.HAMap
GetSelectedClientRoutes() route.HAMap
GetActiveClientRoutes() route.HAMap
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
SetRouteChangeListener(listener listener.NetworkChangeListener)
- InitialRouteRange() []string
+ CurrentRouteRange() []string
SetFirewall(firewall.Manager) error
SetDNSForwarderPort(port uint16)
+ ReconcilePeerAllowedIPs(peerKey string) error
Stop(stateManager *statemanager.Manager)
}
@@ -70,10 +75,8 @@ type ManagerConfig struct {
WGInterface iface.WGIface
StatusRecorder *peer.Status
RelayManager *relayClient.Manager
- InitialRoutes []*route.Route
StateManager *statemanager.Manager
DNSServer dns.Server
- DNSFeatureFlag bool
PeerStore *peerstore.Store
DisableClientRoutes bool
DisableServerRoutes bool
@@ -143,45 +146,12 @@ func NewManager(config ManagerConfig) *DefaultManager {
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
dm.setupRefCounters(useNoop)
- // don't proceed with client routes if it is disabled
- if config.DisableClientRoutes {
- return dm
- }
-
- if runtime.GOOS == "android" {
- dm.setupAndroidRoutes(config)
- }
return dm
}
-func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
- cr := m.initialClientRoutes(config.InitialRoutes)
- routesForComparison := slices.Clone(cr)
-
- if config.DNSFeatureFlag {
- m.fakeIPManager = fakeip.NewManager()
-
- v4ID := uuid.NewString()
- fakeIPRoute := &route.Route{
- ID: route.ID(v4ID),
- Network: m.fakeIPManager.GetFakeIPBlock(),
- NetID: route.NetID(v4ID),
- Peer: m.pubKey,
- NetworkType: route.IPv4Network,
- }
- v6ID := uuid.NewString()
- fakeIPv6Route := &route.Route{
- ID: route.ID(v6ID),
- Network: m.fakeIPManager.GetFakeIPv6Block(),
- NetID: route.NetID(v6ID),
- Peer: m.pubKey,
- NetworkType: route.IPv6Network,
- }
- cr = append(cr, fakeIPRoute, fakeIPv6Route)
- m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route})
- }
-
- m.notifier.SetInitialClientRoutes(cr, routesForComparison)
+func (m *DefaultManager) enableFakeIPRoutes() {
+ m.fakeIPManager = fakeip.NewManager()
+ m.notifier.NotifyRouteChange()
}
func (m *DefaultManager) setupRefCounters(useNoop bool) {
@@ -214,7 +184,7 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) {
)
}
- m.allowedIPsRefCounter = refcounter.New(
+ m.allowedIPsRefCounter = refcounter.NewAllowedIPs(
func(prefix netip.Prefix, peerKey string) (string, error) {
// save peerKey to use it in the remove function
return peerKey, m.wgInterface.AddAllowedIP(peerKey, prefix)
@@ -231,6 +201,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) {
)
}
+// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer
+// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to
+// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a
+// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates
+// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter
+// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is
+// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so
+// prefixes are re-added to an existing peer and an absent peer is left untouched.
+func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error {
+ if m.allowedIPsRefCounter == nil {
+ return nil
+ }
+
+ return m.allowedIPsRefCounter.ReapplyMatching(
+ func(out string) bool { return out == peerKey },
+ func(prefix netip.Prefix) error {
+ if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil {
+ return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err)
+ }
+ return nil
+ },
+ )
+}
+
// Init sets up the routing
func (m *DefaultManager) Init() error {
m.routeSelector = m.initSelector()
@@ -264,7 +258,11 @@ func (m *DefaultManager) initSelector() *routeselector.RouteSelector {
// restore selector state if it exists
if err := m.stateManager.LoadState(state); err != nil {
- log.Warnf("failed to load state: %v", err)
+ if errors.Is(err, syscall.ENOSYS) {
+ log.Debugf("route selector state unavailable on this platform: %v", err)
+ } else {
+ log.Warnf("failed to load state: %v", err)
+ }
return routeselector.NewRouteSelector()
}
@@ -430,6 +428,9 @@ func (m *DefaultManager) UpdateRoutes(
var merr *multierror.Error
if !m.disableClientRoutes {
+ if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil {
+ m.enableFakeIPRoutes()
+ }
// Update route selector based on management server's isSelected status
m.updateRouteSelectorFromManagement(clientRoutes)
@@ -442,6 +443,11 @@ func (m *DefaultManager) UpdateRoutes(
m.updateClientNetworks(updateSerial, filteredClientRoutes)
m.notifier.OnNewRoutes(filteredClientRoutes)
+ // A new network map can add or drop route/exit-node candidates without
+ // touching any peer's chosen-route state, so the peer status alone
+ // wouldn't notify SubscribeStatus subscribers. Bump the revision so the
+ // UI re-fetches ListNetworks.
+ m.statusRecorder.BumpNetworksRevision()
}
m.clientRoutes = clientRoutes
@@ -461,9 +467,32 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
m.notifier.SetListener(listener)
}
-// InitialRouteRange return the list of initial routes. It used by mobile systems
-func (m *DefaultManager) InitialRouteRange() []string {
- return m.notifier.GetInitialRouteRanges()
+// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
+func (m *DefaultManager) CurrentRouteRange() []string {
+ m.mux.Lock()
+ defer m.mux.Unlock()
+
+ if m.disableClientRoutes {
+ return nil
+ }
+
+ filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
+ var nets []string
+ for _, routes := range filtered {
+ for _, r := range routes {
+ if r.IsDynamic() {
+ continue
+ }
+ nets = append(nets, r.NetString())
+ }
+ }
+
+ if m.fakeIPManager != nil {
+ nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
+ }
+
+ sort.Strings(nets)
+ return nets
}
// GetRouteSelector returns the route selector
@@ -582,6 +611,10 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) {
if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil {
log.Errorf("failed to update state: %v", err)
}
+
+ // A selection change flips Network.selected without altering the candidate
+ // set, so bump the revision to push the new state to the UI.
+ m.statusRecorder.BumpNetworksRevision()
}
// stopObsoleteClients stops the client network watcher for the networks that are not in the new list
@@ -657,16 +690,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]
return newServerRoutesMap, newClientRoutesIDMap
}
-func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route {
- _, crMap := m.ClassifyRoutes(initialRoutes)
- rs := make([]*route.Route, 0, len(crMap))
- for _, routes := range crMap {
- rs = append(rs, routes...)
- }
-
- return rs
-}
-
func isRouteSupported(route *route.Route) bool {
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
return true
@@ -701,7 +724,13 @@ func resolveURLsToIPs(urls []string) []net.IP {
return ips
}
-// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
+// updateRouteSelectorFromManagement reconciles exit-node selection on every
+// network map: it keeps at most one exit node selected — the user's persisted
+// pick, else whatever management marks for auto-apply (SkipAutoApply=false),
+// else none. We never auto-activate an exit node the map doesn't request; it
+// stays off until the user picks it. Exit nodes are mutually exclusive, but the
+// RouteSelector stores routes with default-on semantics, so without this every
+// available exit node would report selected at once.
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
m.mirrorV6ExitPairSelections(clientRoutes)
@@ -712,13 +741,14 @@ func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HA
return
}
- exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
- if len(exitNodeInfo.allIDs) == 0 {
+ info := m.collectExitNodeInfo(clientRoutes)
+ if len(info.allIDs) == 0 {
return
}
- m.updateExitNodeSelections(exitNodeInfo)
- m.logExitNodeUpdate(exitNodeInfo)
+ preferred := pickPreferredExitNode(info)
+ m.enforceSingleExitNode(preferred, info.allIDs)
+ m.logExitNodeUpdate(info, preferred)
}
// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection
@@ -746,15 +776,22 @@ type exitNodeInfo struct {
userDeselected []route.NetID
}
+// collectExitNodeInfo categorises the available exit nodes by their persisted
+// selection state. It keys on the base (v4) NetID and skips the synthesized
+// "-v6" partner, which inherits its base's selection through the RouteSelector
+// — counting it separately would double-count the pair.
func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeInfo {
var info exitNodeInfo
for haID, routes := range clientRoutes {
- if !m.isExitNodeRoute(routes) {
+ if !isExitNodeRoutes(routes) {
continue
}
netID := haID.NetID()
+ if strings.HasSuffix(string(netID), route.V6ExitSuffix) {
+ continue
+ }
info.allIDs = append(info.allIDs, netID)
if m.routeSelector.HasUserSelectionForRoute(netID) {
@@ -767,13 +804,6 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI
return info
}
-func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool {
- if len(routes) == 0 {
- return false
- }
- return route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)
-}
-
func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) {
if m.routeSelector.IsSelected(netID) {
info.userSelected = append(info.userSelected, netID)
@@ -791,45 +821,52 @@ func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID r
}
}
-func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) {
- routesToDeselect := m.getRoutesToDeselect(info.allIDs)
- m.deselectExitNodes(routesToDeselect)
- m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs)
+// pickPreferredExitNode chooses the single exit node to keep selected. In order:
+// - a persisted user selection wins (deterministic if several survive from
+// legacy state, so the set self-heals down to one);
+// - otherwise activate only what management marks for auto-apply
+// (SkipAutoApply=false); the lexicographically first if it marks several.
+//
+// Returns "" when neither holds — we never force an arbitrary exit node on. A
+// route the map doesn't auto-apply stays off until the user selects it.
+// info.userDeselected is informational only: an explicit deselect simply keeps
+// that route out of both lists above, so it can't be picked.
+func pickPreferredExitNode(info exitNodeInfo) route.NetID {
+ if len(info.userSelected) > 0 {
+ return minNetID(info.userSelected)
+ }
+ if len(info.selectedByManagement) > 0 {
+ return minNetID(info.selectedByManagement)
+ }
+ return ""
}
-func (m *DefaultManager) getRoutesToDeselect(allIDs []route.NetID) []route.NetID {
- var routesToDeselect []route.NetID
- for _, netID := range allIDs {
- if !m.routeSelector.HasUserSelectionForRoute(netID) {
- routesToDeselect = append(routesToDeselect, netID)
+// enforceSingleExitNode makes preferred the only selected exit node: every other
+// available exit node is deselected and preferred (if any) is selected, without
+// disturbing non-exit route selections. The whole reconciliation runs under a
+// single RouteSelector lock (SetExclusiveExitNode) so a concurrent deselect-all
+// cannot interleave and get undone; a global deselect-all is left untouched so
+// the user's "all off" stays in effect.
+func (m *DefaultManager) enforceSingleExitNode(preferred route.NetID, allIDs []route.NetID) {
+ m.routeSelector.SetExclusiveExitNode(preferred, allIDs)
+}
+
+func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.NetID) {
+ log.Debugf("Exit node selection: %d available, preferred=%q (%d user-selected, %d user-deselected, %d management-selected)",
+ len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement))
+}
+
+// minNetID returns the lexicographically smallest NetID, for a deterministic
+// default pick that stays stable across restarts.
+func minNetID(ids []route.NetID) route.NetID {
+ if len(ids) == 0 {
+ return ""
+ }
+ best := ids[0]
+ for _, id := range ids[1:] {
+ if id < best {
+ best = id
}
}
- return routesToDeselect
-}
-
-func (m *DefaultManager) deselectExitNodes(routesToDeselect []route.NetID) {
- if len(routesToDeselect) == 0 {
- return
- }
-
- err := m.routeSelector.DeselectRoutes(routesToDeselect, routesToDeselect)
- if err != nil {
- log.Warnf("Failed to deselect exit nodes: %v", err)
- }
-}
-
-func (m *DefaultManager) selectExitNodesByManagement(selectedByManagement []route.NetID, allIDs []route.NetID) {
- if len(selectedByManagement) == 0 {
- return
- }
-
- err := m.routeSelector.SelectRoutes(selectedByManagement, true, allIDs)
- if err != nil {
- log.Warnf("Failed to select exit nodes: %v", err)
- }
-}
-
-func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo) {
- log.Debugf("Updated route selector: %d exit nodes available, %d selected by management, %d user-selected, %d user-deselected",
- len(info.allIDs), len(info.selectedByManagement), len(info.userSelected), len(info.userDeselected))
+ return best
}
diff --git a/client/internal/routemanager/manager_test.go b/client/internal/routemanager/manager_test.go
index 926f06bc9..18b44820a 100644
--- a/client/internal/routemanager/manager_test.go
+++ b/client/internal/routemanager/manager_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package routemanager
import (
diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go
index 937314995..2a8398b95 100644
--- a/client/internal/routemanager/mock.go
+++ b/client/internal/routemanager/mock.go
@@ -16,6 +16,8 @@ type MockManager struct {
ClassifyRoutesFunc func(routes []*route.Route) (map[route.ID]*route.Route, route.HAMap)
UpdateRoutesFunc func(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error
TriggerSelectionFunc func(haMap route.HAMap)
+ SelectRoutesFunc func(ids []route.NetID, appendRoute bool) error
+ DeselectRoutesFunc func(ids []route.NetID) error
GetRouteSelectorFunc func() *routeselector.RouteSelector
GetClientRoutesFunc func() route.HAMap
GetSelectedClientRoutesFunc func() route.HAMap
@@ -28,8 +30,8 @@ func (m *MockManager) Init() error {
return nil
}
-// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
-func (m *MockManager) InitialRouteRange() []string {
+// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
+func (m *MockManager) CurrentRouteRange() []string {
return nil
}
@@ -55,6 +57,30 @@ func (m *MockManager) TriggerSelection(networks route.HAMap) {
}
}
+// SelectRoutes mock implementation of SelectRoutes from Manager interface
+func (m *MockManager) SelectRoutes(ids []route.NetID, appendRoute bool) error {
+ if m.SelectRoutesFunc != nil {
+ return m.SelectRoutesFunc(ids, appendRoute)
+ }
+ return nil
+}
+
+// DeselectRoutes mock implementation of DeselectRoutes from Manager interface
+func (m *MockManager) DeselectRoutes(ids []route.NetID) error {
+ if m.DeselectRoutesFunc != nil {
+ return m.DeselectRoutesFunc(ids)
+ }
+ return nil
+}
+
+// SelectAllRoutes mock implementation of SelectAllRoutes from Manager interface
+func (m *MockManager) SelectAllRoutes() {
+}
+
+// DeselectAllRoutes mock implementation of DeselectAllRoutes from Manager interface
+func (m *MockManager) DeselectAllRoutes() {
+}
+
// GetRouteSelector mock implementation of GetRouteSelector from Manager interface
func (m *MockManager) GetRouteSelector() *routeselector.RouteSelector {
if m.GetRouteSelectorFunc != nil {
@@ -112,6 +138,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error {
func (m *MockManager) SetDNSForwarderPort(port uint16) {
}
+// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface
+func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error {
+ return nil
+}
+
// Stop mock implementation of Stop from Manager interface
func (m *MockManager) Stop(stateManager *statemanager.Manager) {
if m.StopFunc != nil {
diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go
index 49300dbb2..5fa329310 100644
--- a/client/internal/routemanager/notifier/notifier_android.go
+++ b/client/internal/routemanager/notifier/notifier_android.go
@@ -6,7 +6,6 @@ import (
"net/netip"
"slices"
"sort"
- "strings"
"sync"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -14,12 +13,15 @@ import (
)
type Notifier struct {
- initialRoutes []*route.Route
- currentRoutes []*route.Route
- fakeIPRoutes []*route.Route
+ mu sync.Mutex
- listener listener.NetworkChangeListener
- listenerMux sync.Mutex
+ // currentRoutes is the last announced route set. It exists only to
+ // suppress noise: without it every network map sync would trigger the
+ // Java side, even when the routes did not change. The actual TUN route
+ // state is owned by the route manager and pulled from there.
+ currentRoutes []*route.Route
+
+ listener listener.NetworkChangeListener
}
func NewNotifier() *Notifier {
@@ -27,20 +29,15 @@ func NewNotifier() *Notifier {
}
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
- n.listenerMux.Lock()
- defer n.listenerMux.Unlock()
+ n.mu.Lock()
+ defer n.mu.Unlock()
n.listener = listener
}
-// SetInitialClientRoutes stores the initial route sets for TUN configuration.
-func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
- n.initialRoutes = filterStatic(initialRoutes)
- n.currentRoutes = filterStatic(routesForComparison)
-}
-
-// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild.
-func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) {
- n.fakeIPRoutes = routes
+func (n *Notifier) NotifyRouteChange() {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ n.notifyLocked()
}
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
@@ -54,46 +51,32 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
}
}
- if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ if !hasRouteDiff(n.currentRoutes, newRoutes) {
return
}
n.currentRoutes = newRoutes
- n.notify()
+ n.notifyLocked()
}
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
// Not used on Android
}
-func (n *Notifier) notify() {
- n.listenerMux.Lock()
- defer n.listenerMux.Unlock()
+func (n *Notifier) notifyLocked() {
if n.listener == nil {
return
}
-
- allRoutes := slices.Clone(n.currentRoutes)
- allRoutes = append(allRoutes, n.fakeIPRoutes...)
-
- routeStrings := n.routesToStrings(allRoutes)
- sort.Strings(routeStrings)
- go func(l listener.NetworkChangeListener) {
- l.OnNetworkChanged(strings.Join(routeStrings, ","))
- }(n.listener)
+ n.listener.OnNetworkChanged("")
}
-func filterStatic(routes []*route.Route) []*route.Route {
- out := make([]*route.Route, 0, len(routes))
- for _, r := range routes {
- if !r.IsDynamic() {
- out = append(out, r)
- }
- }
- return out
+func (n *Notifier) Close() {
+ // unused
}
-func (n *Notifier) routesToStrings(routes []*route.Route) []string {
+func routesToStrings(routes []*route.Route) []string {
nets := make([]string, 0, len(routes))
for _, r := range routes {
nets = append(nets, r.NetString())
@@ -101,25 +84,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
return nets
}
-func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
- slices.SortFunc(a, func(x, y *route.Route) int {
- return strings.Compare(x.NetString(), y.NetString())
- })
- slices.SortFunc(b, func(x, y *route.Route) int {
- return strings.Compare(x.NetString(), y.NetString())
- })
-
- return !slices.EqualFunc(a, b, func(x, y *route.Route) bool {
- return x.NetString() == y.NetString()
- })
-}
-
-func (n *Notifier) GetInitialRouteRanges() []string {
- initialStrings := n.routesToStrings(n.initialRoutes)
- sort.Strings(initialStrings)
- return initialStrings
-}
-
-func (n *Notifier) Close() {
- // unused
+func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
+ as := routesToStrings(a)
+ bs := routesToStrings(b)
+ sort.Strings(as)
+ sort.Strings(bs)
+ return !slices.Equal(as, bs)
}
diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go
index d0888f3a1..d663dd471 100644
--- a/client/internal/routemanager/notifier/notifier_ios.go
+++ b/client/internal/routemanager/notifier/notifier_ios.go
@@ -3,7 +3,6 @@
package notifier
import (
- "container/list"
"net/netip"
"slices"
"sort"
@@ -16,20 +15,12 @@ import (
type Notifier struct {
mu sync.Mutex
- cond *sync.Cond
currentPrefixes []string
listener listener.NetworkChangeListener
- queue *list.List
- closed bool
}
func NewNotifier() *Notifier {
- n := &Notifier{
- queue: list.New(),
- }
- n.cond = sync.NewCond(&n.mu)
- go n.deliverLoop()
- return n
+ return &Notifier{}
}
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
@@ -38,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
n.listener = listener
}
-func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
- // iOS doesn't care about initial routes
-}
-
-func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
+func (n *Notifier) NotifyRouteChange() {
// Not used on iOS
}
@@ -59,44 +46,19 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
sort.Strings(newNets)
n.mu.Lock()
+ defer n.mu.Unlock()
if slices.Equal(n.currentPrefixes, newNets) {
- n.mu.Unlock()
return
}
n.currentPrefixes = newNets
- routes := strings.Join(n.currentPrefixes, ",")
- n.queue.PushBack(routes)
- n.cond.Signal()
- n.mu.Unlock()
+ if n.listener != nil {
+ n.listener.OnNetworkChanged(strings.Join(n.currentPrefixes, ","))
+ }
}
func (n *Notifier) Close() {
- n.mu.Lock()
- n.closed = true
- n.cond.Signal()
- n.mu.Unlock()
}
func (n *Notifier) GetInitialRouteRanges() []string {
return nil
}
-
-func (n *Notifier) deliverLoop() {
- for {
- n.mu.Lock()
- for n.queue.Len() == 0 && !n.closed {
- n.cond.Wait()
- }
- if n.closed && n.queue.Len() == 0 {
- n.mu.Unlock()
- return
- }
- routes := n.queue.Remove(n.queue.Front()).(string)
- l := n.listener
- n.mu.Unlock()
-
- if l != nil {
- l.OnNetworkChanged(routes)
- }
- }
-}
diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go
index 71b1096c2..fe48e07b3 100644
--- a/client/internal/routemanager/notifier/notifier_other.go
+++ b/client/internal/routemanager/notifier/notifier_other.go
@@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
// Not used on non-mobile platforms
}
-func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
- // Not used on non-mobile platforms
-}
-
-func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
+func (n *Notifier) NotifyRouteChange() {
// Not used on non-mobile platforms
}
@@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
// Not used on non-mobile platforms
}
-func (n *Notifier) GetInitialRouteRanges() []string {
- return []string{}
-}
-
func (n *Notifier) Close() {
// unused
}
diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go
new file mode 100644
index 000000000..c6806a6cd
--- /dev/null
+++ b/client/internal/routemanager/reconcile_test.go
@@ -0,0 +1,90 @@
+//go:build !windows
+
+package routemanager
+
+import (
+ "net"
+ "net/netip"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.zx2c4.com/wireguard/tun/netstack"
+
+ "github.com/netbirdio/netbird/client/iface/device"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
+)
+
+// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other
+// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them.
+type reconcileWGMock struct {
+ mu sync.Mutex
+ adds map[string][]netip.Prefix
+}
+
+func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.adds == nil {
+ m.adds = map[string][]netip.Prefix{}
+ }
+ m.adds[peerKey] = append(m.adds[peerKey], allowedIP)
+ return nil
+}
+
+func (m *reconcileWGMock) added(peerKey string) []netip.Prefix {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.adds[peerKey]
+}
+
+func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil }
+func (m *reconcileWGMock) Name() string { return "utun-test" }
+func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} }
+func (m *reconcileWGMock) ToInterface() *net.Interface { return nil }
+func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
+func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
+func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil }
+func (m *reconcileWGMock) GetNet() *netstack.Net { return nil }
+
+// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix
+// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer.
+func TestReconcilePeerAllowedIPs(t *testing.T) {
+ wg := &reconcileWGMock{}
+ m := &DefaultManager{wgInterface: wg}
+ m.allowedIPsRefCounter = refcounter.NewAllowedIPs(
+ func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
+ func(netip.Prefix, string) error { return nil },
+ )
+
+ peerA1 := netip.MustParsePrefix("10.0.0.0/24")
+ peerA2 := netip.MustParsePrefix("10.1.0.0/24")
+ peerB1 := netip.MustParsePrefix("10.2.0.0/24")
+
+ for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
+ _, err := m.allowedIPsRefCounter.Increment(prefix, peer)
+ require.NoError(t, err)
+ }
+ // Extra reference: reconcile must still re-apply the prefix even though its refcount never
+ // hit 0 again (the exact case the plain incremental path skips).
+ _, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA")
+ require.NoError(t, err)
+
+ require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
+
+ assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"),
+ "reconcile must re-apply all routed prefixes of the peer")
+ assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes")
+}
+
+// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is
+// set up.
+func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) {
+ wg := &reconcileWGMock{}
+ m := &DefaultManager{wgInterface: wg}
+
+ require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
+ assert.Empty(t, wg.added("peerA"))
+}
diff --git a/client/internal/routemanager/refcounter/allowedips.go b/client/internal/routemanager/refcounter/allowedips.go
new file mode 100644
index 000000000..6d682e8a9
--- /dev/null
+++ b/client/internal/routemanager/refcounter/allowedips.go
@@ -0,0 +1,206 @@
+package refcounter
+
+import (
+ "errors"
+ "fmt"
+ "net/netip"
+ "sort"
+ "sync"
+
+ "github.com/hashicorp/go-multierror"
+
+ nberrors "github.com/netbirdio/netbird/client/errors"
+)
+
+// allowedIPsEntry holds the per-peer reference counts for a single prefix and which peer is
+// currently installed in WireGuard. WireGuard allows a prefix on exactly one peer, so at most
+// one peer is active at a time even when several peers reference the prefix.
+type allowedIPsEntry struct {
+ // peers maps a peerKey to the number of references holding the prefix for that peer.
+ peers map[string]int
+ // active is the peerKey currently installed in WireGuard for this prefix ("" if none).
+ active string
+ // total is the sum of all per-peer reference counts (kept in sync with peers).
+ total int
+}
+
+// AllowedIPsRefCounter is a peer-aware reference counter for WireGuard AllowedIPs.
+//
+// The generic Counter keys only by prefix and remembers a single Out value set by the first
+// caller, which it never changes. That is wrong for AllowedIPs: two independent watchers (or
+// multiple resolved domains) can reference the same prefix through different peers, and when the
+// peer currently installed in WireGuard releases its last reference the prefix must be handed over
+// to a surviving peer instead of being left pointing at the released one.
+//
+// It calls add/remove (which program WireGuard) only on the transitions that matter:
+// - add on the first reference for a prefix, or when swapping the active peer;
+// - remove on the last reference for a prefix, or on the old peer during a swap.
+type AllowedIPsRefCounter struct {
+ mu sync.Mutex
+ entries map[netip.Prefix]*allowedIPsEntry
+ add AddFunc[netip.Prefix, string, string]
+ remove RemoveFunc[netip.Prefix, string]
+}
+
+// NewAllowedIPs creates a new peer-aware AllowedIPs reference counter.
+// add programs a prefix on a peer in WireGuard and returns the peerKey to store as the active peer.
+// remove unprograms the prefix from the given peer.
+func NewAllowedIPs(add AddFunc[netip.Prefix, string, string], remove RemoveFunc[netip.Prefix, string]) *AllowedIPsRefCounter {
+ return &AllowedIPsRefCounter{
+ entries: map[netip.Prefix]*allowedIPsEntry{},
+ add: add,
+ remove: remove,
+ }
+}
+
+// Increment adds a reference to prefix for peerKey. WireGuard is programmed only for the first
+// reference to a prefix; while a different peer is already installed the prefix is left with it
+// (first peer wins, HA at the WireGuard layer is not possible) and only the reference count is kept.
+func (rm *AllowedIPsRefCounter) Increment(prefix netip.Prefix, peerKey string) (Ref[string], error) {
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+
+ e, ok := rm.entries[prefix]
+ if !ok {
+ e = &allowedIPsEntry{peers: map[string]int{}}
+ rm.entries[prefix] = e
+ }
+
+ logCallerF("Increasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]",
+ prefix, peerKey, e.peers[peerKey], e.peers[peerKey]+1, e.total, e.total+1, e.active)
+
+ // Program WireGuard only when nothing is installed yet for this prefix.
+ if e.active == "" {
+ out, err := rm.add(prefix, peerKey)
+ if errors.Is(err, ErrIgnore) {
+ if e.total == 0 {
+ delete(rm.entries, prefix)
+ }
+ return Ref[string]{Count: e.total, Out: e.active}, nil
+ }
+ if err != nil {
+ if e.total == 0 {
+ delete(rm.entries, prefix)
+ }
+ return Ref[string]{}, fmt.Errorf("failed to add allowed IP %v for peer %s: %w", prefix, peerKey, err)
+ }
+ e.active = out
+ }
+
+ e.peers[peerKey]++
+ e.total++
+
+ return Ref[string]{Count: e.total, Out: e.active}, nil
+}
+
+// Decrement removes a reference to prefix for peerKey. When the peer currently installed in
+// WireGuard releases its last reference, the prefix is swapped to a surviving peer if one exists,
+// otherwise it is removed from WireGuard.
+func (rm *AllowedIPsRefCounter) Decrement(prefix netip.Prefix, peerKey string) (Ref[string], error) {
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+
+ e, ok := rm.entries[prefix]
+ if !ok {
+ logCallerF("No allowed IP reference found for prefix %v", prefix)
+ return Ref[string]{}, nil
+ }
+
+ if e.peers[peerKey] > 0 {
+ logCallerF("Decreasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]",
+ prefix, peerKey, e.peers[peerKey], e.peers[peerKey]-1, e.total, e.total-1, e.active)
+ e.peers[peerKey]--
+ e.total--
+ if e.peers[peerKey] == 0 {
+ delete(e.peers, peerKey)
+ }
+ } else {
+ logCallerF("No allowed IP reference found for prefix %v peer %s", prefix, peerKey)
+ }
+
+ // If the peer currently installed in WireGuard still holds references, nothing to reprogram.
+ // Keying the check on the active peer (not the one just released) makes this self-healing:
+ // a prior swap whose remove/add failed leaves e.active pointing at a peer with no references,
+ // and this retries the hand-off on the next Decrement instead of getting stuck.
+ if e.active != "" && e.peers[e.active] > 0 {
+ return Ref[string]{Count: e.total, Out: e.active}, nil
+ }
+
+ // Detach the stale/gone active peer from WireGuard before reprogramming.
+ if e.active != "" {
+ if err := rm.remove(prefix, e.active); err != nil {
+ return Ref[string]{Count: e.total, Out: e.active}, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err)
+ }
+ e.active = ""
+ }
+
+ // Hand the prefix over to a surviving peer, or drop the entry when none remain.
+ if survivor, ok := pickSurvivor(e.peers); ok {
+ out, err := rm.add(prefix, survivor)
+ if err != nil {
+ return Ref[string]{Count: e.total, Out: ""}, fmt.Errorf("swap allowed IP %v to peer %s: %w", prefix, survivor, err)
+ }
+ e.active = out
+ return Ref[string]{Count: e.total, Out: e.active}, nil
+ }
+
+ delete(rm.entries, prefix)
+ return Ref[string]{Count: 0, Out: ""}, nil
+}
+
+// Flush removes all prefixes from WireGuard and clears the counter.
+func (rm *AllowedIPsRefCounter) Flush() error {
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+
+ var merr *multierror.Error
+ for prefix, e := range rm.entries {
+ if e.active == "" {
+ continue
+ }
+ logCallerF("Flushing allowed IP for prefix %v peer %s", prefix, e.active)
+ if err := rm.remove(prefix, e.active); err != nil {
+ merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err))
+ }
+ }
+
+ clear(rm.entries)
+
+ return nberrors.FormatErrorOrNil(merr)
+}
+
+// ReapplyMatching calls apply for every prefix whose currently installed (active) peer satisfies
+// pred, holding the lock for the whole pass. It is used to re-push allowed IPs onto a peer whose
+// WireGuard entry was rebuilt (e.g. a lazy connection cycling idle->wake) without a matching
+// refcounter change, which would otherwise leave the prefix installed in the counter but missing
+// on the device. Only the active peer is considered — a prefix that lost its installed peer to a
+// failed swap is skipped here and reconciled by the next Increment/Decrement.
+func (rm *AllowedIPsRefCounter) ReapplyMatching(pred func(out string) bool, apply func(key netip.Prefix) error) error {
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+
+ var merr *multierror.Error
+ for prefix, e := range rm.entries {
+ if e.active != "" && pred(e.active) {
+ if err := apply(prefix); err != nil {
+ merr = multierror.Append(merr, err)
+ }
+ }
+ }
+ return nberrors.FormatErrorOrNil(merr)
+}
+
+// pickSurvivor deterministically selects a peer still referencing the prefix. WireGuard cannot do
+// multipath for a single prefix, so any surviving peer is a valid winner; the choice is made stable
+// (lowest peerKey) for predictable behavior and testability.
+func pickSurvivor(peers map[string]int) (string, bool) {
+ if len(peers) == 0 {
+ return "", false
+ }
+ keys := make([]string, 0, len(peers))
+ for k := range peers {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys[0], true
+}
diff --git a/client/internal/routemanager/refcounter/allowedips_test.go b/client/internal/routemanager/refcounter/allowedips_test.go
new file mode 100644
index 000000000..835142083
--- /dev/null
+++ b/client/internal/routemanager/refcounter/allowedips_test.go
@@ -0,0 +1,241 @@
+package refcounter
+
+import (
+ "errors"
+ "net/netip"
+ "testing"
+)
+
+// fakeWG models WireGuard's cryptokey routing: a prefix can be installed on exactly one peer.
+// failAdd/failRemove make the next add/remove fail once, to exercise the self-healing error paths.
+type fakeWG struct {
+ installed map[netip.Prefix]string
+ adds int
+ removes int
+ failAdd bool
+ failRemove bool
+}
+
+func newFakeWG() *fakeWG {
+ return &fakeWG{installed: map[netip.Prefix]string{}}
+}
+
+func (f *fakeWG) counter() *AllowedIPsRefCounter {
+ return NewAllowedIPs(
+ func(prefix netip.Prefix, peerKey string) (string, error) {
+ if f.failAdd {
+ f.failAdd = false
+ return "", errors.New("add failed")
+ }
+ f.adds++
+ f.installed[prefix] = peerKey
+ return peerKey, nil
+ },
+ func(prefix netip.Prefix, peerKey string) error {
+ if f.failRemove {
+ f.failRemove = false
+ return errors.New("remove failed")
+ }
+ f.removes++
+ // only clear if this peer is the one installed, mirroring wg semantics
+ if f.installed[prefix] == peerKey {
+ delete(f.installed, prefix)
+ }
+ return nil
+ },
+ )
+}
+
+func mustPrefix(t *testing.T, s string) netip.Prefix {
+ t.Helper()
+ p, err := netip.ParsePrefix(s)
+ if err != nil {
+ t.Fatalf("parse prefix %q: %v", s, err)
+ }
+ return p
+}
+
+func mustIncrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] {
+ t.Helper()
+ ref, err := c.Increment(p, peer)
+ if err != nil {
+ t.Fatalf("Increment(%v, %s): %v", p, peer, err)
+ }
+ return ref
+}
+
+func mustDecrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] {
+ t.Helper()
+ ref, err := c.Decrement(p, peer)
+ if err != nil {
+ t.Fatalf("Decrement(%v, %s): %v", p, peer, err)
+ }
+ return ref
+}
+
+// TestAllowedIPs_SwapOnActivePeerRemoval reproduces the reported bug: two networks with the same
+// prefix routed by different peers. Removing the network whose peer is installed must hand the
+// prefix over to the surviving peer instead of leaving it on the removed one.
+func TestAllowedIPs_SwapOnActivePeerRemoval(t *testing.T) {
+ f := newFakeWG()
+ c := f.counter()
+ p := mustPrefix(t, "10.44.8.0/24")
+
+ mustIncrement(t, c, p, "peerA")
+ mustIncrement(t, c, p, "peerB")
+ // First peer wins while both are present.
+ if got := f.installed[p]; got != "peerA" {
+ t.Fatalf("expected peerA installed, got %q", got)
+ }
+
+ // Remove the active peer's network -> must swap to peerB.
+ mustDecrement(t, c, p, "peerA")
+ if got := f.installed[p]; got != "peerB" {
+ t.Fatalf("BUG: prefix stuck on removed peer, want peerB got %q", got)
+ }
+
+ // Remove the last one -> prefix gone.
+ mustDecrement(t, c, p, "peerB")
+ if _, ok := f.installed[p]; ok {
+ t.Fatalf("expected prefix removed, still installed on %q", f.installed[p])
+ }
+}
+
+// TestAllowedIPs_RemoveNonActivePeer removing a non-installed peer must not touch WireGuard.
+func TestAllowedIPs_RemoveNonActivePeer(t *testing.T) {
+ f := newFakeWG()
+ c := f.counter()
+ p := mustPrefix(t, "10.44.8.0/24")
+
+ mustIncrement(t, c, p, "peerA")
+ mustIncrement(t, c, p, "peerB")
+ removesBefore := f.removes
+
+ mustDecrement(t, c, p, "peerB")
+ if f.installed[p] != "peerA" {
+ t.Fatalf("active peer must stay peerA, got %q", f.installed[p])
+ }
+ if f.removes != removesBefore {
+ t.Fatalf("removing a non-active peer must not call wg remove")
+ }
+}
+
+// TestAllowedIPs_SamePeerMultipleRefs two references via the same peer must keep the prefix until
+// the last reference is released (the reason the per-peer count must be an int, not a set).
+func TestAllowedIPs_SamePeerMultipleRefs(t *testing.T) {
+ f := newFakeWG()
+ c := f.counter()
+ p := mustPrefix(t, "10.44.8.0/24")
+
+ mustIncrement(t, c, p, "peerA")
+ mustIncrement(t, c, p, "peerA")
+ if f.adds != 1 {
+ t.Fatalf("expected a single wg add for the same peer, got %d", f.adds)
+ }
+
+ mustDecrement(t, c, p, "peerA")
+ if f.installed[p] != "peerA" {
+ t.Fatalf("prefix must stay while a reference remains, got %q", f.installed[p])
+ }
+ if f.removes != 0 {
+ t.Fatalf("no wg remove expected while a reference remains, got %d", f.removes)
+ }
+
+ mustDecrement(t, c, p, "peerA")
+ if _, ok := f.installed[p]; ok {
+ t.Fatalf("prefix must be removed after last reference")
+ }
+}
+
+// TestAllowedIPs_RefCountAndActive checks the Ref returned to callers (used for the HA-disabled log).
+func TestAllowedIPs_RefCountAndActive(t *testing.T) {
+ f := newFakeWG()
+ c := f.counter()
+ p := mustPrefix(t, "10.44.8.0/24")
+
+ ref := mustIncrement(t, c, p, "peerA")
+ if ref.Count != 1 || ref.Out != "peerA" {
+ t.Fatalf("want {1, peerA}, got {%d, %q}", ref.Count, ref.Out)
+ }
+ ref = mustIncrement(t, c, p, "peerB")
+ if ref.Count != 2 || ref.Out != "peerA" {
+ t.Fatalf("want {2, peerA}, got {%d, %q}", ref.Count, ref.Out)
+ }
+}
+
+// TestAllowedIPs_Flush removes everything installed and clears the counter.
+func TestAllowedIPs_Flush(t *testing.T) {
+ f := newFakeWG()
+ c := f.counter()
+ p1 := mustPrefix(t, "10.44.8.0/24")
+ p2 := mustPrefix(t, "10.44.9.0/24")
+
+ mustIncrement(t, c, p1, "peerA")
+ mustIncrement(t, c, p2, "peerB")
+
+ if err := c.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ if len(f.installed) != 0 {
+ t.Fatalf("expected all prefixes removed, got %v", f.installed)
+ }
+ // After flush, a fresh increment must add again.
+ mustIncrement(t, c, p1, "peerC")
+ if f.installed[p1] != "peerC" {
+ t.Fatalf("counter not reset after flush")
+ }
+}
+
+// TestAllowedIPs_SelfHealAfterSwapAddError ensures a failed add during a swap does not permanently
+// strand the prefix: the next Decrement (or Increment) must retry and install a surviving peer.
+func TestAllowedIPs_SelfHealAfterSwapAddError(t *testing.T) {
+ f := newFakeWG()
+ c := f.counter()
+ p := mustPrefix(t, "10.44.8.0/24")
+
+ mustIncrement(t, c, p, "peerA")
+ mustIncrement(t, c, p, "peerB")
+ mustIncrement(t, c, p, "peerC")
+
+ // Removing the active peerA triggers a swap to a survivor; make the add fail once.
+ f.failAdd = true
+ if _, err := c.Decrement(p, "peerA"); err == nil {
+ t.Fatalf("expected error from failed swap add")
+ }
+ if _, ok := f.installed[p]; ok {
+ t.Fatalf("nothing should be installed after a failed swap add, got %q", f.installed[p])
+ }
+
+ // A later Decrement of a non-active survivor must retry the hand-off (self-heal), not stay stuck.
+ ref := mustDecrement(t, c, p, "peerC")
+ if got := f.installed[p]; got == "" {
+ t.Fatalf("self-heal failed: prefix left unrouted after add recovered")
+ }
+ if ref.Out == "" {
+ t.Fatalf("expected an active peer after self-heal, got empty")
+ }
+}
+
+// TestAllowedIPs_SelfHealAfterRemoveError ensures a failed remove during a swap is retried instead
+// of leaving e.active stuck on a peer that no longer holds references.
+func TestAllowedIPs_SelfHealAfterRemoveError(t *testing.T) {
+ f := newFakeWG()
+ c := f.counter()
+ p := mustPrefix(t, "10.44.8.0/24")
+
+ mustIncrement(t, c, p, "peerA")
+ mustIncrement(t, c, p, "peerB")
+
+ // Releasing active peerA must detach it (remove) then add peerB; fail the remove once.
+ f.failRemove = true
+ if _, err := c.Decrement(p, "peerA"); err == nil {
+ t.Fatalf("expected error from failed remove")
+ }
+
+ // Next Decrement of the non-active survivor retries: removes stale peerA, installs peerB.
+ mustDecrement(t, c, p, "peerB")
+ // peerB had only one ref, so after retry the prefix is fully released.
+ if _, ok := f.installed[p]; ok {
+ t.Fatalf("expected prefix released after self-heal, still on %q", f.installed[p])
+ }
+}
diff --git a/client/internal/routemanager/refcounter/refcounter.go b/client/internal/routemanager/refcounter/refcounter.go
index 27a724f50..917120275 100644
--- a/client/internal/routemanager/refcounter/refcounter.go
+++ b/client/internal/routemanager/refcounter/refcounter.go
@@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) {
return ref, ok
}
+// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the
+// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect
+// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its
+// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied.
+// pred and apply are invoked under the lock, so they must not call back into the counter.
+func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error {
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+
+ var merr *multierror.Error
+ for key, ref := range rm.refCountMap {
+ if pred(ref.Out) {
+ if err := apply(key); err != nil {
+ merr = multierror.Append(merr, err)
+ }
+ }
+ }
+ return nberrors.FormatErrorOrNil(merr)
+}
+
// Increment increments the reference count for the given key.
// If this is the first reference to the key, the AddFunc is called.
func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) {
diff --git a/client/internal/routemanager/refcounter/refcounter_test.go b/client/internal/routemanager/refcounter/refcounter_test.go
new file mode 100644
index 000000000..79a99c388
--- /dev/null
+++ b/client/internal/routemanager/refcounter/refcounter_test.go
@@ -0,0 +1,47 @@
+package refcounter
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored
+// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive
+// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes.
+func TestReapplyMatching(t *testing.T) {
+ rc := New[netip.Prefix, string, string](
+ func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
+ func(netip.Prefix, string) error { return nil },
+ )
+
+ peerA1 := netip.MustParsePrefix("10.0.0.0/24")
+ peerA2 := netip.MustParsePrefix("10.1.0.0/24")
+ peerB1 := netip.MustParsePrefix("10.2.0.0/24")
+
+ for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
+ _, err := rc.Increment(prefix, peer)
+ require.NoError(t, err)
+ }
+ // a second reference must not make the key applied twice
+ _, err := rc.Increment(peerA1, "peerA")
+ require.NoError(t, err)
+
+ var applied []netip.Prefix
+ err = rc.ReapplyMatching(
+ func(out string) bool { return out == "peerA" },
+ func(key netip.Prefix) error { applied = append(applied, key); return nil },
+ )
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied)
+
+ var none []netip.Prefix
+ err = rc.ReapplyMatching(
+ func(out string) bool { return out == "missing" },
+ func(key netip.Prefix) error { none = append(none, key); return nil },
+ )
+ require.NoError(t, err)
+ assert.Empty(t, none)
+}
diff --git a/client/internal/routemanager/refcounter/types.go b/client/internal/routemanager/refcounter/types.go
index aadac3e25..7da0e17e3 100644
--- a/client/internal/routemanager/refcounter/types.go
+++ b/client/internal/routemanager/refcounter/types.go
@@ -5,5 +5,7 @@ import "net/netip"
// RouteRefCounter is a Counter for Route, it doesn't take any input on Increment and doesn't use any output on Decrement
type RouteRefCounter = Counter[netip.Prefix, struct{}, struct{}]
-// AllowedIPsRefCounter is a Counter for AllowedIPs, it takes a peer key on Increment and passes it back to Decrement
-type AllowedIPsRefCounter = Counter[netip.Prefix, string, string]
+// AllowedIPsRefCounter tracks WireGuard AllowedIPs per prefix. Unlike the generic Counter it is peer-aware:
+// a prefix can be claimed by several peers at once and WireGuard allows a given prefix on exactly one peer,
+// so the counter records the per-peer reference count and swaps the installed peer when the active one is released.
+// See allowedips.go.
diff --git a/client/internal/routemanager/selection.go b/client/internal/routemanager/selection.go
new file mode 100644
index 000000000..6d5feec79
--- /dev/null
+++ b/client/internal/routemanager/selection.go
@@ -0,0 +1,138 @@
+package routemanager
+
+import (
+ "fmt"
+ "slices"
+
+ "github.com/hashicorp/go-multierror"
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/exp/maps"
+
+ nberrors "github.com/netbirdio/netbird/client/errors"
+ "github.com/netbirdio/netbird/route"
+)
+
+// SelectRoutes selects the routes with the given network IDs and applies the
+// new selection. V4/v6 exit-node pairs are expanded automatically. Exit nodes
+// are mutually exclusive: if the selection activates an exit node, every other
+// available exit node is deselected so two can't be active at once. With
+// appendRoute=false the previous selection is replaced instead of extended.
+func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error {
+ if err := m.selectRoutes(ids, appendRoute); err != nil {
+ return err
+ }
+ m.TriggerSelection(m.GetClientRoutes())
+ return nil
+}
+
+// DeselectRoutes removes the routes with the given network IDs from the
+// selection and applies the change. V4/v6 exit-node pairs are expanded
+// automatically.
+func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error {
+ if err := m.deselectRoutes(ids); err != nil {
+ return err
+ }
+ m.TriggerSelection(m.GetClientRoutes())
+ return nil
+}
+
+func (m *DefaultManager) deselectRoutes(ids []route.NetID) error {
+ routesMap := m.GetClientRoutesWithNetID()
+ routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap)
+
+ log.Debugf("deselecting routes with ids: %v", routes)
+
+ if err := m.routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil {
+ return fmt.Errorf("deselect routes: %w", err)
+ }
+
+ return nil
+}
+
+// SelectAllRoutes selects every available route and applies the selection.
+// Exit nodes stay mutually exclusive: at most one remains active.
+func (m *DefaultManager) SelectAllRoutes() {
+ m.selectAllRoutes()
+ m.TriggerSelection(m.GetClientRoutes())
+}
+
+func (m *DefaultManager) selectAllRoutes() {
+ m.routeSelector.SelectAllRoutes()
+
+ // Select-all wipes every explicit selection, so exit nodes fall back to
+ // management's auto-apply flags — which may mark several at once.
+ // Reconcile immediately so at most one exit node stays active instead of
+ // waiting for the next network map to enforce it.
+ m.mux.Lock()
+ defer m.mux.Unlock()
+ m.updateRouteSelectorFromManagement(m.clientRoutes)
+}
+
+// DeselectAllRoutes deselects every route and applies the change.
+func (m *DefaultManager) DeselectAllRoutes() {
+ m.routeSelector.DeselectAllRoutes()
+ m.TriggerSelection(m.GetClientRoutes())
+}
+
+func (m *DefaultManager) selectRoutes(ids []route.NetID, appendRoute bool) error {
+ routesMap := m.GetClientRoutesWithNetID()
+ routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap)
+ allIDs := maps.Keys(routesMap)
+
+ log.Debugf("selecting routes with ids: %v", routes)
+
+ // A partial failure (e.g. an unknown ID in the request) still selects the
+ // valid routes, so exclusivity below must run regardless of the error.
+ var merr *multierror.Error
+ if err := m.routeSelector.SelectRoutes(routes, appendRoute, allIDs); err != nil {
+ merr = multierror.Append(merr, fmt.Errorf("select routes: %w", err))
+ }
+
+ // Exit nodes are mutually exclusive: if this selection activates an
+ // exit node, deselect every other available exit node so two can't be
+ // selected at once. Non-exit route selections are left untouched.
+ if requestActivatesExitNode(routes, routesMap) {
+ if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 {
+ if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil {
+ merr = multierror.Append(merr, fmt.Errorf("deselect sibling exit nodes: %w", err))
+ }
+ }
+ }
+
+ return nberrors.FormatErrorOrNil(merr)
+}
+
+func isExitNodeRoutes(routes []*route.Route) bool {
+ return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network))
+}
+
+// requestActivatesExitNode reports whether any requested NetID maps to an exit
+// node (default route) in the current route table.
+func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool {
+ for _, id := range requested {
+ if isExitNodeRoutes(routesMap[id]) {
+ return true
+ }
+ }
+ return false
+}
+
+// otherExitNodeIDs returns every available exit-node NetID that is not in the
+// requested set — the siblings to deselect so a single exit node stays active.
+func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID {
+ keep := make(map[route.NetID]struct{}, len(requested))
+ for _, id := range requested {
+ keep[id] = struct{}{}
+ }
+ var others []route.NetID
+ for id, routes := range routesMap {
+ if !isExitNodeRoutes(routes) {
+ continue
+ }
+ if _, ok := keep[id]; ok {
+ continue
+ }
+ others = append(others, id)
+ }
+ return others
+}
diff --git a/client/internal/routemanager/selection_test.go b/client/internal/routemanager/selection_test.go
new file mode 100644
index 000000000..6066b5661
--- /dev/null
+++ b/client/internal/routemanager/selection_test.go
@@ -0,0 +1,129 @@
+package routemanager
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/routeselector"
+ "github.com/netbirdio/netbird/route"
+)
+
+func v6ExitRoute(netID, peer string) *route.Route {
+ return &route.Route{
+ NetID: route.NetID(netID),
+ Network: netip.MustParsePrefix("::/0"),
+ Peer: peer,
+ }
+}
+
+func newSelectionTestManager() *DefaultManager {
+ return &DefaultManager{
+ routeSelector: routeselector.NewRouteSelector(),
+ clientRoutes: route.HAMap{
+ "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)},
+ "exitA-v6|::/0": {v6ExitRoute("exitA-v6", "p1")},
+ "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)},
+ "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
+ },
+ }
+}
+
+func TestSelectRoutes_ExitNodeExclusivity(t *testing.T) {
+ m := newSelectionTestManager()
+
+ // Selecting an exit node selects its v6 pair and deselects the sibling.
+ require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true))
+ assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA should be selected")
+ assert.True(t, m.routeSelector.IsSelected("exitA-v6"), "the v6 pair follows its v4 base")
+ assert.False(t, m.routeSelector.IsSelected("exitB"), "the sibling exit node must be deselected")
+
+ // Switching to the sibling deselects the previous exit node and its v6 pair.
+ require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true))
+ assert.True(t, m.routeSelector.IsSelected("exitB"), "exitB should now be selected")
+ assert.False(t, m.routeSelector.IsSelected("exitA"), "the previous exit node must be deselected")
+ assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "the previous exit node's v6 pair must be deselected")
+ assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
+
+ // Selecting a non-exit route leaves the active exit node alone.
+ require.NoError(t, m.selectRoutes([]route.NetID{"lan"}, true))
+ assert.True(t, m.routeSelector.IsSelected("exitB"), "selecting a non-exit route keeps the exit node")
+
+ // Deselecting the active exit node turns every exit node off.
+ require.NoError(t, m.deselectRoutes([]route.NetID{"exitB"}))
+ assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB should be deselected")
+ assert.False(t, m.routeSelector.IsSelected("exitA"), "exitA stays deselected")
+ assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
+}
+
+func TestSelectRoutes_PartialErrorStillEnforcesExclusivity(t *testing.T) {
+ // The unknown ID must be reported, but the valid exit node in the same
+ // request is still selected — so its sibling must still be deselected.
+ // Both orderings are covered: processing must continue past the invalid
+ // ID wherever it sits in the request.
+ requests := map[string][]route.NetID{
+ "invalid id first": {"missing", "exitB"},
+ "invalid id last": {"exitB", "missing"},
+ }
+
+ for name, ids := range requests {
+ t.Run(name, func(t *testing.T) {
+ m := newSelectionTestManager()
+
+ require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true))
+
+ err := m.selectRoutes(ids, true)
+ assert.Error(t, err, "unknown id must be reported")
+ assert.True(t, m.routeSelector.IsSelected("exitB"), "valid exit node from the request is selected")
+ assert.False(t, m.routeSelector.IsSelected("exitA"), "sibling exit node must be deselected despite the error")
+ assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "sibling's v6 pair must be deselected too")
+ })
+ }
+}
+
+func TestSelectAllRoutes_KeepsSingleExitNode(t *testing.T) {
+ // Both exit nodes are marked for auto-apply by management
+ // (SkipAutoApply=false), the state where select-all could turn on two at
+ // once without the immediate reconciliation.
+ m := &DefaultManager{
+ routeSelector: routeselector.NewRouteSelector(),
+ clientRoutes: route.HAMap{
+ "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
+ "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
+ "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
+ },
+ }
+
+ require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true))
+
+ m.selectAllRoutes()
+
+ assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit routes are all selected")
+ assert.True(t, m.routeSelector.IsSelected("exitA"), "the deterministic management pick stays active")
+ assert.False(t, m.routeSelector.IsSelected("exitB"), "select-all must not leave a second exit node active")
+}
+
+func TestSelectRoutes_UnknownRoute(t *testing.T) {
+ m := newSelectionTestManager()
+
+ assert.Error(t, m.selectRoutes([]route.NetID{"missing"}, true), "selecting an unavailable route must fail")
+ assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail")
+}
+
+func TestExitNodeSelectionHelpers(t *testing.T) {
+ routesMap := map[route.NetID][]*route.Route{
+ "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}},
+ "exitB": {{Network: netip.MustParsePrefix("::/0")}},
+ "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}},
+ }
+
+ assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node")
+ assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node")
+ assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node")
+ assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node")
+
+ others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"})
+ assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored")
+}
diff --git a/client/internal/routemanager/static/route.go b/client/internal/routemanager/static/route.go
index d480fdf00..8ba03d090 100644
--- a/client/internal/routemanager/static/route.go
+++ b/client/internal/routemanager/static/route.go
@@ -15,6 +15,11 @@ type Route struct {
route *route.Route
routeRefCounter *refcounter.RouteRefCounter
allowedIPsRefcounter *refcounter.AllowedIPsRefCounter
+ // currentPeerKey is the routing peer this watcher currently has the prefix installed on
+ // (the HA winner elected by the watcher). It can differ from route.Peer and change on
+ // failover, so it is recorded on AddAllowedIPs and used on RemoveAllowedIPs to decrement
+ // the exact peer that was incremented.
+ currentPeerKey string
}
func NewRoute(params common.HandlerParams) *Route {
@@ -52,12 +57,15 @@ func (r *Route) AddAllowedIPs(peerKey string) error {
ref.Out,
)
}
+ r.currentPeerKey = peerKey
return nil
}
func (r *Route) RemoveAllowedIPs() error {
- if _, err := r.allowedIPsRefcounter.Decrement(r.route.Network); err != nil {
- return err
+ var err error
+ if _, decErr := r.allowedIPsRefcounter.Decrement(r.route.Network, r.currentPeerKey); decErr != nil {
+ err = fmt.Errorf("remove allowed IP %s: %w", r.route.Network, decErr)
}
- return nil
+ r.currentPeerKey = ""
+ return err
}
diff --git a/client/internal/routemanager/sysctl/sysctl_linux.go b/client/internal/routemanager/sysctl/sysctl_linux.go
index f96a57f37..bb131c691 100644
--- a/client/internal/routemanager/sysctl/sysctl_linux.go
+++ b/client/internal/routemanager/sysctl/sysctl_linux.go
@@ -20,6 +20,8 @@ const (
rpFilterPath = "net.ipv4.conf.all.rp_filter"
rpFilterInterfacePath = "net.ipv4.conf.%s.rp_filter"
srcValidMarkPath = "net.ipv4.conf.all.src_valid_mark"
+ percentEscape = "%25"
+ dotEscape = "%2E"
)
type iface interface {
@@ -56,7 +58,7 @@ func Setup(wgIface iface) (map[string]int, error) {
continue
}
- i := fmt.Sprintf(rpFilterInterfacePath, intf.Name)
+ i := fmt.Sprintf(rpFilterInterfacePath, EscapeInterfaceName(intf.Name))
oldVal, err := Set(i, 2, true)
if err != nil {
result = multierror.Append(result, err)
@@ -68,9 +70,20 @@ func Setup(wgIface iface) (map[string]int, error) {
return keys, nberrors.FormatErrorOrNil(result)
}
+// EscapeInterfaceName escapes '%' and '.' in an interface name (e.g. VLANs
+// like eth0.100) so the name survives the dot-to-slash conversion in Set.
+func EscapeInterfaceName(name string) string {
+ safe := strings.ReplaceAll(name, "%", percentEscape)
+ return strings.ReplaceAll(safe, ".", dotEscape)
+}
+
// Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1
func Set(key string, desiredValue int, onlyIfOne bool) (int, error) {
- path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/"))
+ path := strings.ReplaceAll(key, ".", "/")
+ // Unescape interface dots and percent signs
+ path = strings.ReplaceAll(path, dotEscape, ".")
+ path = strings.ReplaceAll(path, percentEscape, "%")
+ path = fmt.Sprintf("/proc/sys/%s", path)
currentValue, err := os.ReadFile(path)
if err != nil {
return -1, fmt.Errorf("read sysctl %s: %w", key, err)
diff --git a/client/internal/routemanager/systemops/routeselection_windows_test.go b/client/internal/routemanager/systemops/routeselection_windows_test.go
new file mode 100644
index 000000000..108338dd9
--- /dev/null
+++ b/client/internal/routemanager/systemops/routeselection_windows_test.go
@@ -0,0 +1,82 @@
+//go:build windows
+
+package systemops
+
+import (
+ "math"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSortRouteCandidates(t *testing.T) {
+ tests := []struct {
+ name string
+ candidates []candidateRoute
+ wantOrder []uint32
+ }{
+ {
+ name: "longest prefix wins over metrics",
+ candidates: []candidateRoute{
+ {interfaceIndex: 1, prefixLength: 0, routeMetric: 0, interfaceMetric: 5},
+ {interfaceIndex: 2, prefixLength: 24, routeMetric: 100, interfaceMetric: 50},
+ },
+ wantOrder: []uint32{2, 1},
+ },
+ {
+ // Windows ranks equal-length prefixes by route metric + interface metric,
+ // so a higher route metric on a low metric interface can still win.
+ name: "combined metric beats route metric alone",
+ candidates: []candidateRoute{
+ {interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100},
+ {interfaceIndex: 5, prefixLength: 0, routeMetric: 10, interfaceMetric: 5},
+ },
+ wantOrder: []uint32{5, 8},
+ },
+ {
+ name: "lower combined metric wins",
+ candidates: []candidateRoute{
+ {interfaceIndex: 5, prefixLength: 0, routeMetric: 300, interfaceMetric: 5},
+ {interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100},
+ },
+ wantOrder: []uint32{8, 5},
+ },
+ {
+ name: "equal combined metric falls back to route metric",
+ candidates: []candidateRoute{
+ {interfaceIndex: 1, prefixLength: 0, routeMetric: 20, interfaceMetric: 10},
+ {interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 25},
+ },
+ wantOrder: []uint32{2, 1},
+ },
+ {
+ // The metrics are uint32 on the Windows side, so the sum must not wrap.
+ name: "combined metric beyond the uint32 range",
+ candidates: []candidateRoute{
+ {interfaceIndex: 1, prefixLength: 0, routeMetric: math.MaxUint32, interfaceMetric: 5},
+ {interfaceIndex: 2, prefixLength: 0, routeMetric: math.MaxUint32 - 10, interfaceMetric: 5},
+ },
+ wantOrder: []uint32{2, 1},
+ },
+ {
+ name: "unknown interface metric ranks on route metric only",
+ candidates: []candidateRoute{
+ {interfaceIndex: 1, prefixLength: 0, routeMetric: 30, interfaceMetric: -1},
+ {interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 10},
+ },
+ wantOrder: []uint32{2, 1},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ sortRouteCandidates(tt.candidates)
+
+ got := make([]uint32, 0, len(tt.candidates))
+ for _, c := range tt.candidates {
+ got = append(got, c.interfaceIndex)
+ }
+ assert.Equal(t, tt.wantOrder, got)
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/rt_tables_linux_test.go b/client/internal/routemanager/systemops/rt_tables_linux_test.go
new file mode 100644
index 000000000..bc9cca8b1
--- /dev/null
+++ b/client/internal/routemanager/systemops/rt_tables_linux_test.go
@@ -0,0 +1,69 @@
+//go:build linux && !android
+
+package systemops
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEntryExists(t *testing.T) {
+ tempDir := t.TempDir()
+ tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir)
+
+ content := []string{
+ "1000 reserved",
+ fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName),
+ "9999 other_table",
+ }
+ require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644))
+
+ file, err := os.Open(tempFilePath)
+ require.NoError(t, err)
+ defer func() {
+ assert.NoError(t, file.Close())
+ }()
+
+ tests := []struct {
+ name string
+ id int
+ shouldExist bool
+ err error
+ }{
+ {
+ name: "ExistsWithNetbirdPrefix",
+ id: 7120,
+ shouldExist: true,
+ err: nil,
+ },
+ {
+ name: "ExistsWithDifferentName",
+ id: 1000,
+ shouldExist: true,
+ err: ErrTableIDExists,
+ },
+ {
+ name: "DoesNotExist",
+ id: 1234,
+ shouldExist: false,
+ err: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ exists, err := entryExists(file, tc.id)
+ if tc.err != nil {
+ assert.ErrorIs(t, err, tc.err)
+ } else {
+ assert.NoError(t, err)
+ }
+ assert.Equal(t, tc.shouldExist, exists)
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_android.go b/client/internal/routemanager/systemops/systemops_android.go
index 7cb8dae93..97b4ed8ec 100644
--- a/client/internal/routemanager/systemops/systemops_android.go
+++ b/client/internal/routemanager/systemops/systemops_android.go
@@ -32,8 +32,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error {
return nil
}
-func EnableIPForwarding() error {
- log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
+func EnableV4IPForwarding() error {
+ log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
+ return nil
+}
+
+func EnableV6IPForwarding(string) (map[string]int, error) {
+ log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
+ return map[string]int{}, nil
+}
+
+func DisableV6IPForwarding(map[string]int) error {
return nil
}
diff --git a/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
new file mode 100644
index 000000000..d45028c19
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
@@ -0,0 +1,191 @@
+//go:build (darwin || dragonfly || freebsd || netbsd || openbsd) && privileged
+
+package systemops
+
+import (
+ "fmt"
+ "net"
+ "net/netip"
+ "os/exec"
+ "regexp"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func init() {
+ testCases = append(testCases, []testCase{
+ {
+ name: "To more specific route without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
+ },
+ }...)
+}
+
+func TestConcurrentRoutes(t *testing.T) {
+ baseIP := netip.MustParseAddr("192.0.2.0")
+
+ var intf *net.Interface
+ var nexthop Nexthop
+
+ _, intf = setupDummyInterface(t)
+ nexthop = Nexthop{netip.Addr{}, intf}
+
+ r := New(nil, nil)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 1024; i++ {
+ wg.Add(1)
+ go func(ip netip.Addr) {
+ defer wg.Done()
+ prefix := netip.PrefixFrom(ip, 32)
+ if err := r.addToRouteTable(prefix, nexthop); err != nil {
+ t.Errorf("Failed to add route for %s: %v", prefix, err)
+ }
+ }(baseIP)
+ baseIP = baseIP.Next()
+ }
+
+ wg.Wait()
+
+ baseIP = netip.MustParseAddr("192.0.2.0")
+
+ for i := 0; i < 1024; i++ {
+ wg.Add(1)
+ go func(ip netip.Addr) {
+ defer wg.Done()
+ prefix := netip.PrefixFrom(ip, 32)
+ if err := r.removeFromRouteTable(prefix, nexthop); err != nil {
+ t.Errorf("Failed to remove route for %s: %v", prefix, err)
+ }
+ }(baseIP)
+ baseIP = baseIP.Next()
+ }
+
+ wg.Wait()
+}
+
+func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
+ t.Helper()
+
+ if runtime.GOOS == "darwin" {
+ err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run()
+ require.NoError(t, err, "Failed to create loopback alias")
+
+ t.Cleanup(func() {
+ err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run()
+ assert.NoError(t, err, "Failed to remove loopback alias")
+ })
+
+ return intf
+ }
+
+ prefix, err := netip.ParsePrefix(ipAddressCIDR)
+ require.NoError(t, err, "Failed to parse prefix")
+
+ netIntf, err := net.InterfaceByName(intf)
+ require.NoError(t, err, "Failed to get interface by name")
+
+ nexthop := Nexthop{netip.Addr{}, netIntf}
+
+ r := New(nil, nil)
+ err = r.addToRouteTable(prefix, nexthop)
+ require.NoError(t, err, "Failed to add route to table")
+
+ t.Cleanup(func() {
+ err := r.removeFromRouteTable(prefix, nexthop)
+ assert.NoError(t, err, "Failed to remove route from table")
+ })
+
+ return intf
+}
+
+func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) {
+ t.Helper()
+
+ var originalNexthop net.IP
+ if dstCIDR == "0.0.0.0/0" {
+ var err error
+ originalNexthop, err = fetchOriginalGateway()
+ if err != nil {
+ t.Logf("Failed to fetch original gateway: %v", err)
+ }
+
+ if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil {
+ t.Logf("Failed to delete route: %v, output: %s", err, output)
+ }
+ }
+
+ t.Cleanup(func() {
+ if originalNexthop != nil {
+ err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run()
+ assert.NoError(t, err, "Failed to restore original route")
+ }
+ })
+
+ err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run()
+ require.NoError(t, err, "Failed to add route")
+
+ t.Cleanup(func() {
+ err := exec.Command("route", "delete", "-net", dstCIDR).Run()
+ assert.NoError(t, err, "Failed to remove route")
+ })
+}
+
+func fetchOriginalGateway() (net.IP, error) {
+ output, err := exec.Command("route", "-n", "get", "default").CombinedOutput()
+ if err != nil {
+ return nil, err
+ }
+
+ matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output))
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("gateway not found")
+ }
+
+ return net.ParseIP(matches[1]), nil
+}
+
+// setupDummyInterface creates a dummy tun interface for FreeBSD route testing
+func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) {
+ t.Helper()
+
+ if runtime.GOOS == "darwin" {
+ return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"}
+ }
+
+ output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput()
+ require.NoError(t, err, "Failed to create tun interface: %s", string(output))
+
+ tunName := strings.TrimSpace(string(output))
+
+ output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput()
+ require.NoError(t, err, "Failed to configure tun interface: %s", string(output))
+
+ intf, err := net.InterfaceByName(tunName)
+ require.NoError(t, err, "Failed to get interface by name")
+
+ t.Cleanup(func() {
+ if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil {
+ t.Logf("Failed to destroy tun interface %s: %v", tunName, err)
+ }
+ })
+
+ return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf
+}
+
+func setupDummyInterfacesAndRoutes(t *testing.T) {
+ t.Helper()
+
+ defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
+ addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy)
+
+ otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
+ addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy)
+}
diff --git a/client/internal/routemanager/systemops/systemops_bsd_test.go b/client/internal/routemanager/systemops/systemops_bsd_test.go
index ec4fc406e..9650945b3 100644
--- a/client/internal/routemanager/systemops/systemops_bsd_test.go
+++ b/client/internal/routemanager/systemops/systemops_bsd_test.go
@@ -3,79 +3,24 @@
package systemops
import (
- "fmt"
- "net"
- "net/netip"
- "os/exec"
- "regexp"
- "runtime"
- "strings"
- "sync"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
"golang.org/x/net/route"
)
+// Interface names used by the shared routing test fixtures. Kept untagged (no
+// privileged build tag) so the non-privileged test files in this package compile.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedVPNint = "utun100"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedExternalInt = "lo0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedInternalInt = "lo0"
-func init() {
- testCases = append(testCases, []testCase{
- {
- name: "To more specific route without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
- },
- }...)
-}
-
-func TestConcurrentRoutes(t *testing.T) {
- baseIP := netip.MustParseAddr("192.0.2.0")
-
- var intf *net.Interface
- var nexthop Nexthop
-
- _, intf = setupDummyInterface(t)
- nexthop = Nexthop{netip.Addr{}, intf}
-
- r := New(nil, nil)
-
- var wg sync.WaitGroup
- for i := 0; i < 1024; i++ {
- wg.Add(1)
- go func(ip netip.Addr) {
- defer wg.Done()
- prefix := netip.PrefixFrom(ip, 32)
- if err := r.addToRouteTable(prefix, nexthop); err != nil {
- t.Errorf("Failed to add route for %s: %v", prefix, err)
- }
- }(baseIP)
- baseIP = baseIP.Next()
- }
-
- wg.Wait()
-
- baseIP = netip.MustParseAddr("192.0.2.0")
-
- for i := 0; i < 1024; i++ {
- wg.Add(1)
- go func(ip netip.Addr) {
- defer wg.Done()
- prefix := netip.PrefixFrom(ip, 32)
- if err := r.removeFromRouteTable(prefix, nexthop); err != nil {
- t.Errorf("Failed to remove route for %s: %v", prefix, err)
- }
- }(baseIP)
- baseIP = baseIP.Next()
- }
-
- wg.Wait()
-}
-
func TestBits(t *testing.T) {
tests := []struct {
name string
@@ -122,122 +67,3 @@ func TestBits(t *testing.T) {
})
}
}
-
-func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
- t.Helper()
-
- if runtime.GOOS == "darwin" {
- err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run()
- require.NoError(t, err, "Failed to create loopback alias")
-
- t.Cleanup(func() {
- err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run()
- assert.NoError(t, err, "Failed to remove loopback alias")
- })
-
- return intf
- }
-
- prefix, err := netip.ParsePrefix(ipAddressCIDR)
- require.NoError(t, err, "Failed to parse prefix")
-
- netIntf, err := net.InterfaceByName(intf)
- require.NoError(t, err, "Failed to get interface by name")
-
- nexthop := Nexthop{netip.Addr{}, netIntf}
-
- r := New(nil, nil)
- err = r.addToRouteTable(prefix, nexthop)
- require.NoError(t, err, "Failed to add route to table")
-
- t.Cleanup(func() {
- err := r.removeFromRouteTable(prefix, nexthop)
- assert.NoError(t, err, "Failed to remove route from table")
- })
-
- return intf
-}
-
-func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) {
- t.Helper()
-
- var originalNexthop net.IP
- if dstCIDR == "0.0.0.0/0" {
- var err error
- originalNexthop, err = fetchOriginalGateway()
- if err != nil {
- t.Logf("Failed to fetch original gateway: %v", err)
- }
-
- if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil {
- t.Logf("Failed to delete route: %v, output: %s", err, output)
- }
- }
-
- t.Cleanup(func() {
- if originalNexthop != nil {
- err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run()
- assert.NoError(t, err, "Failed to restore original route")
- }
- })
-
- err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run()
- require.NoError(t, err, "Failed to add route")
-
- t.Cleanup(func() {
- err := exec.Command("route", "delete", "-net", dstCIDR).Run()
- assert.NoError(t, err, "Failed to remove route")
- })
-}
-
-func fetchOriginalGateway() (net.IP, error) {
- output, err := exec.Command("route", "-n", "get", "default").CombinedOutput()
- if err != nil {
- return nil, err
- }
-
- matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output))
- if len(matches) == 0 {
- return nil, fmt.Errorf("gateway not found")
- }
-
- return net.ParseIP(matches[1]), nil
-}
-
-// setupDummyInterface creates a dummy tun interface for FreeBSD route testing
-func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) {
- t.Helper()
-
- if runtime.GOOS == "darwin" {
- return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"}
- }
-
- output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput()
- require.NoError(t, err, "Failed to create tun interface: %s", string(output))
-
- tunName := strings.TrimSpace(string(output))
-
- output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput()
- require.NoError(t, err, "Failed to configure tun interface: %s", string(output))
-
- intf, err := net.InterfaceByName(tunName)
- require.NoError(t, err, "Failed to get interface by name")
-
- t.Cleanup(func() {
- if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil {
- t.Logf("Failed to destroy tun interface %s: %v", tunName, err)
- }
- })
-
- return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf
-}
-
-func setupDummyInterfacesAndRoutes(t *testing.T) {
- t.Helper()
-
- defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
- addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy)
-
- otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
- addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy)
-}
diff --git a/client/internal/routemanager/systemops/systemops_dialer_test.go b/client/internal/routemanager/systemops/systemops_dialer_test.go
new file mode 100644
index 000000000..f00f9099c
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_dialer_test.go
@@ -0,0 +1,17 @@
+//go:build !android && !ios
+
+package systemops
+
+import (
+ "context"
+ "net"
+)
+
+// dialer is shared by the per-platform routing test cases. Kept untagged (no
+// privileged build tag) so the non-privileged test files compile on every platform.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
+type dialer interface {
+ Dial(network, address string) (net.Conn, error)
+ DialContext(ctx context.Context, network, address string) (net.Conn, error)
+}
diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go
index 5695c40c3..c4f739c30 100644
--- a/client/internal/routemanager/systemops/systemops_generic_test.go
+++ b/client/internal/routemanager/systemops/systemops_generic_test.go
@@ -1,4 +1,4 @@
-//go:build !android && !ios
+//go:build !android && !ios && privileged
package systemops
@@ -26,11 +26,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
-type dialer interface {
- Dial(network, address string) (net.Conn, error)
- DialContext(ctx context.Context, network, address string) (net.Conn, error)
-}
-
func TestAddVPNRoute(t *testing.T) {
testCases := []struct {
name string
@@ -515,125 +510,3 @@ func setupTestEnv(t *testing.T) {
// unique route in vpn table
setupRouteAndCleanup(t, r, netip.MustParsePrefix("172.16.0.0/12"), intf)
}
-
-func TestIsVpnRoute(t *testing.T) {
- tests := []struct {
- name string
- addr string
- vpnRoutes []string
- localRoutes []string
- expectedVpn bool
- expectedPrefix netip.Prefix
- }{
- {
- name: "Match in VPN routes",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Match in local routes",
- addr: "10.1.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"),
- },
- {
- name: "No match",
- addr: "172.16.0.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.Prefix{},
- },
- {
- name: "Default route ignored",
- addr: "192.168.1.1",
- vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Default route matches but ignored",
- addr: "172.16.1.1",
- vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.Prefix{},
- },
- {
- name: "Longest prefix match local",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.0.0/16"},
- localRoutes: []string{"192.168.1.0/24"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Longest prefix match local multiple",
- addr: "192.168.0.1",
- vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
- localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"),
- },
- {
- name: "Longest prefix match vpn",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"192.168.0.0/16"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Longest prefix match vpn multiple",
- addr: "192.168.0.1",
- vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
- localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"),
- },
- {
- name: "Duplicate prefix in both",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"192.168.1.0/24"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- addr, err := netip.ParseAddr(tt.addr)
- if err != nil {
- t.Fatalf("Failed to parse address %s: %v", tt.addr, err)
- }
-
- var vpnRoutes, localRoutes []netip.Prefix
- for _, route := range tt.vpnRoutes {
- prefix, err := netip.ParsePrefix(route)
- if err != nil {
- t.Fatalf("Failed to parse VPN route %s: %v", route, err)
- }
- vpnRoutes = append(vpnRoutes, prefix)
- }
-
- for _, route := range tt.localRoutes {
- prefix, err := netip.ParsePrefix(route)
- if err != nil {
- t.Fatalf("Failed to parse local route %s: %v", route, err)
- }
- localRoutes = append(localRoutes, prefix)
- }
-
- isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes)
- assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value")
- assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix")
- })
- }
-}
diff --git a/client/internal/routemanager/systemops/systemops_ios.go b/client/internal/routemanager/systemops/systemops_ios.go
index 99a363371..0cccd4962 100644
--- a/client/internal/routemanager/systemops/systemops_ios.go
+++ b/client/internal/routemanager/systemops/systemops_ios.go
@@ -58,8 +58,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error {
return nil
}
-func EnableIPForwarding() error {
- log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
+func EnableV4IPForwarding() error {
+ log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
+ return nil
+}
+
+func EnableV6IPForwarding(string) (map[string]int, error) {
+ log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
+ return map[string]int{}, nil
+}
+
+func DisableV6IPForwarding(map[string]int) error {
return nil
}
diff --git a/client/internal/routemanager/systemops/systemops_isvpnroute_test.go b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go
new file mode 100644
index 000000000..677fe1287
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go
@@ -0,0 +1,132 @@
+//go:build !android && !ios
+
+package systemops
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsVpnRoute(t *testing.T) {
+ tests := []struct {
+ name string
+ addr string
+ vpnRoutes []string
+ localRoutes []string
+ expectedVpn bool
+ expectedPrefix netip.Prefix
+ }{
+ {
+ name: "Match in VPN routes",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Match in local routes",
+ addr: "10.1.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"),
+ },
+ {
+ name: "No match",
+ addr: "172.16.0.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.Prefix{},
+ },
+ {
+ name: "Default route ignored",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Default route matches but ignored",
+ addr: "172.16.1.1",
+ vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.Prefix{},
+ },
+ {
+ name: "Longest prefix match local",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.0.0/16"},
+ localRoutes: []string{"192.168.1.0/24"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Longest prefix match local multiple",
+ addr: "192.168.0.1",
+ vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
+ localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"),
+ },
+ {
+ name: "Longest prefix match vpn",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"192.168.0.0/16"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Longest prefix match vpn multiple",
+ addr: "192.168.0.1",
+ vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
+ localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"),
+ },
+ {
+ name: "Duplicate prefix in both",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"192.168.1.0/24"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ addr, err := netip.ParseAddr(tt.addr)
+ if err != nil {
+ t.Fatalf("Failed to parse address %s: %v", tt.addr, err)
+ }
+
+ var vpnRoutes, localRoutes []netip.Prefix
+ for _, route := range tt.vpnRoutes {
+ prefix, err := netip.ParsePrefix(route)
+ if err != nil {
+ t.Fatalf("Failed to parse VPN route %s: %v", route, err)
+ }
+ vpnRoutes = append(vpnRoutes, prefix)
+ }
+
+ for _, route := range tt.localRoutes {
+ prefix, err := netip.ParsePrefix(route)
+ if err != nil {
+ t.Fatalf("Failed to parse local route %s: %v", route, err)
+ }
+ localRoutes = append(localRoutes, prefix)
+ }
+
+ isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes)
+ assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value")
+ assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix")
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_linux.go b/client/internal/routemanager/systemops/systemops_linux.go
index 8c6b7d9a9..7d608d886 100644
--- a/client/internal/routemanager/systemops/systemops_linux.go
+++ b/client/internal/routemanager/systemops/systemops_linux.go
@@ -763,13 +763,10 @@ func flushRoutes(tableID, family int) error {
return nberrors.FormatErrorOrNil(result)
}
-func EnableIPForwarding() error {
+func EnableV4IPForwarding() error {
if _, err := sysctl.Set(ipv4ForwardingPath, 1, false); err != nil {
return err
}
- if _, err := sysctl.Set(ipv6ForwardingPath, 1, false); err != nil {
- log.Warnf("failed to enable IPv6 forwarding: %v", err)
- }
return nil
}
diff --git a/client/internal/routemanager/systemops/systemops_linux_test.go b/client/internal/routemanager/systemops/systemops_linux_test.go
index 880296d91..06c528ce5 100644
--- a/client/internal/routemanager/systemops/systemops_linux_test.go
+++ b/client/internal/routemanager/systemops/systemops_linux_test.go
@@ -1,13 +1,10 @@
-//go:build !android
+//go:build linux && !android && privileged
package systemops
import (
"errors"
- "fmt"
"net"
- "os"
- "strings"
"syscall"
"testing"
@@ -18,10 +15,6 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager/vars"
)
-var expectedVPNint = "wgtest0"
-var expectedExternalInt = "dummyext0"
-var expectedInternalInt = "dummyint0"
-
func init() {
testCases = append(testCases, []testCase{
{
@@ -33,62 +26,6 @@ func init() {
}...)
}
-func TestEntryExists(t *testing.T) {
- tempDir := t.TempDir()
- tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir)
-
- content := []string{
- "1000 reserved",
- fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName),
- "9999 other_table",
- }
- require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644))
-
- file, err := os.Open(tempFilePath)
- require.NoError(t, err)
- defer func() {
- assert.NoError(t, file.Close())
- }()
-
- tests := []struct {
- name string
- id int
- shouldExist bool
- err error
- }{
- {
- name: "ExistsWithNetbirdPrefix",
- id: 7120,
- shouldExist: true,
- err: nil,
- },
- {
- name: "ExistsWithDifferentName",
- id: 1000,
- shouldExist: true,
- err: ErrTableIDExists,
- },
- {
- name: "DoesNotExist",
- id: 1234,
- shouldExist: false,
- err: nil,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- exists, err := entryExists(file, tc.id)
- if tc.err != nil {
- assert.ErrorIs(t, err, tc.err)
- } else {
- assert.NoError(t, err)
- }
- assert.Equal(t, tc.shouldExist, exists)
- })
- }
-}
-
func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) string {
t.Helper()
diff --git a/client/internal/routemanager/systemops/systemops_nonlinux.go b/client/internal/routemanager/systemops/systemops_nonlinux.go
index 016a62ebd..837ac0cd2 100644
--- a/client/internal/routemanager/systemops/systemops_nonlinux.go
+++ b/client/internal/routemanager/systemops/systemops_nonlinux.go
@@ -43,8 +43,17 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error
return r.genericRemoveVPNRoute(prefix, intf)
}
-func EnableIPForwarding() error {
- log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
+func EnableV4IPForwarding() error {
+ log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
+ return nil
+}
+
+func EnableV6IPForwarding(string) (map[string]int, error) {
+ log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
+ return map[string]int{}, nil
+}
+
+func DisableV6IPForwarding(map[string]int) error {
return nil
}
diff --git a/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
new file mode 100644
index 000000000..9be267980
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
@@ -0,0 +1,15 @@
+//go:build linux && !android
+
+package systemops
+
+// Interface names used by the shared routing test fixtures. Kept untagged (no
+// privileged build tag) so the non-privileged test files in this package compile.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedVPNint = "wgtest0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedExternalInt = "dummyext0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedInternalInt = "dummyint0"
diff --git a/client/internal/routemanager/systemops/systemops_routing_data_test.go b/client/internal/routemanager/systemops/systemops_routing_data_test.go
new file mode 100644
index 000000000..16f17f5b9
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_routing_data_test.go
@@ -0,0 +1,83 @@
+//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+
+package systemops
+
+import (
+ "net"
+
+ nbnet "github.com/netbirdio/netbird/client/net"
+)
+
+// Shared, non-privileged routing test fixtures. The privileged TestRouting (and its
+// per-platform init() appenders) consume these; they live here so the unprivileged
+// BSD/darwin test files compile without the privileged build tag.
+
+type PacketExpectation struct {
+ SrcIP net.IP
+ DstIP net.IP
+ SrcPort int
+ DstPort int
+ UDP bool
+ TCP bool
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+type testCase struct {
+ name string
+ expectedInterface string
+ dialer dialer
+ expectedPacket PacketExpectation
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var testCases = []testCase{
+ {
+ name: "To external host without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
+ },
+ {
+ name: "To external host with custom dialer via physical interface",
+ expectedInterface: expectedExternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
+ },
+
+ {
+ name: "To duplicate internal route with custom dialer via physical interface",
+ expectedInterface: expectedInternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
+ },
+ {
+ name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
+ expectedInterface: expectedInternalInt,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
+ },
+
+ {
+ name: "To unique vpn route with custom dialer via physical interface",
+ expectedInterface: expectedExternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53),
+ },
+ {
+ name: "To unique vpn route without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
+ },
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation {
+ return PacketExpectation{
+ SrcIP: net.ParseIP(srcIP),
+ DstIP: net.ParseIP(dstIP),
+ SrcPort: srcPort,
+ DstPort: dstPort,
+ UDP: true,
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_unix_test.go b/client/internal/routemanager/systemops/systemops_unix_test.go
index 959c697e4..efb0ae4e4 100644
--- a/client/internal/routemanager/systemops/systemops_unix_test.go
+++ b/client/internal/routemanager/systemops/systemops_unix_test.go
@@ -1,4 +1,4 @@
-//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly) && privileged
package systemops
@@ -20,63 +20,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
-type PacketExpectation struct {
- SrcIP net.IP
- DstIP net.IP
- SrcPort int
- DstPort int
- UDP bool
- TCP bool
-}
-
-type testCase struct {
- name string
- expectedInterface string
- dialer dialer
- expectedPacket PacketExpectation
-}
-
-var testCases = []testCase{
- {
- name: "To external host without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
- },
- {
- name: "To external host with custom dialer via physical interface",
- expectedInterface: expectedExternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
- },
-
- {
- name: "To duplicate internal route with custom dialer via physical interface",
- expectedInterface: expectedInternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
- },
- {
- name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
- expectedInterface: expectedInternalInt,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
- },
-
- {
- name: "To unique vpn route with custom dialer via physical interface",
- expectedInterface: expectedExternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53),
- },
- {
- name: "To unique vpn route without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
- },
-}
-
func TestRouting(t *testing.T) {
nbnet.Init()
for _, tc := range testCases {
@@ -102,16 +45,6 @@ func TestRouting(t *testing.T) {
}
}
-func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation {
- return PacketExpectation{
- SrcIP: net.ParseIP(srcIP),
- DstIP: net.ParseIP(dstIP),
- SrcPort: srcPort,
- DstPort: dstPort,
- UDP: true,
- }
-}
-
func startPacketCapture(t *testing.T, intf, filter string) *pcap.Handle {
t.Helper()
diff --git a/client/internal/routemanager/systemops/systemops_windows.go b/client/internal/routemanager/systemops/systemops_windows.go
index 7bce6af80..47d556cf6 100644
--- a/client/internal/routemanager/systemops/systemops_windows.go
+++ b/client/internal/routemanager/systemops/systemops_windows.go
@@ -882,26 +882,40 @@ func getInterfaceMetric(interfaceIndex uint32, family int16) int {
return int(ipInterfaceRow.Metric)
}
-// sortRouteCandidates sorts route candidates by priority: prefix length -> route metric -> interface metric
+// sortRouteCandidates sorts route candidates by priority: prefix length -> combined metric -> route metric.
+// Windows prefers the longest matching prefix and, among prefixes of the same length, the lowest metric, see
+// https://learn.microsoft.com/en-us/windows-hardware/customize/desktop/unattend/microsoft-windows-tcpip-interfaces-interface-routes-route-metric
func sortRouteCandidates(candidates []candidateRoute) {
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].prefixLength != candidates[j].prefixLength {
return candidates[i].prefixLength > candidates[j].prefixLength
}
- if candidates[i].routeMetric != candidates[j].routeMetric {
- return candidates[i].routeMetric < candidates[j].routeMetric
+ mi, mj := combinedMetric(candidates[i]), combinedMetric(candidates[j])
+ if mi != mj {
+ return mi < mj
}
- return candidates[i].interfaceMetric < candidates[j].interfaceMetric
+ return candidates[i].routeMetric < candidates[j].routeMetric
})
}
+// combinedMetric returns the effective metric Windows uses to rank routes with an equal prefix length:
+// the sum of the route metric and the metric of the interface the route is on, see
+// https://learn.microsoft.com/en-us/windows-server/networking/technologies/network-subsystem/net-sub-interface-metric
+// An unknown interface metric contributes nothing.
+func combinedMetric(candidate candidateRoute) uint64 {
+ if candidate.interfaceMetric < 0 {
+ return uint64(candidate.routeMetric)
+ }
+ return uint64(candidate.routeMetric) + uint64(candidate.interfaceMetric)
+}
+
// GetBestInterface finds the best interface for reaching a destination,
// excluding the VPN interface to avoid routing loops.
//
// Route selection priority:
// 1. Longest prefix match (most specific route)
-// 2. Lowest route metric
-// 3. Lowest interface metric
+// 2. Lowest combined metric (route metric + interface metric)
+// 3. Lowest route metric.
func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) {
var skipInterfaceIndex int
if vpnIntf != "" {
@@ -925,7 +939,6 @@ func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) {
return nil, fmt.Errorf("no route to %s", dest)
}
- // Sort routes: prefix length -> route metric -> interface metric
sortRouteCandidates(candidates)
for _, candidate := range candidates {
diff --git a/client/internal/routemanager/systemops/systemops_windows_test.go b/client/internal/routemanager/systemops/systemops_windows_test.go
index 3561adec4..77e349bd6 100644
--- a/client/internal/routemanager/systemops/systemops_windows_test.go
+++ b/client/internal/routemanager/systemops/systemops_windows_test.go
@@ -1,3 +1,5 @@
+//go:build windows && privileged
+
package systemops
import (
diff --git a/client/internal/routemanager/systemops/v6forwarding_linux.go b/client/internal/routemanager/systemops/v6forwarding_linux.go
new file mode 100644
index 000000000..c1e0d4588
--- /dev/null
+++ b/client/internal/routemanager/systemops/v6forwarding_linux.go
@@ -0,0 +1,92 @@
+//go:build !android
+
+package systemops
+
+import (
+ "fmt"
+ "net"
+ "os"
+
+ "github.com/hashicorp/go-multierror"
+ log "github.com/sirupsen/logrus"
+
+ nberrors "github.com/netbirdio/netbird/client/errors"
+ "github.com/netbirdio/netbird/client/internal/routemanager/sysctl"
+)
+
+const (
+ // 1 (default) accepts RAs only while forwarding is off; 2 keeps RA
+ // acceptance on regardless, so RA-installed host defaults survive our
+ // v6 forwarding flip.
+ acceptRAInterfacePath = "net.ipv6.conf.%s.accept_ra"
+ acceptRADefaultPath = "net.ipv6.conf.default.accept_ra"
+ acceptRAProcPathFormat = "/proc/sys/net/ipv6/conf/%s/accept_ra"
+)
+
+// EnableV6IPForwarding bumps accept_ra=2 on host v6 interfaces before flipping
+// forwarding=1, so RA-installed host defaults survive. Returns the prior values
+// of sysctls we actually changed; entries already at the target are omitted.
+func EnableV6IPForwarding(wgIfaceName string) (map[string]int, error) {
+ saved := map[string]int{}
+ bumpAcceptRA(saved, wgIfaceName)
+
+ oldVal, err := sysctl.Set(ipv6ForwardingPath, 1, false)
+ if err != nil {
+ return saved, err
+ }
+ if oldVal != 1 {
+ saved[ipv6ForwardingPath] = oldVal
+ }
+ return saved, nil
+}
+
+// DisableV6IPForwarding restores what EnableV6IPForwarding captured.
+func DisableV6IPForwarding(saved map[string]int) error {
+ var result *multierror.Error
+ for key, value := range saved {
+ if _, err := sysctl.Set(key, value, false); err != nil {
+ result = multierror.Append(result, fmt.Errorf("restore %s: %w", key, err))
+ }
+ }
+ return nberrors.FormatErrorOrNil(result)
+}
+
+func bumpAcceptRA(saved map[string]int, wgIfaceName string) {
+ // Also bump conf.default so interfaces created while forwarding is on
+ // (hotplug, new Wi-Fi/dock) inherit accept_ra=2 and keep accepting RAs.
+ bumpAcceptRAKey(saved, acceptRADefaultPath)
+
+ interfaces, err := net.Interfaces()
+ if err != nil {
+ log.Warnf("list interfaces for accept_ra: %v", err)
+ return
+ }
+ for _, intf := range interfaces {
+ if intf.Name == "lo" || intf.Name == wgIfaceName {
+ continue
+ }
+ bumpAcceptRAForInterface(saved, intf.Name)
+ }
+}
+
+func bumpAcceptRAForInterface(saved map[string]int, name string) {
+ // Build procfs path from name, not the dotted key: VLAN names like eth0.100.
+ if _, err := os.Stat(fmt.Sprintf(acceptRAProcPathFormat, name)); err != nil {
+ return
+ }
+ bumpAcceptRAKey(saved, fmt.Sprintf(acceptRAInterfacePath, sysctl.EscapeInterfaceName(name)))
+}
+
+func bumpAcceptRAKey(saved map[string]int, key string) {
+ // onlyIfOne=true: leave admin overrides (0, 2) alone.
+ oldVal, err := sysctl.Set(key, 2, true)
+ if err != nil {
+ log.Warnf("bump %s: %v", key, err)
+ return
+ }
+ // With onlyIfOne, a write only happened when the old value was 1; values
+ // left untouched (0, 2) must not be recorded for restore.
+ if oldVal == 1 {
+ saved[key] = oldVal
+ }
+}
diff --git a/client/internal/routemanager/systemops/v6route_bsd_test.go b/client/internal/routemanager/systemops/v6route_bsd_test.go
index 98ce29c6d..90e49f54e 100644
--- a/client/internal/routemanager/systemops/v6route_bsd_test.go
+++ b/client/internal/routemanager/systemops/v6route_bsd_test.go
@@ -11,6 +11,8 @@ import (
// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback
// interface so route lookups for global IPv6 prefixes resolve in environments
// without v6 connectivity. If a default already exists it is left alone.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
func ensureIPv6DefaultRoute(t *testing.T) {
t.Helper()
diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go
index 0b17cefff..d8c0012d1 100644
--- a/client/internal/routemanager/systemops/v6route_linux_test.go
+++ b/client/internal/routemanager/systemops/v6route_linux_test.go
@@ -1,10 +1,11 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package systemops
import (
"errors"
"net"
+ "net/netip"
"syscall"
"testing"
@@ -29,6 +30,7 @@ func ensureIPv6DefaultRoute(t *testing.T) {
}
if err := netlink.RouteAdd(route); err != nil {
if errors.Is(err, syscall.EEXIST) {
+ requireUsableIPv6Nexthop(t)
return
}
t.Skipf("install IPv6 fallback default route: %v", err)
@@ -38,4 +40,36 @@ func ensureIPv6DefaultRoute(t *testing.T) {
t.Logf("delete IPv6 fallback default route: %v", err)
}
})
+
+ requireUsableIPv6Nexthop(t)
+}
+
+// requireUsableIPv6Nexthop skips the test unless the resolved IPv6 default
+// nexthop can actually carry a route. Installing the default route succeeding
+// does not imply the kernel accepts it as a nexthop for a concrete prefix.
+func requireUsableIPv6Nexthop(t *testing.T) {
+ t.Helper()
+
+ nexthop, err := GetNextHop(netip.IPv6Unspecified())
+ if err != nil {
+ t.Skipf("resolve IPv6 default nexthop: %v", err)
+ }
+
+ probe := &netlink.Route{
+ Scope: netlink.SCOPE_UNIVERSE,
+ Table: syscall.RT_TABLE_MAIN,
+ Family: netlink.FAMILY_V6,
+ Dst: &net.IPNet{IP: net.ParseIP("100::64"), Mask: net.CIDRMask(128, 128)},
+ }
+ require.NoError(t, addNextHop(nexthop, probe), "build IPv6 probe route")
+
+ switch err := netlink.RouteAdd(probe); {
+ case err == nil:
+ if err := netlink.RouteDel(probe); err != nil && !errors.Is(err, syscall.ESRCH) {
+ t.Logf("delete IPv6 probe route: %v", err)
+ }
+ case errors.Is(err, syscall.EEXIST):
+ default:
+ t.Skipf("IPv6 nexthop %s unusable for route installation: %v", nexthop, err)
+ }
}
diff --git a/client/internal/routemanager/systemops/v6route_windows_test.go b/client/internal/routemanager/systemops/v6route_windows_test.go
index f79277b87..2c813a790 100644
--- a/client/internal/routemanager/systemops/v6route_windows_test.go
+++ b/client/internal/routemanager/systemops/v6route_windows_test.go
@@ -8,11 +8,14 @@ import (
"testing"
)
+//nolint:unused // consumed by the privileged-tagged routing tests
const loopbackIfaceWindows = "Loopback Pseudo-Interface 1"
// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback
// interface so route lookups for global IPv6 prefixes resolve in environments
// without v6 connectivity. If a default already exists it is left alone.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
func ensureIPv6DefaultRoute(t *testing.T) {
t.Helper()
diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go
index 232baf746..1254b384d 100644
--- a/client/internal/routeselector/routeselector.go
+++ b/client/internal/routeselector/routeselector.go
@@ -115,7 +115,38 @@ func (rs *RouteSelector) DeselectAllRoutes() {
clear(rs.selectedRoutes)
}
-// IsDeselectAll reports whether the user has explicitly deselected all routes.
+// SetExclusiveExitNode atomically makes preferred the only selected exit node
+// among exitIDs: every other ID in exitIDs is deselected and preferred (when
+// non-empty) is selected, all under a single lock. Holding the lock across the
+// whole reconciliation prevents a concurrent DeselectAllRoutes from interleaving
+// between the deselect and select steps and being silently undone. A global
+// deselect-all is left untouched so the user's "all off" stays in effect;
+// non-exit routes are never referenced, so their selection is preserved.
+func (rs *RouteSelector) SetExclusiveExitNode(preferred route.NetID, exitIDs []route.NetID) {
+ rs.mu.Lock()
+ defer rs.mu.Unlock()
+
+ if rs.deselectAll {
+ return
+ }
+
+ for _, id := range exitIDs {
+ if id == preferred {
+ continue
+ }
+ rs.deselectedRoutes[id] = struct{}{}
+ delete(rs.selectedRoutes, id)
+ }
+
+ if preferred != "" {
+ delete(rs.deselectedRoutes, preferred)
+ rs.selectedRoutes[preferred] = struct{}{}
+ }
+}
+
+// IsDeselectAll reports whether the global "deselect all" flag is set, i.e. the
+// user explicitly disabled every route. Callers enforcing per-route invariants
+// (e.g. single exit node) should leave the selection untouched when it is.
func (rs *RouteSelector) IsDeselectAll() bool {
rs.mu.RLock()
defer rs.mu.RUnlock()
diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go
index c9d6acb4d..2b1ba3fb9 100644
--- a/client/internal/routeselector/routeselector_test.go
+++ b/client/internal/routeselector/routeselector_test.go
@@ -859,3 +859,31 @@ func TestRouteSelector_ComplexScenarios(t *testing.T) {
})
}
}
+
+// TestRouteSelector_EnableExitNodeKeepsOtherRoutes is a regression test for the
+// tray exit-node toggle disabling every non-exit routed network. The tray used
+// to Select an exit node with append=false, which the RouteSelector treats as
+// "drop the whole current selection" (default-on semantics) — so enabling an
+// exit node also turned off every LAN/route the user had on. The fix sends
+// append=true and lets the daemon's SelectNetworks handler deselect only the
+// sibling exit nodes. This test models that handler sequence against the
+// selector: SelectRoutes(exit, append=true) followed by DeselectRoutes(other
+// exit nodes) must leave non-exit routes untouched.
+func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
+ all := []route.NetID{"exitA", "exitB", "lan1", "lan2"}
+
+ // User has two LAN routes on (default-on: nothing deselected => all selected).
+ require.True(t, rs.IsSelected("lan1"))
+ require.True(t, rs.IsSelected("lan2"))
+
+ // Tray enables exitA: SelectNetworks handler does SelectRoutes(append=true)
+ // then deselects sibling exit nodes (exitB), never the LAN routes.
+ require.NoError(t, rs.SelectRoutes([]route.NetID{"exitA"}, true, all))
+ require.NoError(t, rs.DeselectRoutes([]route.NetID{"exitB"}, all))
+
+ assert.True(t, rs.IsSelected("exitA"), "selected exit node stays on")
+ assert.False(t, rs.IsSelected("exitB"), "sibling exit node is deselected")
+ assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected")
+ assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected")
+}
diff --git a/client/internal/sleep/service.go b/client/internal/sleep/service.go
index 196a33f52..93691c4c7 100644
--- a/client/internal/sleep/service.go
+++ b/client/internal/sleep/service.go
@@ -18,8 +18,8 @@ type Service struct {
}
func New() (*Service, error) {
- d, err := NewDetector()
- if err != nil {
+ d, err := NewDetector() //nolint:staticcheck
+ if err != nil { //nolint:staticcheck // always errors on platforms without a sleep detector
return nil, err
}
diff --git a/client/internal/state.go b/client/internal/state.go
index 041cb73f8..0adfa26e4 100644
--- a/client/internal/state.go
+++ b/client/internal/state.go
@@ -33,17 +33,34 @@ func CtxGetState(ctx context.Context) *contextState {
}
type contextState struct {
- err error
- status StatusType
- mutex sync.Mutex
+ err error
+ status StatusType
+ mutex sync.Mutex
+ onChange func()
+}
+
+// SetOnChange installs a callback fired after every successful Set. Used by
+// the daemon to wire the status recorder's notifyStateChange so any
+// state.Set in the connect/login paths pushes a fresh snapshot to
+// SubscribeStatus subscribers without each callsite having to opt in.
+// The callback runs outside the contextState mutex to avoid a lock-order
+// dependency with the recorder's stateChangeMux.
+func (c *contextState) SetOnChange(fn func()) {
+ c.mutex.Lock()
+ c.onChange = fn
+ c.mutex.Unlock()
}
func (c *contextState) Set(update StatusType) {
c.mutex.Lock()
- defer c.mutex.Unlock()
-
c.status = update
c.err = nil
+ cb := c.onChange
+ c.mutex.Unlock()
+
+ if cb != nil {
+ cb()
+ }
}
func (c *contextState) Status() (StatusType, error) {
@@ -57,6 +74,17 @@ func (c *contextState) Status() (StatusType, error) {
return c.status, nil
}
+// CurrentStatus returns the last status set via Set, ignoring any wrapped
+// error. Use when the status is needed for reporting purposes (e.g. the
+// status snapshot stream) and a transient wrapped error from a retry loop
+// shouldn't blank out the underlying status.
+func (c *contextState) CurrentStatus() StatusType {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+
+ return c.status
+}
+
func (c *contextState) Wrap(err error) error {
c.mutex.Lock()
defer c.mutex.Unlock()
diff --git a/client/internal/statemanager/manager.go b/client/internal/statemanager/manager.go
index 566905985..ca4194690 100644
--- a/client/internal/statemanager/manager.go
+++ b/client/internal/statemanager/manager.go
@@ -1,6 +1,7 @@
package statemanager
import (
+ "bytes"
"context"
"encoding/json"
"errors"
@@ -305,6 +306,11 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage,
var rawStates map[string]json.RawMessage
if err := json.Unmarshal(data, &rawStates); err != nil {
+ if len(bytes.TrimSpace(data)) == 0 {
+ log.Warnf("state file %s is empty (%d bytes)", m.filePath, len(data))
+ } else {
+ log.Warnf("state file %s has malformed content (%d bytes)", m.filePath, len(data))
+ }
m.handleCorruptedState(deleteCorrupt)
return nil, fmt.Errorf("unmarshal states: %w", err)
}
diff --git a/client/internal/tunnelnotifier/notifier.go b/client/internal/tunnelnotifier/notifier.go
new file mode 100644
index 000000000..b62923a6e
--- /dev/null
+++ b/client/internal/tunnelnotifier/notifier.go
@@ -0,0 +1,124 @@
+package tunnelnotifier
+
+import (
+ "container/list"
+ "sync"
+
+ "github.com/netbirdio/netbird/client/internal/dns"
+ "github.com/netbirdio/netbird/client/internal/listener"
+)
+
+type eventKind int
+
+const (
+ eventRoutes eventKind = iota
+ eventIfaceIP
+ eventIfaceIPv6
+ eventDNS
+)
+
+var (
+ _ listener.NetworkChangeListener = (*Notifier)(nil)
+ _ dns.IosDnsManager = (*Notifier)(nil)
+)
+
+type event struct {
+ kind eventKind
+ payload string
+}
+
+type Notifier struct {
+ mu sync.Mutex
+ cond *sync.Cond
+ queue *list.List
+ closed bool
+ done chan struct{}
+
+ listener listener.NetworkChangeListener
+ dnsManager dns.IosDnsManager
+}
+
+func New(l listener.NetworkChangeListener, dm dns.IosDnsManager) *Notifier {
+ n := &Notifier{
+ queue: list.New(),
+ done: make(chan struct{}),
+ listener: l,
+ dnsManager: dm,
+ }
+ n.cond = sync.NewCond(&n.mu)
+ go n.deliverLoop()
+ return n
+}
+
+func (n *Notifier) OnNetworkChanged(routes string) {
+ n.enqueue(event{kind: eventRoutes, payload: routes})
+}
+
+func (n *Notifier) SetInterfaceIP(ip string) {
+ n.enqueue(event{kind: eventIfaceIP, payload: ip})
+}
+
+func (n *Notifier) SetInterfaceIPv6(ip string) {
+ n.enqueue(event{kind: eventIfaceIPv6, payload: ip})
+}
+
+func (n *Notifier) ApplyDns(config string) {
+ n.enqueue(event{kind: eventDNS, payload: config})
+}
+
+// Close stops accepting new events and blocks until the delivery loop has
+// drained all queued events and exited.
+func (n *Notifier) Close() {
+ n.mu.Lock()
+ n.closed = true
+ n.cond.Signal()
+ n.mu.Unlock()
+ <-n.done
+}
+
+func (n *Notifier) enqueue(ev event) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ if n.closed {
+ return
+ }
+ n.queue.PushBack(ev)
+ n.cond.Signal()
+}
+
+func (n *Notifier) deliverLoop() {
+ defer close(n.done)
+ for {
+ n.mu.Lock()
+ for n.queue.Len() == 0 && !n.closed {
+ n.cond.Wait()
+ }
+ if n.closed && n.queue.Len() == 0 {
+ n.mu.Unlock()
+ return
+ }
+ ev := n.queue.Remove(n.queue.Front()).(event)
+ l := n.listener
+ dm := n.dnsManager
+ n.mu.Unlock()
+
+ switch ev.kind {
+ case eventRoutes:
+ if l != nil {
+ l.OnNetworkChanged(ev.payload)
+ }
+ case eventIfaceIP:
+ if l != nil {
+ l.SetInterfaceIP(ev.payload)
+ }
+ case eventIfaceIPv6:
+ if l != nil {
+ l.SetInterfaceIPv6(ev.payload)
+ }
+ case eventDNS:
+ if dm != nil {
+ dm.ApplyDns(ev.payload)
+ }
+ }
+ }
+}
diff --git a/client/internal/tunnelnotifier/notifier_test.go b/client/internal/tunnelnotifier/notifier_test.go
new file mode 100644
index 000000000..ffbcdc15c
--- /dev/null
+++ b/client/internal/tunnelnotifier/notifier_test.go
@@ -0,0 +1,192 @@
+package tunnelnotifier
+
+import (
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type call struct {
+ kind string
+ payload string
+}
+
+type recorder struct {
+ mu sync.Mutex
+ calls []call
+ inFlight atomic.Int32
+ overlap atomic.Bool
+ delay time.Duration
+}
+
+func (r *recorder) record(kind, payload string) {
+ if r.inFlight.Add(1) != 1 {
+ r.overlap.Store(true)
+ }
+ if r.delay > 0 {
+ time.Sleep(r.delay)
+ }
+ r.mu.Lock()
+ r.calls = append(r.calls, call{kind: kind, payload: payload})
+ r.mu.Unlock()
+ r.inFlight.Add(-1)
+}
+
+func (r *recorder) count() int {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return len(r.calls)
+}
+
+func (r *recorder) snapshot() []call {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ out := make([]call, len(r.calls))
+ copy(out, r.calls)
+ return out
+}
+
+type fakeListener struct {
+ rec *recorder
+}
+
+func (f *fakeListener) OnNetworkChanged(routes string) {
+ f.rec.record("routes", routes)
+}
+
+func (f *fakeListener) SetInterfaceIP(ip string) {
+ f.rec.record("ip", ip)
+}
+
+func (f *fakeListener) SetInterfaceIPv6(ip string) {
+ f.rec.record("ipv6", ip)
+}
+
+type fakeDNSManager struct {
+ rec *recorder
+}
+
+func (f *fakeDNSManager) ApplyDns(config string) {
+ f.rec.record("dns", config)
+}
+
+func TestFIFOOrder(t *testing.T) {
+ rec := &recorder{}
+ n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
+ defer n.Close()
+
+ n.SetInterfaceIP("10.0.0.1")
+ n.SetInterfaceIPv6("fd00::1")
+ n.ApplyDns(`{"domains":[]}`)
+ n.OnNetworkChanged("10.0.0.0/8,192.168.0.0/16")
+ n.ApplyDns(`{"domains":["example.com"]}`)
+
+ require.Eventually(t, func() bool { return rec.count() == 5 }, time.Second, time.Millisecond)
+
+ expected := []call{
+ {kind: "ip", payload: "10.0.0.1"},
+ {kind: "ipv6", payload: "fd00::1"},
+ {kind: "dns", payload: `{"domains":[]}`},
+ {kind: "routes", payload: "10.0.0.0/8,192.168.0.0/16"},
+ {kind: "dns", payload: `{"domains":["example.com"]}`},
+ }
+ assert.Equal(t, expected, rec.snapshot())
+}
+
+func TestNoOverlappingCalls(t *testing.T) {
+ rec := &recorder{delay: 100 * time.Microsecond}
+ n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
+ defer n.Close()
+
+ const producers = 8
+ const perProducer = 25
+
+ var wg sync.WaitGroup
+ for i := 0; i < producers; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ for j := 0; j < perProducer; j++ {
+ payload := fmt.Sprintf("%d-%d", id, j)
+ switch j % 4 {
+ case 0:
+ n.OnNetworkChanged(payload)
+ case 1:
+ n.SetInterfaceIP(payload)
+ case 2:
+ n.SetInterfaceIPv6(payload)
+ case 3:
+ n.ApplyDns(payload)
+ }
+ }
+ }(i)
+ }
+ wg.Wait()
+
+ require.Eventually(t, func() bool { return rec.count() == producers*perProducer }, 5*time.Second, time.Millisecond)
+ assert.False(t, rec.overlap.Load())
+}
+
+func TestDNSAndRoutesInterleaved(t *testing.T) {
+ rec := &recorder{delay: 100 * time.Microsecond}
+ n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
+ defer n.Close()
+
+ const events = 50
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < events; i++ {
+ n.ApplyDns(fmt.Sprintf("dns-%d", i))
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ for i := 0; i < events; i++ {
+ n.OnNetworkChanged(fmt.Sprintf("routes-%d", i))
+ }
+ }()
+ wg.Wait()
+
+ require.Eventually(t, func() bool { return rec.count() == 2*events }, 5*time.Second, time.Millisecond)
+ assert.False(t, rec.overlap.Load())
+
+ var dnsSeen, routesSeen int
+ for _, c := range rec.snapshot() {
+ switch c.kind {
+ case "dns":
+ assert.Equal(t, fmt.Sprintf("dns-%d", dnsSeen), c.payload)
+ dnsSeen++
+ case "routes":
+ assert.Equal(t, fmt.Sprintf("routes-%d", routesSeen), c.payload)
+ routesSeen++
+ }
+ }
+ assert.Equal(t, events, dnsSeen)
+ assert.Equal(t, events, routesSeen)
+}
+
+func TestCloseDrainsQueue(t *testing.T) {
+ rec := &recorder{delay: time.Millisecond}
+ n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
+
+ const events = 20
+ for i := 0; i < events; i++ {
+ n.OnNetworkChanged(fmt.Sprintf("routes-%d", i))
+ }
+ n.Close()
+
+ require.Equal(t, events, rec.count(), "Close must not return before all queued events are delivered")
+
+ n.OnNetworkChanged("after-close")
+ n.ApplyDns("after-close")
+ time.Sleep(50 * time.Millisecond)
+ assert.Equal(t, events, rec.count())
+}
diff --git a/client/internal/updater/installer/doc.go b/client/internal/updater/installer/doc.go
index 0a60454bb..11b0512ac 100644
--- a/client/internal/updater/installer/doc.go
+++ b/client/internal/updater/installer/doc.go
@@ -37,23 +37,32 @@
// Updater Process (Setup):
//
// 1. Receives parameters from service via command-line arguments
-// 2. Runs installer with appropriate silent/quiet flags:
+// 2. Terminates the UI so the installer does not have to replace a locked image
+// file, which would otherwise leave the install needing a reboot
+// 3. Runs installer with appropriate silent/quiet flags:
// - Windows EXE: installer.exe /S
-// - Windows MSI: msiexec.exe /i installer.msi /quiet /qn /l*v msi.log
+// - Windows MSI: msiexec.exe /i installer.msi /qn /norestart REBOOT=ReallySuppress /l*v msi.log
// - macOS PKG: installer -pkg installer.pkg -target /
// - macOS Homebrew: brew upgrade netbirdio/tap/netbird
-// 3. Installer terminates daemon and UI processes
-// 4. Installer replaces binaries with new version
-// 5. Updater waits for installer to complete
-// 6. Updater restarts daemon:
+// 4. Installer terminates the daemon
+// 5. Installer replaces binaries with new version
+// 6. Updater waits for installer to complete. On Windows, MSI exit codes 3010
+// (ERROR_SUCCESS_REBOOT_REQUIRED) and 1641 (ERROR_SUCCESS_REBOOT_INITIATED)
+// are a pending-reboot outcome, not a failure: the install succeeded, but
+// some files are only replaced on the next restart (the reboot itself is
+// suppressed via /norestart and REBOOT=ReallySuppress), and the flow
+// continues as on success
+// 7. Updater restarts daemon:
// - Windows: netbird.exe service start
// - macOS/Linux: netbird service start
-// 7. Updater restarts UI:
-// - Windows: Launches netbird-ui.exe as active console user using CreateProcessAsUser
+// 8. Updater restarts UI:
+// - Windows: Launches netbird-ui.exe using CreateProcessAsUser in every
+// session it was terminated in, falling back to the active console session
// - macOS: Uses launchctl asuser to launch NetBird.app for console user
// - Linux: Not implemented (UI typically auto-starts)
-// 8. Updater writes result.json with success/error status
-// 9. Updater process exits
+// 9. Updater writes result.json with success/error status (a pending reboot is
+// recorded as success)
+// 10. Updater process exits
//
// # Result Communication
//
diff --git a/client/internal/updater/installer/installer_common.go b/client/internal/updater/installer/installer_common.go
index 8e44bee82..17566f7de 100644
--- a/client/internal/updater/installer/installer_common.go
+++ b/client/internal/updater/installer/installer_common.go
@@ -42,6 +42,9 @@ func NewWithDir(tempDir string) *Installer {
// This will run by the original service process
func (u *Installer) RunInstallation(ctx context.Context, targetVersion string) (err error) {
resultHandler := NewResultHandler(u.tempDir)
+ if err := resultHandler.ClearStaleResult(); err != nil {
+ log.Warnf("clear stale installer result: %v", err)
+ }
defer func() {
if err != nil {
diff --git a/client/internal/updater/installer/installer_run_darwin.go b/client/internal/updater/installer/installer_run_darwin.go
index 248a404aa..5650bc769 100644
--- a/client/internal/updater/installer/installer_run_darwin.go
+++ b/client/internal/updater/installer/installer_run_darwin.go
@@ -98,47 +98,44 @@ func (u *Installer) startDaemon(daemonFolder string) error {
func (u *Installer) startUIAsUser() error {
log.Infof("starting netbird-ui: %s", uiBinary)
- // Get the current console user
- cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
- output, err := cmd.Output()
+ username, err := consoleUser()
if err != nil {
- return fmt.Errorf("failed to get console user: %w", err)
+ return err
}
- username := strings.TrimSpace(string(output))
- if username == "" || username == "root" {
- return fmt.Errorf("no active user session found")
- }
-
- log.Infof("starting UI for user: %s", username)
-
- // Get user's UID
userInfo, err := user.Lookup(username)
if err != nil {
- return fmt.Errorf("failed to lookup user %s: %w", username, err)
+ return fmt.Errorf("lookup user %s: %w", username, err)
}
- // Start the UI process as the console user using launchctl
- // This ensures the app runs in the user's context with proper GUI access
- launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
+ log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid)
+
+ launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary)
log.Infof("launchCmd: %s", launchCmd.String())
- // Set the user's home directory for proper macOS app behavior
- launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir)
- log.Infof("set HOME environment variable: %s", userInfo.HomeDir)
- if err := launchCmd.Start(); err != nil {
- return fmt.Errorf("failed to start UI process: %w", err)
- }
-
- // Release the process so it can run independently
- if err := launchCmd.Process.Release(); err != nil {
- log.Warnf("failed to release UI process: %v", err)
+ if err := launchCmd.Run(); err != nil {
+ return fmt.Errorf("run UI launch: %w", err)
}
log.Infof("netbird-ui started successfully for user %s", username)
return nil
}
+func consoleUser() (string, error) {
+ output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
+ if err != nil {
+ return "", fmt.Errorf("get console user: %w", err)
+ }
+
+ username := strings.TrimSpace(string(output))
+ switch username {
+ case "", "root", "loginwindow", "_mbsetupuser":
+ return "", fmt.Errorf("no active GUI user session, console user: %q", username)
+ }
+
+ return username, nil
+}
+
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
log.Infof("installing pkg file: %s", path)
diff --git a/client/internal/updater/installer/installer_run_windows.go b/client/internal/updater/installer/installer_run_windows.go
index 70c7e32cf..b2ecf3299 100644
--- a/client/internal/updater/installer/installer_run_windows.go
+++ b/client/internal/updater/installer/installer_run_windows.go
@@ -2,6 +2,7 @@ package installer
import (
"context"
+ "errors"
"fmt"
"os"
"os/exec"
@@ -22,6 +23,12 @@ const (
msiLogFile = "msi.log"
+ // ERROR_SUCCESS_REBOOT_REQUIRED and ERROR_SUCCESS_REBOOT_INITIATED
+ msiRebootRequired = 3010
+ msiRebootInitiated = 1641
+
+ processExitWait = 10 * time.Second
+
msiDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.msi"
exeDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.exe"
)
@@ -38,6 +45,8 @@ var (
func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) {
resultHandler := NewResultHandler(u.tempDir)
+ var uiSessions []uint32
+
// Always ensure daemon and UI are restarted after setup
defer func() {
log.Infof("starting daemon back")
@@ -46,7 +55,7 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
}
log.Infof("starting UI back")
- if err := u.startUIAsUser(daemonFolder); err != nil {
+ if err := u.startUI(daemonFolder, uiSessions); err != nil {
log.Errorf("failed to start UI: %v", err)
}
@@ -75,6 +84,14 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
return
}
+ // The UI holds an open handle on its own image. Left running, Restart Manager
+ // cannot shut it down (msiexec runs as LocalSystem here, the UI as the
+ // interactive user), so the MSI falls back to replacing the file on reboot and
+ // marks the install as restart-required. The deferred close-application action
+ // in the package runs too late to prevent that, it happens after
+ // InstallValidate has already registered the file as in use.
+ uiSessions = killUI()
+
var cmd *exec.Cmd
switch installerType {
case TypeExe:
@@ -84,7 +101,9 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
installerDir := filepath.Dir(installerFile)
logPath := filepath.Join(installerDir, msiLogFile)
log.Infof("run msi installer: %s", installerFile)
- cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/quiet", "/qn", "/l*v", logPath)
+ // REBOOT=ReallySuppress: a silent install has no way to ask, so without it
+ // msiexec reboots the machine on its own if it decides one is needed.
+ cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/qn", "/norestart", "REBOOT=ReallySuppress", "/l*v", logPath)
}
cmd.Dir = filepath.Dir(installerFile)
@@ -95,9 +114,13 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string
}
log.Infof("installer started with PID %d", cmd.Process.Pid)
- if resultErr = cmd.Wait(); resultErr != nil {
- log.Errorf("installer process finished with error: %v", resultErr)
- return
+ if err := cmd.Wait(); err != nil {
+ if !isRebootPending(err) {
+ resultErr = err
+ log.Errorf("installer process finished with error: %v", err)
+ return
+ }
+ log.Warnf("installer completed but reported a pending reboot, some files will be replaced on the next restart")
}
return nil
@@ -117,16 +140,142 @@ func (u *Installer) startDaemon(daemonFolder string) error {
return nil
}
-func (u *Installer) startUIAsUser(daemonFolder string) error {
+func (u *Installer) startUI(daemonFolder string, sessionIDs []uint32) error {
uiPath := filepath.Join(daemonFolder, uiName)
log.Infof("starting netbird-ui: %s", uiPath)
- // Get the active console session ID
- sessionID := windows.WTSGetActiveConsoleSessionId()
- if sessionID == 0xFFFFFFFF {
- return fmt.Errorf("no active user session found")
+ if len(sessionIDs) == 0 {
+ sessionID := windows.WTSGetActiveConsoleSessionId()
+ if sessionID == 0xFFFFFFFF {
+ return fmt.Errorf("no active user session found")
+ }
+ sessionIDs = []uint32{sessionID}
}
+ var errs []error
+ for _, sessionID := range sessionIDs {
+ if err := startUIInSession(uiPath, sessionID); err != nil {
+ errs = append(errs, fmt.Errorf("session %d: %w", sessionID, err))
+ continue
+ }
+ log.Infof("netbird-ui started successfully in session %d", sessionID)
+ }
+ return errors.Join(errs...)
+}
+
+// isRebootPending reports whether the installer exit code means it succeeded but
+// left work for the next restart. The reboot itself is suppressed, so this is not
+// a failure.
+func isRebootPending(err error) bool {
+ var exitErr *exec.ExitError
+ if !errors.As(err, &exitErr) {
+ return false
+ }
+
+ switch exitErr.ExitCode() {
+ case msiRebootRequired, msiRebootInitiated:
+ return true
+ default:
+ return false
+ }
+}
+
+// killUI terminates any running netbird-ui process and returns the IDs of the
+// interactive sessions the terminated processes belonged to. Setup starts the
+// UI again in those sessions once the installer is done.
+func killUI() []uint32 {
+ pids, err := processIDsByName(uiName)
+ if err != nil {
+ log.Warnf("failed to look up %s processes: %v", uiName, err)
+ return nil
+ }
+
+ sessions := make(map[uint32]struct{})
+ for _, pid := range pids {
+ var sessionID uint32
+ if err := windows.ProcessIdToSessionId(pid, &sessionID); err != nil {
+ log.Warnf("failed to look up session of %s (PID %d): %v", uiName, pid, err)
+ }
+
+ if err := terminateProcess(pid); err != nil {
+ log.Warnf("failed to terminate %s (PID %d): %v", uiName, pid, err)
+ continue
+ }
+ log.Infof("terminated %s (PID %d) in session %d", uiName, pid, sessionID)
+
+ if sessionID != 0 {
+ sessions[sessionID] = struct{}{}
+ }
+ }
+
+ sessionIDs := make([]uint32, 0, len(sessions))
+ for sessionID := range sessions {
+ sessionIDs = append(sessionIDs, sessionID)
+ }
+ return sessionIDs
+}
+
+func processIDsByName(name string) ([]uint32, error) {
+ snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
+ if err != nil {
+ return nil, fmt.Errorf("create process snapshot: %w", err)
+ }
+ defer func() {
+ if err := windows.CloseHandle(snapshot); err != nil {
+ log.Warnf("failed to close process snapshot: %v", err)
+ }
+ }()
+
+ var entry windows.ProcessEntry32
+ entry.Size = uint32(unsafe.Sizeof(entry))
+
+ var pids []uint32
+ for err = windows.Process32First(snapshot, &entry); err == nil; err = windows.Process32Next(snapshot, &entry) {
+ if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), name) {
+ pids = append(pids, entry.ProcessID)
+ }
+ }
+ if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
+ return nil, fmt.Errorf("enumerate processes: %w", err)
+ }
+
+ return pids, nil
+}
+
+func terminateProcess(pid uint32) error {
+ handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid)
+ if err != nil {
+ // The process may have exited between enumeration and now.
+ if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
+ return nil
+ }
+ return fmt.Errorf("open process: %w", err)
+ }
+ defer func() {
+ if err := windows.CloseHandle(handle); err != nil {
+ log.Warnf("failed to close process handle: %v", err)
+ }
+ }()
+
+ if err := windows.TerminateProcess(handle, 0); err != nil {
+ return fmt.Errorf("terminate process: %w", err)
+ }
+
+ // Wait for the handle to signal so the image file is released before the
+ // installer tries to overwrite it. A timeout is reported through the returned
+ // event, not through err, which stays nil unless the wait itself failed.
+ event, err := windows.WaitForSingleObject(handle, uint32(processExitWait.Milliseconds()))
+ if err != nil {
+ return fmt.Errorf("wait for process exit: %w", err)
+ }
+ if event != windows.WAIT_OBJECT_0 {
+ return fmt.Errorf("wait for process exit: unexpected wait result %#x", event)
+ }
+
+ return nil
+}
+
+func startUIInSession(uiPath string, sessionID uint32) error {
// Get the user token for that session
var userToken windows.Token
err := windows.WTSQueryUserToken(sessionID, &userToken)
@@ -158,6 +307,16 @@ func (u *Installer) startUIAsUser(daemonFolder string) error {
}
}()
+ var env *uint16
+ if err := windows.CreateEnvironmentBlock(&env, primaryToken, false); err != nil {
+ return fmt.Errorf("create environment block: %w", err)
+ }
+ defer func() {
+ if err := windows.DestroyEnvironmentBlock(env); err != nil {
+ log.Warnf("failed to destroy environment block: %v", err)
+ }
+ }()
+
// Prepare startup info
var si windows.StartupInfo
si.Cb = uint32(unsafe.Sizeof(si))
@@ -180,7 +339,7 @@ func (u *Installer) startUIAsUser(daemonFolder string) error {
nil,
false,
creationFlags,
- nil,
+ env,
nil,
&si,
&pi,
@@ -197,7 +356,6 @@ func (u *Installer) startUIAsUser(daemonFolder string) error {
log.Warnf("failed to close thread handle: %v", err)
}
- log.Infof("netbird-ui started successfully in session %d", sessionID)
return nil
}
diff --git a/client/internal/updater/installer/installer_run_windows_test.go b/client/internal/updater/installer/installer_run_windows_test.go
new file mode 100644
index 000000000..6a4540610
--- /dev/null
+++ b/client/internal/updater/installer/installer_run_windows_test.go
@@ -0,0 +1,108 @@
+package installer
+
+import (
+ "errors"
+ "os/exec"
+ "slices"
+ "strconv"
+ "testing"
+)
+
+// exitErrorWithCode returns a real *exec.ExitError carrying the given exit code.
+func exitErrorWithCode(t *testing.T, code int) error {
+ t.Helper()
+
+ err := exec.Command("cmd.exe", "/c", "exit "+strconv.Itoa(code)).Run()
+ if err == nil {
+ t.Fatalf("expected a non-zero exit for code %d", code)
+ }
+ return err
+}
+
+func TestIsRebootPending(t *testing.T) {
+ tests := []struct {
+ name string
+ code int
+ want bool
+ }{
+ {name: "reboot required", code: msiRebootRequired, want: true},
+ {name: "reboot initiated", code: msiRebootInitiated, want: true},
+ {name: "generic failure", code: 1603, want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isRebootPending(exitErrorWithCode(t, tt.code)); got != tt.want {
+ t.Errorf("isRebootPending(exit %d) = %v, want %v", tt.code, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestProcessIDsByNameAndTerminate spawns a long-running system process, finds it
+// by name and terminates it, covering the path the updater uses to release the UI
+// image file before the installer replaces it.
+func TestProcessIDsByNameAndTerminate(t *testing.T) {
+ cmd := exec.Command("ping.exe", "-n", "60", "127.0.0.1")
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("start ping: %v", err)
+ }
+
+ pid := uint32(cmd.Process.Pid)
+ killed := false
+ t.Cleanup(func() {
+ if !killed {
+ _ = cmd.Process.Kill()
+ }
+ _ = cmd.Wait()
+ })
+
+ // Name matching must be case-insensitive: the snapshot reports PING.EXE.
+ pids, err := processIDsByName("ping.exe")
+ if err != nil {
+ t.Fatalf("processIDsByName: %v", err)
+ }
+
+ if !slices.Contains(pids, pid) {
+ t.Fatalf("PID %d not among the ping.exe processes found: %v", pid, pids)
+ }
+
+ if err := terminateProcess(pid); err != nil {
+ t.Fatalf("terminateProcess: %v", err)
+ }
+ killed = true
+
+ // terminateProcess only returns once the handle has signalled, so the process
+ // is already gone and Wait must not block. It exits with the code passed to
+ // TerminateProcess, which is 0, so Wait reports no error.
+ if err := cmd.Wait(); err != nil {
+ t.Fatalf("wait for terminated ping: %v", err)
+ }
+ if !cmd.ProcessState.Exited() {
+ t.Error("process did not exit after terminateProcess")
+ }
+
+ remaining, err := processIDsByName("ping.exe")
+ if err != nil {
+ t.Fatalf("processIDsByName after terminate: %v", err)
+ }
+ if slices.Contains(remaining, pid) {
+ t.Errorf("PID %d still listed after terminateProcess", pid)
+ }
+}
+
+func TestProcessIDsByNameNoMatch(t *testing.T) {
+ pids, err := processIDsByName("netbird-nonexistent-process.exe")
+ if err != nil {
+ t.Fatalf("processIDsByName: %v", err)
+ }
+ if len(pids) != 0 {
+ t.Errorf("expected no matches, got %v", pids)
+ }
+}
+
+func TestIsRebootPendingNonExitError(t *testing.T) {
+ if isRebootPending(errors.New("start installer: file not found")) {
+ t.Error("a non-exit error must not be treated as a pending reboot")
+ }
+}
diff --git a/client/internal/updater/installer/result.go b/client/internal/updater/installer/result.go
index 526c3eb53..55a0d8ac8 100644
--- a/client/internal/updater/installer/result.go
+++ b/client/internal/updater/installer/result.go
@@ -54,6 +54,12 @@ func (rh *ResultHandler) GetErrorResultReason() string {
return ""
}
+// ClearStaleResult removes a result file left over from a previous installation
+// attempt so result watchers cannot read an outdated outcome for the current attempt.
+func (rh *ResultHandler) ClearStaleResult() error {
+ return rh.cleanup()
+}
+
func (rh *ResultHandler) WriteSuccess() error {
result := Result{
Success: true,
diff --git a/client/internal/updater/manager.go b/client/internal/updater/manager.go
index 7fc300739..1b69368d0 100644
--- a/client/internal/updater/manager.go
+++ b/client/internal/updater/manager.go
@@ -435,7 +435,7 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error
}
inst := installer.New()
- if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil {
+ if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { //nolint:staticcheck // always errors on platforms without an installer
log.Errorf("error triggering update: %v", err)
m.statusRecorder.PublishEvent(
cProto.SystemEvent_ERROR,
diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go
index 359a83556..f92f085ab 100644
--- a/client/ios/NetBirdSDK/client.go
+++ b/client/ios/NetBirdSDK/client.go
@@ -13,8 +13,8 @@ import (
"time"
log "github.com/sirupsen/logrus"
- "golang.org/x/exp/maps"
+ nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/debug"
@@ -22,6 +22,8 @@ import (
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/netstate"
+ "github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route"
@@ -29,10 +31,12 @@ import (
types "github.com/netbirdio/netbird/upload-server/types"
)
-// ConnectionListener export internal Listener for mobile
-type ConnectionListener interface {
- peer.Listener
-}
+// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
+// anonymizeLevel values for DebugBundle.
+const (
+ AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
+ AnonymizeLevelStrict = nbAnonymize.LevelStrictString
+)
// RouteListener export internal RouteListener for mobile
type NetworkChangeListener interface {
@@ -80,6 +84,12 @@ type Client struct {
onHostDnsFn func([]string)
dnsManager dns.IosDnsManager
loginComplete bool
+ // netState outlives engine restarts: it mirrors the OS connectivity, not
+ // the engine lifecycle. Run injects it into each new ConnectClient, which
+ // distributes it to every reconnection loop.
+ netState *netstate.State
+ // sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
+ sweeper *netsweep.Sweeper
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
preloadedConfig *profilemanager.Config
@@ -102,6 +112,8 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener,
dnsManager: dnsManager,
+ netState: netstate.New(),
+ sweeper: netsweep.New(),
}
}
@@ -159,19 +171,26 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
defer c.ctxCancel()
c.ctxCancelLock.Unlock()
- auth := NewAuthWithConfig(ctx, cfg)
- err = auth.LoginSync()
- if err != nil {
- return err
- }
-
- log.Infof("Auth successful")
+ // No login pre-flight here. The engine's own loginToManagement (connect.go) performs
+ // the authoritative Login immediately before the first Sync, so a LoginSync() call at
+ // this point only duplicated it — costing two extra Login RPCs (IsLoginRequired +
+ // Login) on every engine start, since IsLoginRequired is itself a full Login RPC.
+ //
+ // Auth failures still reach the caller through the engine path: loginToManagement
+ // returns PermissionDenied, which marks the shared status recorder
+ // (MarkManagementDisconnected) and fires ClientStop → onDisconnected, where
+ // IsLoginRequiredCached() reports login-required. The error is also returned out of Run().
+ //
+ // A pre-flight was also actively harmful when the server is unreachable: its 2-minute
+ // backoff blocked the start and then reported "login required" for what was really a
+ // timeout. The engine instead keeps retrying and recovers when the server returns.
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
c.onHostDnsFn = func([]string) {}
cfg.WgIface = interfaceName
- connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
+ connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
+ internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
c.setState(cfg, connectClient)
// Persist the latest sync response so DebugBundle can include the network
// map. On iOS this is backed by disk to keep it out of the constrained
@@ -180,6 +199,25 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath)
}
+// SetNetworkAvailable feeds OS-reported network availability into the client
+// (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops
+// suspend their attempts and the connection listener reports NoNetwork
+// instead of Connecting; when availability returns, the loops resume
+// immediately with a fresh backoff.
+func (c *Client) SetNetworkAvailable(available bool) {
+ c.netState.Set(available)
+ c.recorder.SetNetworkAvailable(available)
+}
+
+// NotifyNetworkChange marks the management, signal and relay connections
+// stale after the OS switched networks and schedules a sweep that cuts
+// whatever has not redialed on the new network by then. The engine and the
+// TUN device stay untouched.
+func (c *Client) NotifyNetworkChange() {
+ c.sweeper.MarkNetworkChange()
+ log.Infof("network change: connections marked stale")
+}
+
// Stop the internal client and free the resources
func (c *Client) Stop() {
c.ctxCancelLock.Lock()
@@ -195,8 +233,10 @@ func (c *Client) Stop() {
// DebugBundle generates a debug bundle, uploads it and returns the upload key.
// It works with or without a running engine: when the engine is up it reuses
// the live config, sync response and client metrics; otherwise it loads the
-// config from disk (or the preloaded tvOS config).
-func (c *Client) DebugBundle(anonymize bool) (string, error) {
+// config from disk (or the preloaded tvOS config). anonymizeLevel is "default"
+// or "strict"; strict also anonymizes internal IP ranges, peer names, and
+// WireGuard public keys, and implies anonymize.
+func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, error) {
cfg, cc := c.stateSnapshot()
// If the engine hasn't been started, load config so we can reach management.
@@ -233,6 +273,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
deps.SyncResponse = resp
if e := cc.Engine(); e != nil {
+ deps.RefreshStatus = func() {
+ e.RunHealthProbes(context.Background(), true)
+ }
if cm := e.GetClientMetrics(); cm != nil {
deps.ClientMetrics = cm
}
@@ -243,6 +286,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
deps,
debug.BundleConfig{
Anonymize: anonymize,
+ AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
IncludeSystemInfo: true,
},
)
@@ -260,7 +304,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
- key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path)
+ key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false)
if err != nil {
return "", fmt.Errorf("upload debug bundle: %w", err)
}
@@ -312,7 +356,11 @@ func (c *Client) GetStatusDetails() *StatusDetails {
// SetConnectionListener set the network connection listener
func (c *Client) SetConnectionListener(listener ConnectionListener) {
- c.recorder.SetConnectionListener(listener)
+ if listener == nil {
+ c.recorder.RemoveConnectionListener()
+ return
+ }
+ c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
}
// RemoveConnectionListener remove connection listener
@@ -634,23 +682,18 @@ func (c *Client) SelectRoute(id string) error {
}
routeManager := engine.GetRouteManager()
- routeSelector := routeManager.GetRouteSelector()
if id == "All" {
log.Debugf("select all routes")
- routeSelector.SelectAllRoutes()
- } else {
- log.Debugf("select route with id: %s", id)
- routes := toNetIDs([]string{id})
- routesMap := routeManager.GetClientRoutesWithNetID()
- routes = route.ExpandV6ExitPairs(routes, routesMap)
- if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routesMap)); err != nil {
- log.Debugf("error when selecting routes: %s", err)
- return fmt.Errorf("select routes: %w", err)
- }
+ routeManager.SelectAllRoutes()
+ return nil
}
- routeManager.TriggerSelection(routeManager.GetClientRoutes())
- return nil
+ log.Debugf("select route with id: %s", id)
+ if err := routeManager.SelectRoutes(toNetIDs([]string{id}), true); err != nil {
+ log.Debugf("error when selecting routes: %s", err)
+ return err
+ }
+ return nil
}
func (c *Client) DeselectRoute(id string) error {
@@ -664,21 +707,17 @@ func (c *Client) DeselectRoute(id string) error {
}
routeManager := engine.GetRouteManager()
- routeSelector := routeManager.GetRouteSelector()
if id == "All" {
log.Debugf("deselect all routes")
- routeSelector.DeselectAllRoutes()
- } else {
- log.Debugf("deselect route with id: %s", id)
- routes := toNetIDs([]string{id})
- routesMap := routeManager.GetClientRoutesWithNetID()
- routes = route.ExpandV6ExitPairs(routes, routesMap)
- if err := routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil {
- log.Debugf("error when deselecting routes: %s", err)
- return fmt.Errorf("deselect routes: %w", err)
- }
+ routeManager.DeselectAllRoutes()
+ return nil
+ }
+
+ log.Debugf("deselect route with id: %s", id)
+ if err := routeManager.DeselectRoutes(toNetIDs([]string{id})); err != nil {
+ log.Debugf("error when deselecting routes: %s", err)
+ return err
}
- routeManager.TriggerSelection(routeManager.GetClientRoutes())
return nil
}
diff --git a/client/ios/NetBirdSDK/connection_listener.go b/client/ios/NetBirdSDK/connection_listener.go
new file mode 100644
index 000000000..d792537ba
--- /dev/null
+++ b/client/ios/NetBirdSDK/connection_listener.go
@@ -0,0 +1,43 @@
+//go:build ios
+
+package NetBirdSDK
+
+import (
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+// Client state values, re-exported as basic constants so gomobile emits them
+// into the generated bindings. They mirror peer.ClientState*: append-only,
+// never reorder.
+const (
+ ClientStateDisconnected = int(peer.ClientStateDisconnected)
+ ClientStateConnected = int(peer.ClientStateConnected)
+ ClientStateConnecting = int(peer.ClientStateConnecting)
+ ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
+ ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
+)
+
+// ConnectionListener export internal Listener for mobile.
+//
+// It intentionally lacks OnStateChanged for now: adding a method to a gomobile
+// interface breaks every Swift implementation, so the iOS app keeps building
+// against the legacy per-state callbacks. A follow-up will extend it together
+// with the app.
+type ConnectionListener interface {
+ OnConnected()
+ OnDisconnected()
+ OnConnecting()
+ OnDisconnecting()
+ OnAddressChanged(string, string)
+ OnPeersListChanged(int)
+}
+
+// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
+// peer.Listener.
+type connectionListenerAdapter struct {
+ ConnectionListener
+}
+
+// OnStateChanged is dropped on iOS until the app adopts the state callback;
+// the legacy per-state callbacks continue to fire.
+func (a connectionListenerAdapter) OnStateChanged(peer.ClientState) {}
diff --git a/client/ios/NetBirdSDK/env_list.go b/client/ios/NetBirdSDK/env_list.go
index 88ac97957..a3ffa0ebe 100644
--- a/client/ios/NetBirdSDK/env_list.go
+++ b/client/ios/NetBirdSDK/env_list.go
@@ -38,7 +38,7 @@ func GetEnvKeyNBForceRelay() string {
// GetEnvKeyNBLazyConn Exports the environment variable for the iOS client
func GetEnvKeyNBLazyConn() string {
- return lazyconn.EnvEnableLazyConn
+ return lazyconn.EnvLazyConn
}
// GetEnvKeyNBInactivityThreshold Exports the environment variable for the iOS client
diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go
index 5e9b8fe1b..42a575359 100644
--- a/client/ios/NetBirdSDK/login.go
+++ b/client/ios/NetBirdSDK/login.go
@@ -36,6 +36,7 @@ type URLOpener interface {
// Auth can register or login new client
type Auth struct {
ctx context.Context
+ cancel context.CancelFunc
config *profilemanager.Config
cfgPath string
}
@@ -43,22 +44,42 @@ type Auth struct {
// NewAuth instantiate Auth struct and validate the management URL
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
inputCfg := profilemanager.ConfigInput{
+ ConfigPath: cfgPath,
ManagementURL: mgmURL,
}
- cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
+ // Load the existing config when a config file is already present so an
+ // interactive re-login reuses the peer's persisted WireGuard private key
+ // (and thus its identity) instead of generating a fresh one. Generating a
+ // new key registers a brand-new peer on the management server on every
+ // re-auth (named after the fallback hostname). Only fall back to a fresh
+ // in-memory config for the first-time login when no config file exists yet.
+ // DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside
+ // the tvOS App Group sandbox where atomic temp-file+rename is blocked.
+ var cfg *profilemanager.Config
+ var err error
+ if cfgPath != "" {
+ cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg)
+ } else {
+ cfg, err = profilemanager.CreateInMemoryConfig(inputCfg)
+ }
if err != nil {
return nil, err
}
- // Preserve the existing profile config (name, keys) and override only the management URL.
- if existing, err := profilemanager.GetConfig(cfgPath); err == nil {
- existing.ManagementURL = cfg.ManagementURL
- cfg = existing
- }
+ // Use a cancellable context so Stop() can abort an in-progress interactive
+ // login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server
+ // bound to a port) until the OAuth callback arrives or the flow expires;
+ // cancelling the context unblocks WaitToken, which then shuts that server down
+ // and frees the port for the next login attempt. iOS runs login in the main-app
+ // process (decoupled from the network extension), so without this the server
+ // lingers after the user dismisses the browser and the next connect stalls
+ // trying to bind the same port.
+ ctx, cancel := context.WithCancel(context.Background())
return &Auth{
- ctx: context.Background(),
+ ctx: ctx,
+ cancel: cancel,
config: cfg,
cfgPath: cfgPath,
}, nil
@@ -66,12 +87,24 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
// NewAuthWithConfig instantiate Auth based on existing config
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
+ ctx, cancel := context.WithCancel(ctx)
return &Auth{
ctx: ctx,
+ cancel: cancel,
config: config,
}
}
+// Stop aborts an in-progress interactive login started via Login/LoginWithDeviceName.
+// It cancels the auth context, which unblocks the PKCE WaitToken and shuts down its
+// loopback HTTP server, freeing the redirect port. Safe to call multiple times and
+// safe to call when no login is running.
+func (a *Auth) Stop() {
+ if a.cancel != nil {
+ a.cancel()
+ }
+}
+
// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info.
// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO
// is not supported and returns false without saving the configuration. For other errors return false.
@@ -189,17 +222,36 @@ func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, forceDevic
// LoginWithDeviceName performs interactive login with device authentication support
// The deviceName parameter allows specifying a custom device name (required for tvOS)
func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
+ a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, false)
+}
+
+// LoginInteractive performs the same interactive login as LoginWithDeviceName but skips the
+// IsLoginRequired() pre-flight and goes straight to the browser / device-code flow.
+//
+// IsLoginRequired() is itself a full Login RPC against the management server, so when the
+// caller has ALREADY established that login is required it is a pure duplicate. On iOS the
+// main app decides to show the browser based on its own isLoginRequired() check and then
+// calls straight into this method, so re-asking the server would add another Login RPC to
+// every interactive login.
+//
+// Use LoginWithDeviceName when the auth state is unknown and a silent (browser-less) login
+// must still be possible; use this when the browser is going to be shown regardless.
+func (a *Auth) LoginInteractive(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
+ a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, true)
+}
+
+func (a *Auth) startLogin(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) {
if resultListener == nil {
- log.Errorf("LoginWithDeviceName: resultListener is nil")
+ log.Errorf("startLogin: resultListener is nil")
return
}
if urlOpener == nil {
- log.Errorf("LoginWithDeviceName: urlOpener is nil")
+ log.Errorf("startLogin: urlOpener is nil")
resultListener.OnError(fmt.Errorf("urlOpener is nil"))
return
}
go func() {
- err := a.login(urlOpener, forceDeviceAuth, deviceName)
+ err := a.login(urlOpener, forceDeviceAuth, deviceName, skipLoginCheck)
if err != nil {
resultListener.OnError(err)
} else {
@@ -208,7 +260,7 @@ func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpen
}()
}
-func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string) error {
+func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) error {
// Create context with device name if provided
ctx := a.ctx
if deviceName != "" {
@@ -222,10 +274,13 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
}
defer authClient.Close()
- // check if we need to generate JWT token
- needsLogin, err := authClient.IsLoginRequired(ctx)
- if err != nil {
- return fmt.Errorf("failed to check login requirement: %v", err)
+ // check if we need to generate JWT token (skipped when the caller already knows)
+ needsLogin := true
+ if !skipLoginCheck {
+ needsLogin, err = authClient.IsLoginRequired(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to check login requirement: %v", err)
+ }
}
jwtToken := ""
@@ -268,7 +323,7 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
const authInfoRequestTimeout = 30 * time.Second
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) {
- oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth)
+ oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, "")
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
diff --git a/client/ios/NetBirdSDK/version.go b/client/ios/NetBirdSDK/version.go
new file mode 100644
index 000000000..606ad18e2
--- /dev/null
+++ b/client/ios/NetBirdSDK/version.go
@@ -0,0 +1,12 @@
+//go:build ios
+
+package NetBirdSDK
+
+import "github.com/netbirdio/netbird/version"
+
+// GoClientVersion returns the NetBird Go client version that was baked into
+// the framework at compile time via
+// -ldflags "-X github.com/netbirdio/netbird/version.version=".
+func GoClientVersion() string {
+ return version.NetbirdVersion()
+}
diff --git a/client/jobexec/executor.go b/client/jobexec/executor.go
index e29cc8840..9401acacc 100644
--- a/client/jobexec/executor.go
+++ b/client/jobexec/executor.go
@@ -54,7 +54,7 @@ func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.
}
}()
- key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path)
+ key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path, false)
if err != nil {
log.Errorf("failed to upload debug bundle: %v", err)
return "", fmt.Errorf("upload debug bundle: %w", err)
diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go
index 6e7ab19cb..29288b511 100644
--- a/client/mdm/canonical_loaders.go
+++ b/client/mdm/canonical_loaders.go
@@ -15,18 +15,21 @@ var allKeys = []string{
KeyDisableUpdateSettings,
KeyDisableProfiles,
KeyDisableNetworks,
+ KeyDisableAdvancedView,
KeyDisableClientRoutes,
KeyDisableServerRoutes,
KeyBlockInbound,
KeyDisableMetricsCollection,
KeyAllowServerSSH,
KeyDisableAutoConnect,
+ KeyDisableAutostart,
KeyPreSharedKey,
KeyRosenpassEnabled,
KeyRosenpassPermissive,
KeyWireguardPort,
KeySplitTunnelMode,
KeySplitTunnelApps,
+ KeyLazyConnection,
}
// canonicalKey maps the lowercase form of a managed-config value name to
diff --git a/client/mdm/policy.go b/client/mdm/policy.go
index 109fb322e..1feff28f8 100644
--- a/client/mdm/policy.go
+++ b/client/mdm/policy.go
@@ -11,6 +11,7 @@ package mdm
import (
"sort"
"strconv"
+ "strings"
log "github.com/sirupsen/logrus"
)
@@ -19,20 +20,33 @@ import (
// names (lowerCamelCase) so the daemon can map a Policy key directly to a
// configuration field.
const (
- KeyManagementURL = "managementURL"
- KeyDisableUpdateSettings = "disableUpdateSettings"
- KeyDisableProfiles = "disableProfiles"
- KeyDisableNetworks = "disableNetworks"
+ KeyManagementURL = "managementURL"
+ KeyDisableUpdateSettings = "disableUpdateSettings"
+ KeyDisableProfiles = "disableProfiles"
+ KeyDisableNetworks = "disableNetworks"
+ // KeyDisableAdvancedView gates the advanced-view section in the
+ // upcoming UI revision. UI-only: NOT stored on Config, not
+ // applied by applyMDMPolicy, not rejectable via SetConfig. The
+ // daemon surfaces it through GetFeatures (tristate: present
+ // true / present false / absent) and the same key appears in
+ // GetConfigResponse.mDMManagedFields when set.
+ KeyDisableAdvancedView = "disableAdvancedView"
KeyDisableClientRoutes = "disableClientRoutes"
KeyDisableServerRoutes = "disableServerRoutes"
KeyBlockInbound = "blockInbound"
KeyDisableMetricsCollection = "disableMetricsCollection"
KeyAllowServerSSH = "allowServerSSH"
KeyDisableAutoConnect = "disableAutoConnect"
- KeyPreSharedKey = "preSharedKey"
- KeyRosenpassEnabled = "rosenpassEnabled"
- KeyRosenpassPermissive = "rosenpassPermissive"
- KeyWireguardPort = "wireguardPort"
+ // KeyDisableAutostart suppresses the GUI's fresh-install
+ // launch-on-login default and marks the Settings toggle as
+ // MDM-managed. UI-only: NOT stored on Config and not applied by
+ // applyMDMPolicy; the GUI reads it directly and it appears in
+ // GetConfigResponse.mDMManagedFields when set.
+ KeyDisableAutostart = "disableAutostart"
+ KeyPreSharedKey = "preSharedKey"
+ KeyRosenpassEnabled = "rosenpassEnabled"
+ KeyRosenpassPermissive = "rosenpassPermissive"
+ KeyWireguardPort = "wireguardPort"
// Split tunnel is modeled as a single conceptual policy with two
// registry/plist values. KeySplitTunnelMode is the discriminator
@@ -41,6 +55,11 @@ const (
// construction — only one mode can be set at a time.
KeySplitTunnelMode = "splitTunnelMode"
KeySplitTunnelApps = "splitTunnelApps"
+
+ // KeyLazyConnection forces the lazy-connection feature on or off, overriding
+ // the management feature flag. Read as a bool (native bool, or on/off,
+ // true/false, 1/0, yes/no); absent = defer to management.
+ KeyLazyConnection = "lazyConnection"
)
// Split-tunnel mode literals (KeySplitTunnelMode values).
@@ -62,12 +81,13 @@ var boolStringLiterals = map[string]bool{
"true": true,
"1": true,
"yes": true,
+ "on": true,
"false": false,
"0": false,
"no": false,
+ "off": false,
}
-
// Policy holds MDM-managed settings read from the platform source. A nil or
// empty Policy means no enforcement is active.
type Policy struct {
@@ -150,7 +170,8 @@ func (p *Policy) GetString(key string) (string, bool) {
}
// GetBool returns the managed value for key coerced to bool, and whether the
-// key was set. Accepts native bool and string literals "true"/"false"/"1"/"0".
+// key was set. Accepts native bool and string literals (true/false, 1/0,
+// yes/no, on/off), case-insensitively and trimmed of surrounding whitespace.
func (p *Policy) GetBool(key string) (bool, bool) {
if p == nil {
return false, false
@@ -163,7 +184,7 @@ func (p *Policy) GetBool(key string) (bool, bool) {
case bool:
return t, true
case string:
- b, known := boolStringLiterals[t]
+ b, known := boolStringLiterals[strings.ToLower(strings.TrimSpace(t))]
return b, known
case int:
return t != 0, true
diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go
index 47a6ed2c9..6cbe69776 100644
--- a/client/mdm/policy_test.go
+++ b/client/mdm/policy_test.go
@@ -31,8 +31,8 @@ func TestPolicy_Empty(t *testing.T) {
func TestPolicy_HasKey(t *testing.T) {
p := NewPolicy(map[string]any{
- KeyManagementURL: "https://corp.example.com",
- KeyDisableProfiles: true,
+ KeyManagementURL: "https://corp.example.com",
+ KeyDisableProfiles: true,
})
assert.False(t, p.IsEmpty())
assert.True(t, p.HasKey(KeyManagementURL))
@@ -53,8 +53,8 @@ func TestPolicy_ManagedKeysSorted(t *testing.T) {
func TestPolicy_GetString(t *testing.T) {
p := NewPolicy(map[string]any{
KeyManagementURL: "https://corp.example.com",
- KeyDisableProfiles: true, // wrong type for GetString
- KeyPreSharedKey: "", // empty rejected
+ KeyDisableProfiles: true, // wrong type for GetString
+ KeyPreSharedKey: "", // empty rejected
})
v, ok := p.GetString(KeyManagementURL)
assert.True(t, ok)
@@ -85,6 +85,11 @@ func TestPolicy_GetBool(t *testing.T) {
{"string 0", "0", false, true},
{"string yes", "yes", true, true},
{"string no", "no", false, true},
+ {"string on", "on", true, true},
+ {"string off", "off", false, true},
+ {"mixed case On", "On", true, true},
+ {"upper TRUE", "TRUE", true, true},
+ {"padded yes", " yes ", true, true},
{"int nonzero", 1, true, true},
{"int zero", 0, false, true},
{"int64 nonzero", int64(2), true, true},
diff --git a/client/netbird.wxs b/client/netbird.wxs
index 6f18b63b5..f30a7aa7e 100644
--- a/client/netbird.wxs
+++ b/client/netbird.wxs
@@ -13,9 +13,6 @@
-
-
-
@@ -32,9 +29,6 @@
-
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/client/netstate/netstate.go b/client/netstate/netstate.go
new file mode 100644
index 000000000..0d7a1268b
--- /dev/null
+++ b/client/netstate/netstate.go
@@ -0,0 +1,110 @@
+// Package netstate tracks OS-reported network availability for the client.
+//
+// A State instance is owned by the platform integration (e.g. the Android or
+// iOS bindings, fed from ConnectivityManager callbacks or NWPathMonitor) and
+// is injected into the connection retry loops (management, signal, relay,
+// peer guards and the top-level connect loop), which consult it to avoid
+// burning CPU and battery on reconnect attempts while the device has no
+// network at all (e.g. airplane mode), and to reset their backoff as soon as
+// the network returns.
+//
+// Consumers hold a *State that may be nil — every non-mobile platform leaves
+// it unset. The read methods are safe on a nil receiver: they report online
+// and never block, so consumers behave as if this package did not exist.
+package netstate
+
+import (
+ "context"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// State holds the OS-reported network availability. The zero value is not
+// usable; create instances with New.
+type State struct {
+ mu sync.Mutex
+ online bool
+ changed chan struct{}
+}
+
+// New creates a State that starts online. Platforms without network tracking
+// pass a nil *State instead: the read methods treat nil as always online and
+// never block, so consumers need no nil guards.
+func New() *State {
+ return &State{
+ online: true,
+ changed: make(chan struct{}),
+ }
+}
+
+// Set records whether the OS reports any usable network. Transitions wake up
+// all Wait callers immediately. Unlike the read methods, Set is not nil-safe:
+// it is only for the platform owner that created the State with New.
+func (s *State) Set(online bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.online == online {
+ return
+ }
+ s.online = online
+ close(s.changed)
+ s.changed = make(chan struct{})
+ log.Infof("OS network availability changed: online=%t", online)
+}
+
+// IsOnline reports whether the OS reports at least one usable network. On a
+// nil receiver — no State injected — it reports online.
+func (s *State) IsOnline() bool {
+ if s == nil {
+ return true
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.online
+}
+
+// Changed returns a channel closed on the next availability transition, for
+// callers that already own a select loop and cannot block in Wait. Re-read it
+// after every fire: each transition installs a fresh channel. On a nil
+// receiver — no State injected — it returns nil, which blocks forever in a
+// select, so the caller simply never observes a transition.
+func (s *State) Changed() <-chan struct{} {
+ if s == nil {
+ return nil
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.changed
+}
+
+// Wait blocks while the network is offline. It reports whether it had to
+// wait, so callers can reset their backoff after an outage. It returns early
+// with the context error when ctx is done. On a nil receiver — no State
+// injected — it returns immediately.
+func (s *State) Wait(ctx context.Context) (bool, error) {
+ if s == nil {
+ return false, nil
+ }
+ waited := false
+ for {
+ s.mu.Lock()
+ if s.online {
+ s.mu.Unlock()
+ return waited, nil
+ }
+ ch := s.changed
+ s.mu.Unlock()
+
+ if !waited {
+ waited = true
+ log.Debugf("network is offline, pausing connection attempts")
+ }
+
+ select {
+ case <-ctx.Done():
+ return waited, ctx.Err()
+ case <-ch:
+ }
+ }
+}
diff --git a/client/netstate/netstate_test.go b/client/netstate/netstate_test.go
new file mode 100644
index 000000000..ea7015761
--- /dev/null
+++ b/client/netstate/netstate_test.go
@@ -0,0 +1,170 @@
+package netstate
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewStateIsOnline(t *testing.T) {
+ assert.True(t, New().IsOnline(), "a fresh State should start online")
+}
+
+func TestSetTogglesOnlineState(t *testing.T) {
+ s := New()
+
+ s.Set(false)
+ assert.False(t, s.IsOnline(), "state should be offline after Set(false)")
+
+ s.Set(true)
+ assert.True(t, s.IsOnline(), "state should be online after Set(true)")
+}
+
+func TestWaitReturnsImmediatelyWhenOnline(t *testing.T) {
+ s := New()
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ waited, err := s.Wait(ctx)
+ require.NoError(t, err)
+ assert.False(t, waited, "Wait should not block when the network is online")
+}
+
+func TestWaitBlocksUntilOnline(t *testing.T) {
+ s := New()
+ s.Set(false)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ result := make(chan bool, 1)
+ go func() {
+ waited, err := s.Wait(ctx)
+ if err != nil {
+ result <- false
+ return
+ }
+ result <- waited
+ }()
+
+ // Verify Wait is actually blocking while offline
+ select {
+ case <-result:
+ t.Fatal("Wait should block while the network is offline")
+ case <-time.After(100 * time.Millisecond):
+ }
+
+ s.Set(true)
+
+ select {
+ case waited := <-result:
+ assert.True(t, waited, "Wait should report that it had to wait for the network")
+ case <-time.After(2 * time.Second):
+ t.Fatal("Wait should return promptly after the network becomes available")
+ }
+}
+
+func TestWaitReturnsOnContextCancel(t *testing.T) {
+ s := New()
+ s.Set(false)
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ result := make(chan error, 1)
+ go func() {
+ _, err := s.Wait(ctx)
+ result <- err
+ }()
+
+ cancel()
+
+ select {
+ case err := <-result:
+ assert.ErrorIs(t, err, context.Canceled)
+ case <-time.After(2 * time.Second):
+ t.Fatal("Wait should return promptly after context cancellation")
+ }
+}
+
+func TestWaitWakesAllWaiters(t *testing.T) {
+ s := New()
+ s.Set(false)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ const waiters = 10
+ var wg sync.WaitGroup
+ results := make(chan bool, waiters)
+ for i := 0; i < waiters; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ waited, err := s.Wait(ctx)
+ if err != nil {
+ results <- false
+ return
+ }
+ results <- waited
+ }()
+ }
+
+ time.Sleep(100 * time.Millisecond)
+ s.Set(true)
+ wg.Wait()
+
+ close(results)
+ count := 0
+ for waited := range results {
+ assert.True(t, waited, "every waiter should report that it waited")
+ count++
+ }
+ assert.Equal(t, waiters, count, "all waiters should have returned")
+}
+
+func TestNilStateReadsAreNoops(t *testing.T) {
+ var s *State
+
+ assert.True(t, s.IsOnline(), "nil State should report online")
+
+ waited, err := s.Wait(context.Background())
+ require.NoError(t, err)
+ assert.False(t, waited, "nil State's Wait should not block")
+}
+
+func TestConcurrentSetAndWait(t *testing.T) {
+ s := New()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ var wg sync.WaitGroup
+ for i := 0; i < 4; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 100; j++ {
+ s.Set(j%2 == 0)
+ s.IsOnline()
+ }
+ }()
+ }
+ for i := 0; i < 4; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 100; j++ {
+ if _, err := s.Wait(ctx); err != nil {
+ return
+ }
+ }
+ }()
+ }
+
+ wg.Wait()
+}
diff --git a/client/netsweep/netsweep.go b/client/netsweep/netsweep.go
new file mode 100644
index 000000000..46bc0a709
--- /dev/null
+++ b/client/netsweep/netsweep.go
@@ -0,0 +1,267 @@
+// Package netsweep cuts network-bound activity when the OS switches networks:
+// a sweep closes the registered connections and aborts the in-flight dials, so
+// their owners redial immediately instead of waiting for the old sockets to
+// time out.
+//
+// A nil *Sweeper disables everything: all methods are nil-safe no-ops.
+package netsweep
+
+import (
+ "context"
+ "errors"
+ "net"
+ "sync"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/netstate"
+)
+
+// DefaultSweepDelay absorbs network flapping while the OS settles on a
+// default network before the stale registrations are cut.
+const DefaultSweepDelay = 500 * time.Millisecond
+
+const recentMarkWindow = 3 * time.Second
+
+// Config customizes a Sweeper. The zero value applies the defaults.
+type Config struct {
+ // SweepDelay overrides DefaultSweepDelay when positive.
+ SweepDelay time.Duration
+}
+
+// ErrSwept reports that a dial finished after a network change swept its
+// registration. The connection is already closed; the caller must treat it
+// as a failed dial and redial on the new network.
+var ErrSwept = errors.New("netsweep: connection swept by network change")
+
+// sweepID identifies one registration in a sweeper. Connections and dials
+// draw from the same counter, so an id is unique across both registries.
+type sweepID uint64
+
+type connEntry struct {
+ conn net.Conn
+ gen uint64
+}
+
+// Dial tracks one dial from start to connection registration. It hands the
+// dialed connection to the sweeper atomically, so a sweep can never fall
+// between the dial finishing and the connection being registered.
+type Dial struct {
+ sweeper *Sweeper
+ ctx context.Context
+ cancel context.CancelFunc
+ id sweepID
+ done bool // set by a sweep, WrapConn or Release; guarded by sweeper.mu
+ gen uint64
+}
+
+// Ctx returns the dial's context. A sweep cancels it, so a dial started on the
+// old network aborts instead of waiting out its handshake timeout.
+func (d *Dial) Ctx() context.Context {
+ return d.ctx
+}
+
+// Release ends the dial's registration and cancels its context. It is
+// idempotent and safe after WrapConn, so callers can defer it.
+func (d *Dial) Release() {
+ s := d.sweeper
+ if s == nil {
+ return
+ }
+
+ s.mu.Lock()
+ d.done = true
+ delete(s.dials, d.id)
+ s.mu.Unlock()
+
+ d.cancel()
+}
+
+// sweptConn deregisters itself from the sweeper when closed.
+type sweptConn struct {
+ net.Conn
+ sweeper *Sweeper
+ id sweepID
+}
+
+func (c *sweptConn) Close() error {
+ c.sweeper.deregister(c.id)
+ return c.Conn.Close()
+}
+
+// Sweeper registers live connections and in-flight dials so the
+// network-change sweep can cut everything registered before the change.
+type Sweeper struct {
+ mu sync.Mutex
+ conns map[sweepID]connEntry
+ dials map[sweepID]*Dial
+ nextID sweepID
+ gen uint64
+ timer *time.Timer
+ sweepDelay time.Duration
+ lastMark time.Time
+}
+
+// New creates an empty sweeper with the default configuration.
+func New() *Sweeper {
+ return NewWithConfig(Config{})
+}
+
+// NewWithConfig creates an empty sweeper customized by cfg.
+func NewWithConfig(cfg Config) *Sweeper {
+ delay := cfg.SweepDelay
+ if delay <= 0 {
+ delay = DefaultSweepDelay
+ }
+ return &Sweeper{
+ conns: make(map[sweepID]connEntry),
+ dials: make(map[sweepID]*Dial),
+ sweepDelay: delay,
+ }
+}
+
+// StartDial registers an in-flight dial. Dial with Ctx, hand the result to
+// WrapConn, and Release the dial when the attempt is over, typically deferred.
+func (s *Sweeper) StartDial(ctx context.Context) *Dial {
+ if s == nil {
+ return &Dial{ctx: ctx}
+ }
+
+ ctx, cancel := context.WithCancel(ctx)
+ d := &Dial{sweeper: s, ctx: ctx, cancel: cancel}
+
+ s.mu.Lock()
+ d.id = s.nextID
+ s.nextID++
+ d.gen = s.gen
+ s.dials[d.id] = d
+ s.mu.Unlock()
+
+ return d
+}
+
+// WrapConn hands conn over to the sweeper. If a sweep ran since StartDial,
+// the connection belongs to the old network: it is closed and ErrSwept is
+// returned. Otherwise conn is registered against the next sweep and returned
+// wrapped, deregistering itself on Close. Call it once, before Release.
+func (d *Dial) WrapConn(conn net.Conn) (net.Conn, error) {
+ s := d.sweeper
+ if s == nil {
+ return conn, nil
+ }
+
+ s.mu.Lock()
+ if d.done {
+ s.mu.Unlock()
+ if err := conn.Close(); err != nil {
+ log.Debugf("swept dial close error: %v", err)
+ }
+ return nil, ErrSwept
+ }
+ d.done = true
+ delete(s.dials, d.id)
+ id := s.nextID
+ s.nextID++
+ // The conn inherits the dial's generation: the socket was bound to the
+ // network that was default when the dial started, not when it finished.
+ s.conns[id] = connEntry{conn: conn, gen: d.gen}
+ s.mu.Unlock()
+
+ return &sweptConn{Conn: conn, sweeper: s, id: id}, nil
+}
+
+// MarkNetworkChange records that the OS switched networks: everything
+// registered so far becomes stale, and a sweep is (re)scheduled after the
+// configured delay to cut whatever is still stale by then. Owners that
+// redialed in the meantime hold fresh-generation registrations and survive,
+// so no cancellation is needed around the sweep.
+func (s *Sweeper) MarkNetworkChange() {
+ if s == nil {
+ return
+ }
+
+ s.mu.Lock()
+ s.gen++
+ cutoff := s.gen
+ s.lastMark = time.Now()
+ if s.timer != nil {
+ s.timer.Stop()
+ }
+ s.timer = time.AfterFunc(s.sweepDelay, func() {
+ n := s.sweep(cutoff)
+ log.Infof("network change sweep: closed %d stale connections", n)
+ })
+ s.mu.Unlock()
+}
+
+// QuickRetryBackoff wraps bo so that after each Reset the first retry comes
+// quickly when the disconnect followed a recent network change and the
+// network is online. Any other failure keeps bo's spread, so the clients of
+// a restarted server still scatter their reconnects. A nil sweeper returns
+// bo unchanged.
+func (s *Sweeper) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff, netState *netstate.State) backoff.BackOff {
+ if s == nil {
+ return bo
+ }
+ return backoff.WithContext(newQuickRetryBackoff(bo, s, netState), ctx)
+}
+
+func (s *Sweeper) markedRecently() bool {
+ if s == nil {
+ return false
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return !s.lastMark.IsZero() && time.Since(s.lastMark) < recentMarkWindow
+}
+
+// sweep closes the registered connections and aborts the in-flight dials
+// older than cutoff, and returns how many connections it closed. A dial
+// whose connection was not yet handed to WrapConn is marked, so the late
+// WrapConn closes it instead of registering it.
+func (s *Sweeper) sweep(cutoff uint64) int {
+ if s == nil {
+ return 0
+ }
+
+ s.mu.Lock()
+ var conns []net.Conn
+ for id, e := range s.conns {
+ if e.gen < cutoff {
+ delete(s.conns, id)
+ conns = append(conns, e.conn)
+ }
+ }
+ var dials []*Dial
+ for id, d := range s.dials {
+ if d.gen < cutoff {
+ d.done = true
+ delete(s.dials, id)
+ dials = append(dials, d)
+ }
+ }
+ s.mu.Unlock()
+
+ if len(dials) > 0 {
+ log.Debugf("aborting %d in-flight dials", len(dials))
+ for _, d := range dials {
+ d.cancel()
+ }
+ }
+
+ for _, conn := range conns {
+ log.Debugf("sweeping connection %s -> %s", conn.LocalAddr(), conn.RemoteAddr())
+ if err := conn.Close(); err != nil {
+ log.Debugf("swept connection close error: %v", err)
+ }
+ }
+ return len(conns)
+}
+
+func (s *Sweeper) deregister(id sweepID) {
+ s.mu.Lock()
+ delete(s.conns, id)
+ s.mu.Unlock()
+}
diff --git a/client/netsweep/netsweep_test.go b/client/netsweep/netsweep_test.go
new file mode 100644
index 000000000..88d660c2d
--- /dev/null
+++ b/client/netsweep/netsweep_test.go
@@ -0,0 +1,241 @@
+package netsweep
+
+import (
+ "context"
+ "math"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSweepClosesRegisteredConns(t *testing.T) {
+ sweeper := New()
+
+ c1 := wrap(t, sweeper, connPair(t))
+ c2 := wrap(t, sweeper, connPair(t))
+
+ assert.Equal(t, 2, sweeper.sweepAll(), "both live connections should be closed")
+
+ // The wrappers must report closed now.
+ buf := make([]byte, 1)
+ _, err := c1.Read(buf)
+ assert.Error(t, err, "first connection should be unusable after the sweep")
+ _, err = c2.Read(buf)
+ assert.Error(t, err, "second connection should be unusable after the sweep")
+
+ assert.Equal(t, 0, sweeper.sweepAll(), "second sweep should find nothing")
+}
+
+func TestCloseDeregisters(t *testing.T) {
+ sweeper := New()
+
+ conn := wrap(t, sweeper, connPair(t))
+ require.NoError(t, conn.Close())
+
+ assert.Equal(t, 0, sweeper.sweepAll(), "closed connection must leave the registry")
+}
+
+func TestCloseIsIdempotent(t *testing.T) {
+ sweeper := New()
+
+ conn := wrap(t, sweeper, connPair(t))
+ require.NoError(t, conn.Close())
+ assert.Error(t, conn.Close(), "double close surfaces the underlying error but must not panic")
+}
+
+func TestSweepOnlyAffectsOlderConns(t *testing.T) {
+ sweeper := New()
+
+ _ = wrap(t, sweeper, connPair(t))
+ assert.Equal(t, 1, sweeper.sweepAll())
+
+ // A connection dialed after the sweep must survive until the next one.
+ _ = wrap(t, sweeper, connPair(t))
+ assert.Equal(t, 1, sweeper.sweepAll(), "post-sweep connection belongs to the next sweep")
+}
+
+func TestSweepAbortsInFlightDials(t *testing.T) {
+ sweeper := New()
+
+ dial := sweeper.StartDial(context.Background())
+ defer dial.Release()
+
+ sweeper.sweepAll()
+
+ assert.ErrorIs(t, dial.Ctx().Err(), context.Canceled, "sweep must cancel the in-flight dial context")
+}
+
+func TestReleasedDialIsNotAborted(t *testing.T) {
+ sweeper := New()
+
+ // Simulate a dial that finished before the sweep.
+ released := sweeper.StartDial(context.Background())
+ released.Release()
+
+ // A dial still in flight during the sweep.
+ pending := sweeper.StartDial(context.Background())
+ defer pending.Release()
+
+ sweeper.sweepAll()
+ assert.ErrorIs(t, pending.Ctx().Err(), context.Canceled, "pending dial must be aborted")
+}
+
+func TestSweepBetweenDialAndHandoffClosesConn(t *testing.T) {
+ sweeper := New()
+
+ dial := sweeper.StartDial(context.Background())
+ defer dial.Release()
+
+ // The dial succeeds on the old network, then the sweep lands before the
+ // connection is handed over.
+ conn := connPair(t)
+ assert.Equal(t, 0, sweeper.sweepAll(), "the connection is not registered yet")
+
+ wrapped, err := dial.WrapConn(conn)
+ require.ErrorIs(t, err, ErrSwept)
+ require.Nil(t, wrapped)
+
+ buf := make([]byte, 1)
+ _, err = conn.Read(buf)
+ assert.Error(t, err, "the old-network connection must be closed, not leaked")
+
+ assert.Equal(t, 0, sweeper.sweepAll(), "nothing may leak into the next sweep")
+}
+
+func TestMarkNetworkChangeSparesFreshConns(t *testing.T) {
+ sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond})
+
+ stale := wrap(t, sweeper, connPair(t))
+ sweeper.MarkNetworkChange()
+ _ = wrap(t, sweeper, connPair(t))
+
+ _ = stale.SetReadDeadline(time.Now().Add(time.Second))
+ buf := make([]byte, 1)
+ _, err := stale.Read(buf)
+ require.ErrorIs(t, err, net.ErrClosed, "stale connection must be closed by the delayed sweep")
+
+ assert.Equal(t, 1, sweeper.sweepAll(), "the fresh connection must survive the stale sweep")
+}
+
+func TestMarkNetworkChangeAbortsStaleDials(t *testing.T) {
+ sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond})
+
+ stale := sweeper.StartDial(context.Background())
+ defer stale.Release()
+ sweeper.MarkNetworkChange()
+ fresh := sweeper.StartDial(context.Background())
+ defer fresh.Release()
+
+ assert.Eventually(t, func() bool {
+ return stale.Ctx().Err() != nil
+ }, time.Second, 5*time.Millisecond, "stale dial must be aborted by the delayed sweep")
+ assert.NoError(t, fresh.Ctx().Err(), "post-mark dial must not be aborted")
+}
+
+func TestConnInheritsDialGeneration(t *testing.T) {
+ sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond})
+
+ // The dial starts before the network change but completes after it: the
+ // socket is bound to the old network, so the sweep must still cut it.
+ dial := sweeper.StartDial(context.Background())
+ defer dial.Release()
+ sweeper.MarkNetworkChange()
+
+ wrapped, err := dial.WrapConn(connPair(t))
+ require.NoError(t, err)
+
+ _ = wrapped.SetReadDeadline(time.Now().Add(time.Second))
+ buf := make([]byte, 1)
+ _, err = wrapped.Read(buf)
+ require.ErrorIs(t, err, net.ErrClosed, "old-generation connection must be swept")
+}
+
+func TestRepeatedMarksCoalesce(t *testing.T) {
+ sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond})
+
+ first := wrap(t, sweeper, connPair(t))
+ sweeper.MarkNetworkChange()
+ second := wrap(t, sweeper, connPair(t))
+ sweeper.MarkNetworkChange()
+ _ = wrap(t, sweeper, connPair(t))
+
+ buf := make([]byte, 1)
+ for _, conn := range []net.Conn{first, second} {
+ _ = conn.SetReadDeadline(time.Now().Add(time.Second))
+ _, err := conn.Read(buf)
+ require.ErrorIs(t, err, net.ErrClosed, "every pre-mark connection must be swept by the rescheduled sweep")
+ }
+ assert.Equal(t, 1, sweeper.sweepAll(), "only the newest-generation connection may remain")
+}
+
+func TestNilSweeperIsNoop(t *testing.T) {
+ var sweeper *Sweeper
+
+ conn := connPair(t)
+ dial := sweeper.StartDial(context.Background())
+ defer dial.Release()
+
+ wrapped, err := dial.WrapConn(conn)
+ require.NoError(t, err)
+ assert.Equal(t, conn, wrapped, "nil sweeper must return the conn unchanged")
+ assert.NoError(t, dial.Ctx().Err(), "nil sweeper must not cancel the dial context")
+ assert.Equal(t, 0, sweeper.sweepAll(), "nil sweeper closes nothing")
+}
+
+// wrap registers conn with the sweeper through a completed dial.
+func wrap(t *testing.T, sweeper *Sweeper, conn net.Conn) net.Conn {
+ t.Helper()
+
+ dial := sweeper.StartDial(context.Background())
+ defer dial.Release()
+
+ wrapped, err := dial.WrapConn(conn)
+ require.NoError(t, err)
+ return wrapped
+}
+
+// connPair dials a loopback TCP connection and keeps the accepted peer open
+// until the test ends: a peer that closed early would make the connection
+// unreadable on its own, so a read error after the sweep would prove nothing.
+func connPair(t *testing.T) net.Conn {
+ t.Helper()
+
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ if err := l.Close(); err != nil {
+ t.Logf("listener close error: %v", err)
+ }
+ })
+
+ accepted := make(chan net.Conn, 1)
+ go func() {
+ conn, err := l.Accept()
+ if err != nil {
+ close(accepted)
+ return
+ }
+ accepted <- conn
+ }()
+
+ conn, err := net.Dial("tcp", l.Addr().String())
+ require.NoError(t, err)
+
+ peer, ok := <-accepted
+ require.True(t, ok, "listener must accept the dialed connection")
+ t.Cleanup(func() {
+ if err := peer.Close(); err != nil {
+ t.Logf("peer close error: %v", err)
+ }
+ })
+
+ return conn
+}
+
+// sweepAll cuts every registration regardless of generation.
+func (s *Sweeper) sweepAll() int {
+ return s.sweep(math.MaxUint64)
+}
diff --git a/client/netsweep/quick_retry.go b/client/netsweep/quick_retry.go
new file mode 100644
index 000000000..524a5c50c
--- /dev/null
+++ b/client/netsweep/quick_retry.go
@@ -0,0 +1,39 @@
+package netsweep
+
+import (
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+
+ "github.com/netbirdio/netbird/client/netstate"
+)
+
+const quickRetryDelay = 200 * time.Millisecond
+
+type quickRetryBackoff struct {
+ backoff.BackOff
+ sweeper *Sweeper
+ netState *netstate.State
+ used bool
+}
+
+func newQuickRetryBackoff(bo backoff.BackOff, sweeper *Sweeper, netState *netstate.State) *quickRetryBackoff {
+ return &quickRetryBackoff{
+ BackOff: bo,
+ sweeper: sweeper,
+ netState: netState,
+ }
+}
+
+func (b *quickRetryBackoff) NextBackOff() time.Duration {
+ if !b.used && b.sweeper.markedRecently() && b.netState.IsOnline() {
+ b.used = true
+ return quickRetryDelay
+ }
+ return b.BackOff.NextBackOff()
+}
+
+func (b *quickRetryBackoff) Reset() {
+ b.used = false
+ b.BackOff.Reset()
+}
diff --git a/client/netsweep/quick_retry_test.go b/client/netsweep/quick_retry_test.go
new file mode 100644
index 000000000..5505862c5
--- /dev/null
+++ b/client/netsweep/quick_retry_test.go
@@ -0,0 +1,58 @@
+package netsweep
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestQuickRetryAfterRecentMark(t *testing.T) {
+ sweeper := New()
+ sweeper.MarkNetworkChange()
+
+ slow := backoff.NewConstantBackOff(5 * time.Second)
+ bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil)
+
+ assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "first retry after a mark must be quick")
+ assert.Equal(t, 5*time.Second, bo.NextBackOff(), "second retry must fall back to the wrapped backoff")
+
+ bo.Reset()
+ assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "reset must re-arm the quick retry")
+}
+
+func TestQuickRetryWithoutMarkKeepsSpread(t *testing.T) {
+ sweeper := New()
+
+ slow := backoff.NewConstantBackOff(5 * time.Second)
+ bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil)
+
+ assert.Equal(t, 5*time.Second, bo.NextBackOff(), "without a mark the wrapped backoff decides")
+
+ sweeper.mu.Lock()
+ sweeper.lastMark = time.Now().Add(-recentMarkWindow)
+ sweeper.mu.Unlock()
+ assert.Equal(t, 5*time.Second, bo.NextBackOff(), "a stale mark must not trigger the quick retry")
+}
+
+func TestQuickRetryNilSweeperPassthrough(t *testing.T) {
+ var sweeper *Sweeper
+
+ slow := backoff.NewConstantBackOff(5 * time.Second)
+ bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil)
+
+ assert.Equal(t, backoff.BackOff(slow), bo, "nil sweeper must return the backoff unchanged")
+}
+
+func TestQuickRetryHonorsContext(t *testing.T) {
+ sweeper := New()
+ sweeper.MarkNetworkChange()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ bo := sweeper.QuickRetryBackoff(ctx, backoff.NewConstantBackOff(time.Millisecond), nil)
+
+ assert.Equal(t, backoff.Stop, bo.NextBackOff(), "cancelled context must stop the retry loop")
+}
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 488b0186c..b438a310a 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -192,7 +192,7 @@ func (x SystemEvent_Severity) Number() protoreflect.EnumNumber {
// Deprecated: Use SystemEvent_Severity.Descriptor instead.
func (SystemEvent_Severity) EnumDescriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{51, 0}
+ return file_daemon_proto_rawDescGZIP(), []int{53, 0}
}
type SystemEvent_Category int32
@@ -247,7 +247,7 @@ func (x SystemEvent_Category) Number() protoreflect.EnumNumber {
// Deprecated: Use SystemEvent_Category.Descriptor instead.
func (SystemEvent_Category) EnumDescriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{51, 1}
+ return file_daemon_proto_rawDescGZIP(), []int{53, 1}
}
type EmptyRequest struct {
@@ -823,9 +823,15 @@ func (x *WaitSSOLoginResponse) GetEmail() string {
}
type UpRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
- Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
+ Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
+ // async instructs the daemon to start the connection attempt and return
+ // immediately without waiting for the engine to become ready. Status updates
+ // are delivered via the SubscribeStatus stream. When false (the default) the
+ // RPC blocks until the engine is running or gives up, which is the behaviour
+ // needed by the CLI.
+ Async bool `protobuf:"varint,4,opt,name=async,proto3" json:"async,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -874,6 +880,13 @@ func (x *UpRequest) GetUsername() string {
return ""
}
+func (x *UpRequest) GetAsync() bool {
+ if x != nil {
+ return x.Async
+ }
+ return false
+}
+
type UpResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -978,8 +991,12 @@ type StatusResponse struct {
FullStatus *FullStatus `protobuf:"bytes,2,opt,name=fullStatus,proto3" json:"fullStatus,omitempty"`
// NetBird daemon version
DaemonVersion string `protobuf:"bytes,3,opt,name=daemonVersion,proto3" json:"daemonVersion,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // Absolute UTC instant at which the peer's SSO session expires.
+ // Unset when the peer is not SSO-registered or login expiration is disabled.
+ // The UI derives "warning active" from this value and its own clock.
+ SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *StatusResponse) Reset() {
@@ -1033,6 +1050,13 @@ func (x *StatusResponse) GetDaemonVersion() string {
return ""
}
+func (x *StatusResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.SessionExpiresAt
+ }
+ return nil
+}
+
type DownRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -2129,8 +2153,13 @@ type FullStatus struct {
Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"`
LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"`
SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // networksRevision bumps whenever the set of routed networks (route and
+ // exit-node candidates) or their selected state changes. The UI fingerprints
+ // on it to know when to re-fetch ListNetworks via the push stream, instead
+ // of polling on every status snapshot.
+ NetworksRevision uint64 `protobuf:"varint,11,opt,name=networksRevision,proto3" json:"networksRevision,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *FullStatus) Reset() {
@@ -2233,6 +2262,13 @@ func (x *FullStatus) GetSshServerState() *SSHServerState {
return nil
}
+func (x *FullStatus) GetNetworksRevision() uint64 {
+ if x != nil {
+ return x.NetworksRevision
+ }
+ return 0
+}
+
// Networks
type ListNetworksRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -2735,14 +2771,23 @@ func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule {
// DebugBundler
type DebugBundleRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"`
- SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
- UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
- LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
- CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"`
+ SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
+ UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
+ LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
+ CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
+ // uploadInsecure allows uploading to an http endpoint or one with an
+ // untrusted TLS certificate. Restricted to privileged callers; for
+ // self-hosted upload servers.
+ UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"`
+ // anonymizeLevel selects how much the anonymizer redacts: "default"
+ // (or empty) keeps internal IP ranges, "strict" also anonymizes them.
+ // Unknown values are treated as "strict". Only meaningful with anonymize;
+ // "strict" implies it.
+ AnonymizeLevel string `protobuf:"bytes,8,opt,name=anonymizeLevel,proto3" json:"anonymizeLevel,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *DebugBundleRequest) Reset() {
@@ -2810,6 +2855,20 @@ func (x *DebugBundleRequest) GetCliVersion() string {
return ""
}
+func (x *DebugBundleRequest) GetUploadInsecure() bool {
+ if x != nil {
+ return x.UploadInsecure
+ }
+ return false
+}
+
+func (x *DebugBundleRequest) GetAnonymizeLevel() string {
+ if x != nil {
+ return x.AnonymizeLevel
+ }
+ return ""
+}
+
type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -3030,6 +3089,86 @@ func (*SetLogLevelResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{36}
}
+type RegisterUILogRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RegisterUILogRequest) Reset() {
+ *x = RegisterUILogRequest{}
+ mi := &file_daemon_proto_msgTypes[37]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RegisterUILogRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RegisterUILogRequest) ProtoMessage() {}
+
+func (x *RegisterUILogRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[37]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RegisterUILogRequest.ProtoReflect.Descriptor instead.
+func (*RegisterUILogRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{37}
+}
+
+func (x *RegisterUILogRequest) GetPath() string {
+ if x != nil {
+ return x.Path
+ }
+ return ""
+}
+
+type RegisterUILogResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RegisterUILogResponse) Reset() {
+ *x = RegisterUILogResponse{}
+ mi := &file_daemon_proto_msgTypes[38]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RegisterUILogResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RegisterUILogResponse) ProtoMessage() {}
+
+func (x *RegisterUILogResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[38]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RegisterUILogResponse.ProtoReflect.Descriptor instead.
+func (*RegisterUILogResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{38}
+}
+
// State represents a daemon state entry
type State struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -3040,7 +3179,7 @@ type State struct {
func (x *State) Reset() {
*x = State{}
- mi := &file_daemon_proto_msgTypes[37]
+ mi := &file_daemon_proto_msgTypes[39]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3052,7 +3191,7 @@ func (x *State) String() string {
func (*State) ProtoMessage() {}
func (x *State) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[37]
+ mi := &file_daemon_proto_msgTypes[39]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3065,7 +3204,7 @@ func (x *State) ProtoReflect() protoreflect.Message {
// Deprecated: Use State.ProtoReflect.Descriptor instead.
func (*State) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{37}
+ return file_daemon_proto_rawDescGZIP(), []int{39}
}
func (x *State) GetName() string {
@@ -3084,7 +3223,7 @@ type ListStatesRequest struct {
func (x *ListStatesRequest) Reset() {
*x = ListStatesRequest{}
- mi := &file_daemon_proto_msgTypes[38]
+ mi := &file_daemon_proto_msgTypes[40]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3096,7 +3235,7 @@ func (x *ListStatesRequest) String() string {
func (*ListStatesRequest) ProtoMessage() {}
func (x *ListStatesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[38]
+ mi := &file_daemon_proto_msgTypes[40]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3109,7 +3248,7 @@ func (x *ListStatesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListStatesRequest.ProtoReflect.Descriptor instead.
func (*ListStatesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{38}
+ return file_daemon_proto_rawDescGZIP(), []int{40}
}
// ListStatesResponse contains a list of states
@@ -3122,7 +3261,7 @@ type ListStatesResponse struct {
func (x *ListStatesResponse) Reset() {
*x = ListStatesResponse{}
- mi := &file_daemon_proto_msgTypes[39]
+ mi := &file_daemon_proto_msgTypes[41]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3134,7 +3273,7 @@ func (x *ListStatesResponse) String() string {
func (*ListStatesResponse) ProtoMessage() {}
func (x *ListStatesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[39]
+ mi := &file_daemon_proto_msgTypes[41]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3147,7 +3286,7 @@ func (x *ListStatesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListStatesResponse.ProtoReflect.Descriptor instead.
func (*ListStatesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{39}
+ return file_daemon_proto_rawDescGZIP(), []int{41}
}
func (x *ListStatesResponse) GetStates() []*State {
@@ -3168,7 +3307,7 @@ type CleanStateRequest struct {
func (x *CleanStateRequest) Reset() {
*x = CleanStateRequest{}
- mi := &file_daemon_proto_msgTypes[40]
+ mi := &file_daemon_proto_msgTypes[42]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3180,7 +3319,7 @@ func (x *CleanStateRequest) String() string {
func (*CleanStateRequest) ProtoMessage() {}
func (x *CleanStateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[40]
+ mi := &file_daemon_proto_msgTypes[42]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3193,7 +3332,7 @@ func (x *CleanStateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CleanStateRequest.ProtoReflect.Descriptor instead.
func (*CleanStateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{40}
+ return file_daemon_proto_rawDescGZIP(), []int{42}
}
func (x *CleanStateRequest) GetStateName() string {
@@ -3220,7 +3359,7 @@ type CleanStateResponse struct {
func (x *CleanStateResponse) Reset() {
*x = CleanStateResponse{}
- mi := &file_daemon_proto_msgTypes[41]
+ mi := &file_daemon_proto_msgTypes[43]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3232,7 +3371,7 @@ func (x *CleanStateResponse) String() string {
func (*CleanStateResponse) ProtoMessage() {}
func (x *CleanStateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[41]
+ mi := &file_daemon_proto_msgTypes[43]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3245,7 +3384,7 @@ func (x *CleanStateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CleanStateResponse.ProtoReflect.Descriptor instead.
func (*CleanStateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{41}
+ return file_daemon_proto_rawDescGZIP(), []int{43}
}
func (x *CleanStateResponse) GetCleanedStates() int32 {
@@ -3266,7 +3405,7 @@ type DeleteStateRequest struct {
func (x *DeleteStateRequest) Reset() {
*x = DeleteStateRequest{}
- mi := &file_daemon_proto_msgTypes[42]
+ mi := &file_daemon_proto_msgTypes[44]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3278,7 +3417,7 @@ func (x *DeleteStateRequest) String() string {
func (*DeleteStateRequest) ProtoMessage() {}
func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[42]
+ mi := &file_daemon_proto_msgTypes[44]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3291,7 +3430,7 @@ func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteStateRequest.ProtoReflect.Descriptor instead.
func (*DeleteStateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{42}
+ return file_daemon_proto_rawDescGZIP(), []int{44}
}
func (x *DeleteStateRequest) GetStateName() string {
@@ -3318,7 +3457,7 @@ type DeleteStateResponse struct {
func (x *DeleteStateResponse) Reset() {
*x = DeleteStateResponse{}
- mi := &file_daemon_proto_msgTypes[43]
+ mi := &file_daemon_proto_msgTypes[45]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3330,7 +3469,7 @@ func (x *DeleteStateResponse) String() string {
func (*DeleteStateResponse) ProtoMessage() {}
func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[43]
+ mi := &file_daemon_proto_msgTypes[45]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3343,7 +3482,7 @@ func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteStateResponse.ProtoReflect.Descriptor instead.
func (*DeleteStateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{43}
+ return file_daemon_proto_rawDescGZIP(), []int{45}
}
func (x *DeleteStateResponse) GetDeletedStates() int32 {
@@ -3362,7 +3501,7 @@ type SetSyncResponsePersistenceRequest struct {
func (x *SetSyncResponsePersistenceRequest) Reset() {
*x = SetSyncResponsePersistenceRequest{}
- mi := &file_daemon_proto_msgTypes[44]
+ mi := &file_daemon_proto_msgTypes[46]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3374,7 +3513,7 @@ func (x *SetSyncResponsePersistenceRequest) String() string {
func (*SetSyncResponsePersistenceRequest) ProtoMessage() {}
func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[44]
+ mi := &file_daemon_proto_msgTypes[46]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3387,7 +3526,7 @@ func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message
// Deprecated: Use SetSyncResponsePersistenceRequest.ProtoReflect.Descriptor instead.
func (*SetSyncResponsePersistenceRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{44}
+ return file_daemon_proto_rawDescGZIP(), []int{46}
}
func (x *SetSyncResponsePersistenceRequest) GetEnabled() bool {
@@ -3405,7 +3544,7 @@ type SetSyncResponsePersistenceResponse struct {
func (x *SetSyncResponsePersistenceResponse) Reset() {
*x = SetSyncResponsePersistenceResponse{}
- mi := &file_daemon_proto_msgTypes[45]
+ mi := &file_daemon_proto_msgTypes[47]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3417,7 +3556,7 @@ func (x *SetSyncResponsePersistenceResponse) String() string {
func (*SetSyncResponsePersistenceResponse) ProtoMessage() {}
func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[45]
+ mi := &file_daemon_proto_msgTypes[47]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3430,7 +3569,7 @@ func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message
// Deprecated: Use SetSyncResponsePersistenceResponse.ProtoReflect.Descriptor instead.
func (*SetSyncResponsePersistenceResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{45}
+ return file_daemon_proto_rawDescGZIP(), []int{47}
}
type TCPFlags struct {
@@ -3447,7 +3586,7 @@ type TCPFlags struct {
func (x *TCPFlags) Reset() {
*x = TCPFlags{}
- mi := &file_daemon_proto_msgTypes[46]
+ mi := &file_daemon_proto_msgTypes[48]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3459,7 +3598,7 @@ func (x *TCPFlags) String() string {
func (*TCPFlags) ProtoMessage() {}
func (x *TCPFlags) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[46]
+ mi := &file_daemon_proto_msgTypes[48]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3472,7 +3611,7 @@ func (x *TCPFlags) ProtoReflect() protoreflect.Message {
// Deprecated: Use TCPFlags.ProtoReflect.Descriptor instead.
func (*TCPFlags) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{46}
+ return file_daemon_proto_rawDescGZIP(), []int{48}
}
func (x *TCPFlags) GetSyn() bool {
@@ -3534,7 +3673,7 @@ type TracePacketRequest struct {
func (x *TracePacketRequest) Reset() {
*x = TracePacketRequest{}
- mi := &file_daemon_proto_msgTypes[47]
+ mi := &file_daemon_proto_msgTypes[49]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3546,7 +3685,7 @@ func (x *TracePacketRequest) String() string {
func (*TracePacketRequest) ProtoMessage() {}
func (x *TracePacketRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[47]
+ mi := &file_daemon_proto_msgTypes[49]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3559,7 +3698,7 @@ func (x *TracePacketRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use TracePacketRequest.ProtoReflect.Descriptor instead.
func (*TracePacketRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{47}
+ return file_daemon_proto_rawDescGZIP(), []int{49}
}
func (x *TracePacketRequest) GetSourceIp() string {
@@ -3637,7 +3776,7 @@ type TraceStage struct {
func (x *TraceStage) Reset() {
*x = TraceStage{}
- mi := &file_daemon_proto_msgTypes[48]
+ mi := &file_daemon_proto_msgTypes[50]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3649,7 +3788,7 @@ func (x *TraceStage) String() string {
func (*TraceStage) ProtoMessage() {}
func (x *TraceStage) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[48]
+ mi := &file_daemon_proto_msgTypes[50]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3662,7 +3801,7 @@ func (x *TraceStage) ProtoReflect() protoreflect.Message {
// Deprecated: Use TraceStage.ProtoReflect.Descriptor instead.
func (*TraceStage) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{48}
+ return file_daemon_proto_rawDescGZIP(), []int{50}
}
func (x *TraceStage) GetName() string {
@@ -3703,7 +3842,7 @@ type TracePacketResponse struct {
func (x *TracePacketResponse) Reset() {
*x = TracePacketResponse{}
- mi := &file_daemon_proto_msgTypes[49]
+ mi := &file_daemon_proto_msgTypes[51]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3715,7 +3854,7 @@ func (x *TracePacketResponse) String() string {
func (*TracePacketResponse) ProtoMessage() {}
func (x *TracePacketResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[49]
+ mi := &file_daemon_proto_msgTypes[51]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3728,7 +3867,7 @@ func (x *TracePacketResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use TracePacketResponse.ProtoReflect.Descriptor instead.
func (*TracePacketResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{49}
+ return file_daemon_proto_rawDescGZIP(), []int{51}
}
func (x *TracePacketResponse) GetStages() []*TraceStage {
@@ -3753,7 +3892,7 @@ type SubscribeRequest struct {
func (x *SubscribeRequest) Reset() {
*x = SubscribeRequest{}
- mi := &file_daemon_proto_msgTypes[50]
+ mi := &file_daemon_proto_msgTypes[52]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3765,7 +3904,7 @@ func (x *SubscribeRequest) String() string {
func (*SubscribeRequest) ProtoMessage() {}
func (x *SubscribeRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[50]
+ mi := &file_daemon_proto_msgTypes[52]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3778,7 +3917,7 @@ func (x *SubscribeRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead.
func (*SubscribeRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{50}
+ return file_daemon_proto_rawDescGZIP(), []int{52}
}
type SystemEvent struct {
@@ -3796,7 +3935,7 @@ type SystemEvent struct {
func (x *SystemEvent) Reset() {
*x = SystemEvent{}
- mi := &file_daemon_proto_msgTypes[51]
+ mi := &file_daemon_proto_msgTypes[53]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3808,7 +3947,7 @@ func (x *SystemEvent) String() string {
func (*SystemEvent) ProtoMessage() {}
func (x *SystemEvent) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[51]
+ mi := &file_daemon_proto_msgTypes[53]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3821,7 +3960,7 @@ func (x *SystemEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use SystemEvent.ProtoReflect.Descriptor instead.
func (*SystemEvent) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{51}
+ return file_daemon_proto_rawDescGZIP(), []int{53}
}
func (x *SystemEvent) GetId() string {
@@ -3881,7 +4020,7 @@ type GetEventsRequest struct {
func (x *GetEventsRequest) Reset() {
*x = GetEventsRequest{}
- mi := &file_daemon_proto_msgTypes[52]
+ mi := &file_daemon_proto_msgTypes[54]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3893,7 +4032,7 @@ func (x *GetEventsRequest) String() string {
func (*GetEventsRequest) ProtoMessage() {}
func (x *GetEventsRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[52]
+ mi := &file_daemon_proto_msgTypes[54]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3906,7 +4045,7 @@ func (x *GetEventsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetEventsRequest.ProtoReflect.Descriptor instead.
func (*GetEventsRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{52}
+ return file_daemon_proto_rawDescGZIP(), []int{54}
}
type GetEventsResponse struct {
@@ -3918,7 +4057,7 @@ type GetEventsResponse struct {
func (x *GetEventsResponse) Reset() {
*x = GetEventsResponse{}
- mi := &file_daemon_proto_msgTypes[53]
+ mi := &file_daemon_proto_msgTypes[55]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3930,7 +4069,7 @@ func (x *GetEventsResponse) String() string {
func (*GetEventsResponse) ProtoMessage() {}
func (x *GetEventsResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[53]
+ mi := &file_daemon_proto_msgTypes[55]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3943,7 +4082,7 @@ func (x *GetEventsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetEventsResponse.ProtoReflect.Descriptor instead.
func (*GetEventsResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{53}
+ return file_daemon_proto_rawDescGZIP(), []int{55}
}
func (x *GetEventsResponse) GetEvents() []*SystemEvent {
@@ -3965,7 +4104,7 @@ type SwitchProfileRequest struct {
func (x *SwitchProfileRequest) Reset() {
*x = SwitchProfileRequest{}
- mi := &file_daemon_proto_msgTypes[54]
+ mi := &file_daemon_proto_msgTypes[56]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3977,7 +4116,7 @@ func (x *SwitchProfileRequest) String() string {
func (*SwitchProfileRequest) ProtoMessage() {}
func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[54]
+ mi := &file_daemon_proto_msgTypes[56]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3990,7 +4129,7 @@ func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SwitchProfileRequest.ProtoReflect.Descriptor instead.
func (*SwitchProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{54}
+ return file_daemon_proto_rawDescGZIP(), []int{56}
}
func (x *SwitchProfileRequest) GetProfileName() string {
@@ -4019,7 +4158,7 @@ type SwitchProfileResponse struct {
func (x *SwitchProfileResponse) Reset() {
*x = SwitchProfileResponse{}
- mi := &file_daemon_proto_msgTypes[55]
+ mi := &file_daemon_proto_msgTypes[57]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4031,7 +4170,7 @@ func (x *SwitchProfileResponse) String() string {
func (*SwitchProfileResponse) ProtoMessage() {}
func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[55]
+ mi := &file_daemon_proto_msgTypes[57]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4044,7 +4183,7 @@ func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use SwitchProfileResponse.ProtoReflect.Descriptor instead.
func (*SwitchProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{55}
+ return file_daemon_proto_rawDescGZIP(), []int{57}
}
func (x *SwitchProfileResponse) GetId() string {
@@ -4100,7 +4239,7 @@ type SetConfigRequest struct {
func (x *SetConfigRequest) Reset() {
*x = SetConfigRequest{}
- mi := &file_daemon_proto_msgTypes[56]
+ mi := &file_daemon_proto_msgTypes[58]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4112,7 +4251,7 @@ func (x *SetConfigRequest) String() string {
func (*SetConfigRequest) ProtoMessage() {}
func (x *SetConfigRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[56]
+ mi := &file_daemon_proto_msgTypes[58]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4125,7 +4264,7 @@ func (x *SetConfigRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetConfigRequest.ProtoReflect.Descriptor instead.
func (*SetConfigRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{56}
+ return file_daemon_proto_rawDescGZIP(), []int{58}
}
func (x *SetConfigRequest) GetUsername() string {
@@ -4381,7 +4520,7 @@ type SetConfigResponse struct {
func (x *SetConfigResponse) Reset() {
*x = SetConfigResponse{}
- mi := &file_daemon_proto_msgTypes[57]
+ mi := &file_daemon_proto_msgTypes[59]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4393,7 +4532,7 @@ func (x *SetConfigResponse) String() string {
func (*SetConfigResponse) ProtoMessage() {}
func (x *SetConfigResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[57]
+ mi := &file_daemon_proto_msgTypes[59]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4406,7 +4545,7 @@ func (x *SetConfigResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetConfigResponse.ProtoReflect.Descriptor instead.
func (*SetConfigResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{57}
+ return file_daemon_proto_rawDescGZIP(), []int{59}
}
type AddProfileRequest struct {
@@ -4421,7 +4560,7 @@ type AddProfileRequest struct {
func (x *AddProfileRequest) Reset() {
*x = AddProfileRequest{}
- mi := &file_daemon_proto_msgTypes[58]
+ mi := &file_daemon_proto_msgTypes[60]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4433,7 +4572,7 @@ func (x *AddProfileRequest) String() string {
func (*AddProfileRequest) ProtoMessage() {}
func (x *AddProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[58]
+ mi := &file_daemon_proto_msgTypes[60]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4446,7 +4585,7 @@ func (x *AddProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use AddProfileRequest.ProtoReflect.Descriptor instead.
func (*AddProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{58}
+ return file_daemon_proto_rawDescGZIP(), []int{60}
}
func (x *AddProfileRequest) GetUsername() string {
@@ -4474,7 +4613,7 @@ type AddProfileResponse struct {
func (x *AddProfileResponse) Reset() {
*x = AddProfileResponse{}
- mi := &file_daemon_proto_msgTypes[59]
+ mi := &file_daemon_proto_msgTypes[61]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4486,7 +4625,7 @@ func (x *AddProfileResponse) String() string {
func (*AddProfileResponse) ProtoMessage() {}
func (x *AddProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[59]
+ mi := &file_daemon_proto_msgTypes[61]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4499,7 +4638,7 @@ func (x *AddProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use AddProfileResponse.ProtoReflect.Descriptor instead.
func (*AddProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{59}
+ return file_daemon_proto_rawDescGZIP(), []int{61}
}
func (x *AddProfileResponse) GetId() string {
@@ -4522,7 +4661,7 @@ type RenameProfileRequest struct {
func (x *RenameProfileRequest) Reset() {
*x = RenameProfileRequest{}
- mi := &file_daemon_proto_msgTypes[60]
+ mi := &file_daemon_proto_msgTypes[62]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4534,7 +4673,7 @@ func (x *RenameProfileRequest) String() string {
func (*RenameProfileRequest) ProtoMessage() {}
func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[60]
+ mi := &file_daemon_proto_msgTypes[62]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4547,7 +4686,7 @@ func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenameProfileRequest.ProtoReflect.Descriptor instead.
func (*RenameProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{60}
+ return file_daemon_proto_rawDescGZIP(), []int{62}
}
func (x *RenameProfileRequest) GetUsername() string {
@@ -4581,7 +4720,7 @@ type RenameProfileResponse struct {
func (x *RenameProfileResponse) Reset() {
*x = RenameProfileResponse{}
- mi := &file_daemon_proto_msgTypes[61]
+ mi := &file_daemon_proto_msgTypes[63]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4593,7 +4732,7 @@ func (x *RenameProfileResponse) String() string {
func (*RenameProfileResponse) ProtoMessage() {}
func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[61]
+ mi := &file_daemon_proto_msgTypes[63]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4606,7 +4745,7 @@ func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenameProfileResponse.ProtoReflect.Descriptor instead.
func (*RenameProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{61}
+ return file_daemon_proto_rawDescGZIP(), []int{63}
}
func (x *RenameProfileResponse) GetOldProfileName() string {
@@ -4628,7 +4767,7 @@ type RemoveProfileRequest struct {
func (x *RemoveProfileRequest) Reset() {
*x = RemoveProfileRequest{}
- mi := &file_daemon_proto_msgTypes[62]
+ mi := &file_daemon_proto_msgTypes[64]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4640,7 +4779,7 @@ func (x *RemoveProfileRequest) String() string {
func (*RemoveProfileRequest) ProtoMessage() {}
func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[62]
+ mi := &file_daemon_proto_msgTypes[64]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4653,7 +4792,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead.
func (*RemoveProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{62}
+ return file_daemon_proto_rawDescGZIP(), []int{64}
}
func (x *RemoveProfileRequest) GetUsername() string {
@@ -4681,7 +4820,7 @@ type RemoveProfileResponse struct {
func (x *RemoveProfileResponse) Reset() {
*x = RemoveProfileResponse{}
- mi := &file_daemon_proto_msgTypes[63]
+ mi := &file_daemon_proto_msgTypes[65]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4693,7 +4832,7 @@ func (x *RemoveProfileResponse) String() string {
func (*RemoveProfileResponse) ProtoMessage() {}
func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[63]
+ mi := &file_daemon_proto_msgTypes[65]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4706,7 +4845,7 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead.
func (*RemoveProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{63}
+ return file_daemon_proto_rawDescGZIP(), []int{65}
}
func (x *RemoveProfileResponse) GetId() string {
@@ -4725,7 +4864,7 @@ type ListProfilesRequest struct {
func (x *ListProfilesRequest) Reset() {
*x = ListProfilesRequest{}
- mi := &file_daemon_proto_msgTypes[64]
+ mi := &file_daemon_proto_msgTypes[66]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4737,7 +4876,7 @@ func (x *ListProfilesRequest) String() string {
func (*ListProfilesRequest) ProtoMessage() {}
func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[64]
+ mi := &file_daemon_proto_msgTypes[66]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4750,7 +4889,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead.
func (*ListProfilesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{64}
+ return file_daemon_proto_rawDescGZIP(), []int{66}
}
func (x *ListProfilesRequest) GetUsername() string {
@@ -4769,7 +4908,7 @@ type ListProfilesResponse struct {
func (x *ListProfilesResponse) Reset() {
*x = ListProfilesResponse{}
- mi := &file_daemon_proto_msgTypes[65]
+ mi := &file_daemon_proto_msgTypes[67]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4781,7 +4920,7 @@ func (x *ListProfilesResponse) String() string {
func (*ListProfilesResponse) ProtoMessage() {}
func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[65]
+ mi := &file_daemon_proto_msgTypes[67]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4794,7 +4933,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead.
func (*ListProfilesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{65}
+ return file_daemon_proto_rawDescGZIP(), []int{67}
}
func (x *ListProfilesResponse) GetProfiles() []*Profile {
@@ -4815,7 +4954,7 @@ type Profile struct {
func (x *Profile) Reset() {
*x = Profile{}
- mi := &file_daemon_proto_msgTypes[66]
+ mi := &file_daemon_proto_msgTypes[68]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4827,7 +4966,7 @@ func (x *Profile) String() string {
func (*Profile) ProtoMessage() {}
func (x *Profile) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[66]
+ mi := &file_daemon_proto_msgTypes[68]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4840,7 +4979,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message {
// Deprecated: Use Profile.ProtoReflect.Descriptor instead.
func (*Profile) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{66}
+ return file_daemon_proto_rawDescGZIP(), []int{68}
}
func (x *Profile) GetName() string {
@@ -4872,7 +5011,7 @@ type GetActiveProfileRequest struct {
func (x *GetActiveProfileRequest) Reset() {
*x = GetActiveProfileRequest{}
- mi := &file_daemon_proto_msgTypes[67]
+ mi := &file_daemon_proto_msgTypes[69]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4884,7 +5023,7 @@ func (x *GetActiveProfileRequest) String() string {
func (*GetActiveProfileRequest) ProtoMessage() {}
func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[67]
+ mi := &file_daemon_proto_msgTypes[69]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4897,7 +5036,7 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead.
func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{67}
+ return file_daemon_proto_rawDescGZIP(), []int{69}
}
type GetActiveProfileResponse struct {
@@ -4911,7 +5050,7 @@ type GetActiveProfileResponse struct {
func (x *GetActiveProfileResponse) Reset() {
*x = GetActiveProfileResponse{}
- mi := &file_daemon_proto_msgTypes[68]
+ mi := &file_daemon_proto_msgTypes[70]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4923,7 +5062,7 @@ func (x *GetActiveProfileResponse) String() string {
func (*GetActiveProfileResponse) ProtoMessage() {}
func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[68]
+ mi := &file_daemon_proto_msgTypes[70]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4936,7 +5075,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead.
func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{68}
+ return file_daemon_proto_rawDescGZIP(), []int{70}
}
func (x *GetActiveProfileResponse) GetProfileName() string {
@@ -4970,7 +5109,7 @@ type LogoutRequest struct {
func (x *LogoutRequest) Reset() {
*x = LogoutRequest{}
- mi := &file_daemon_proto_msgTypes[69]
+ mi := &file_daemon_proto_msgTypes[71]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4982,7 +5121,7 @@ func (x *LogoutRequest) String() string {
func (*LogoutRequest) ProtoMessage() {}
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[69]
+ mi := &file_daemon_proto_msgTypes[71]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4995,7 +5134,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead.
func (*LogoutRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{69}
+ return file_daemon_proto_rawDescGZIP(), []int{71}
}
func (x *LogoutRequest) GetProfileName() string {
@@ -5020,7 +5159,7 @@ type LogoutResponse struct {
func (x *LogoutResponse) Reset() {
*x = LogoutResponse{}
- mi := &file_daemon_proto_msgTypes[70]
+ mi := &file_daemon_proto_msgTypes[72]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5032,7 +5171,7 @@ func (x *LogoutResponse) String() string {
func (*LogoutResponse) ProtoMessage() {}
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[70]
+ mi := &file_daemon_proto_msgTypes[72]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5045,7 +5184,79 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead.
func (*LogoutResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{70}
+ return file_daemon_proto_rawDescGZIP(), []int{72}
+}
+
+type WailsUIReadyRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *WailsUIReadyRequest) Reset() {
+ *x = WailsUIReadyRequest{}
+ mi := &file_daemon_proto_msgTypes[73]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *WailsUIReadyRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*WailsUIReadyRequest) ProtoMessage() {}
+
+func (x *WailsUIReadyRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[73]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use WailsUIReadyRequest.ProtoReflect.Descriptor instead.
+func (*WailsUIReadyRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{73}
+}
+
+type WailsUIReadyResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *WailsUIReadyResponse) Reset() {
+ *x = WailsUIReadyResponse{}
+ mi := &file_daemon_proto_msgTypes[74]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *WailsUIReadyResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*WailsUIReadyResponse) ProtoMessage() {}
+
+func (x *WailsUIReadyResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[74]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use WailsUIReadyResponse.ProtoReflect.Descriptor instead.
+func (*WailsUIReadyResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{74}
}
type GetFeaturesRequest struct {
@@ -5056,7 +5267,7 @@ type GetFeaturesRequest struct {
func (x *GetFeaturesRequest) Reset() {
*x = GetFeaturesRequest{}
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[75]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5068,7 +5279,7 @@ func (x *GetFeaturesRequest) String() string {
func (*GetFeaturesRequest) ProtoMessage() {}
func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[75]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5081,7 +5292,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead.
func (*GetFeaturesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{71}
+ return file_daemon_proto_rawDescGZIP(), []int{75}
}
type GetFeaturesResponse struct {
@@ -5089,13 +5300,19 @@ type GetFeaturesResponse struct {
DisableProfiles bool `protobuf:"varint,1,opt,name=disable_profiles,json=disableProfiles,proto3" json:"disable_profiles,omitempty"`
DisableUpdateSettings bool `protobuf:"varint,2,opt,name=disable_update_settings,json=disableUpdateSettings,proto3" json:"disable_update_settings,omitempty"`
DisableNetworks bool `protobuf:"varint,3,opt,name=disable_networks,json=disableNetworks,proto3" json:"disable_networks,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // disableAdvancedView gates the upcoming UI revision's advanced
+ // section. Tristate: unset = no MDM directive, the UI applies its
+ // own default; true = MDM enforces disable; false = MDM enforces
+ // enable. Sourced exclusively from the MDM policy — no CLI /
+ // config flag backs this value.
+ DisableAdvancedView *bool `protobuf:"varint,4,opt,name=disable_advanced_view,json=disableAdvancedView,proto3,oneof" json:"disable_advanced_view,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *GetFeaturesResponse) Reset() {
*x = GetFeaturesResponse{}
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5107,7 +5324,7 @@ func (x *GetFeaturesResponse) String() string {
func (*GetFeaturesResponse) ProtoMessage() {}
func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[76]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5120,7 +5337,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead.
func (*GetFeaturesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{72}
+ return file_daemon_proto_rawDescGZIP(), []int{76}
}
func (x *GetFeaturesResponse) GetDisableProfiles() bool {
@@ -5144,6 +5361,13 @@ func (x *GetFeaturesResponse) GetDisableNetworks() bool {
return false
}
+func (x *GetFeaturesResponse) GetDisableAdvancedView() bool {
+ if x != nil && x.DisableAdvancedView != nil {
+ return *x.DisableAdvancedView
+ }
+ return false
+}
+
// MDMManagedFieldsViolation is attached as a gRPC error detail on a
// FailedPrecondition status returned from SetConfig (and similar mutating
// RPCs) when the caller tries to modify one or more MDM-enforced fields.
@@ -5158,7 +5382,7 @@ type MDMManagedFieldsViolation struct {
func (x *MDMManagedFieldsViolation) Reset() {
*x = MDMManagedFieldsViolation{}
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[77]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5170,7 +5394,7 @@ func (x *MDMManagedFieldsViolation) String() string {
func (*MDMManagedFieldsViolation) ProtoMessage() {}
func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[77]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5183,7 +5407,7 @@ func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message {
// Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead.
func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{73}
+ return file_daemon_proto_rawDescGZIP(), []int{77}
}
func (x *MDMManagedFieldsViolation) GetFields() []string {
@@ -5201,7 +5425,7 @@ type TriggerUpdateRequest struct {
func (x *TriggerUpdateRequest) Reset() {
*x = TriggerUpdateRequest{}
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[78]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5213,7 +5437,7 @@ func (x *TriggerUpdateRequest) String() string {
func (*TriggerUpdateRequest) ProtoMessage() {}
func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[78]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5226,7 +5450,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead.
func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{74}
+ return file_daemon_proto_rawDescGZIP(), []int{78}
}
type TriggerUpdateResponse struct {
@@ -5239,7 +5463,7 @@ type TriggerUpdateResponse struct {
func (x *TriggerUpdateResponse) Reset() {
*x = TriggerUpdateResponse{}
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[79]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5251,7 +5475,7 @@ func (x *TriggerUpdateResponse) String() string {
func (*TriggerUpdateResponse) ProtoMessage() {}
func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[79]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5264,7 +5488,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead.
func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{75}
+ return file_daemon_proto_rawDescGZIP(), []int{79}
}
func (x *TriggerUpdateResponse) GetSuccess() bool {
@@ -5292,7 +5516,7 @@ type GetPeerSSHHostKeyRequest struct {
func (x *GetPeerSSHHostKeyRequest) Reset() {
*x = GetPeerSSHHostKeyRequest{}
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[80]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5304,7 +5528,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string {
func (*GetPeerSSHHostKeyRequest) ProtoMessage() {}
func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[80]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5317,7 +5541,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{76}
+ return file_daemon_proto_rawDescGZIP(), []int{80}
}
func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string {
@@ -5344,7 +5568,7 @@ type GetPeerSSHHostKeyResponse struct {
func (x *GetPeerSSHHostKeyResponse) Reset() {
*x = GetPeerSSHHostKeyResponse{}
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[81]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5356,7 +5580,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string {
func (*GetPeerSSHHostKeyResponse) ProtoMessage() {}
func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[81]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5369,7 +5593,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{77}
+ return file_daemon_proto_rawDescGZIP(), []int{81}
}
func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte {
@@ -5404,14 +5628,18 @@ func (x *GetPeerSSHHostKeyResponse) GetFound() bool {
type RequestJWTAuthRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// hint for OIDC login_hint parameter (typically email address)
- Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"`
+ // hasGraphicalSession tells the daemon that the caller has a graphical session,
+ // which decides whether PKCE or the device code flow is preferred. The daemon
+ // cannot detect this itself: it does not inherit the session environment.
+ HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *RequestJWTAuthRequest) Reset() {
*x = RequestJWTAuthRequest{}
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[82]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5423,7 +5651,7 @@ func (x *RequestJWTAuthRequest) String() string {
func (*RequestJWTAuthRequest) ProtoMessage() {}
func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[82]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5436,7 +5664,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{78}
+ return file_daemon_proto_rawDescGZIP(), []int{82}
}
func (x *RequestJWTAuthRequest) GetHint() string {
@@ -5446,6 +5674,13 @@ func (x *RequestJWTAuthRequest) GetHint() string {
return ""
}
+func (x *RequestJWTAuthRequest) GetHasGraphicalSession() bool {
+ if x != nil {
+ return x.HasGraphicalSession
+ }
+ return false
+}
+
// RequestJWTAuthResponse contains authentication flow information
type RequestJWTAuthResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -5469,7 +5704,7 @@ type RequestJWTAuthResponse struct {
func (x *RequestJWTAuthResponse) Reset() {
*x = RequestJWTAuthResponse{}
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[83]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5481,7 +5716,7 @@ func (x *RequestJWTAuthResponse) String() string {
func (*RequestJWTAuthResponse) ProtoMessage() {}
func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[83]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5494,7 +5729,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{79}
+ return file_daemon_proto_rawDescGZIP(), []int{83}
}
func (x *RequestJWTAuthResponse) GetVerificationURI() string {
@@ -5559,7 +5794,7 @@ type WaitJWTTokenRequest struct {
func (x *WaitJWTTokenRequest) Reset() {
*x = WaitJWTTokenRequest{}
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[84]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5571,7 +5806,7 @@ func (x *WaitJWTTokenRequest) String() string {
func (*WaitJWTTokenRequest) ProtoMessage() {}
func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[84]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5584,7 +5819,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{80}
+ return file_daemon_proto_rawDescGZIP(), []int{84}
}
func (x *WaitJWTTokenRequest) GetDeviceCode() string {
@@ -5616,7 +5851,7 @@ type WaitJWTTokenResponse struct {
func (x *WaitJWTTokenResponse) Reset() {
*x = WaitJWTTokenResponse{}
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[85]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5628,7 +5863,7 @@ func (x *WaitJWTTokenResponse) String() string {
func (*WaitJWTTokenResponse) ProtoMessage() {}
func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[85]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5641,7 +5876,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{81}
+ return file_daemon_proto_rawDescGZIP(), []int{85}
}
func (x *WaitJWTTokenResponse) GetToken() string {
@@ -5665,6 +5900,329 @@ func (x *WaitJWTTokenResponse) GetExpiresIn() int64 {
return 0
}
+// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow.
+type RequestExtendAuthSessionRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Optional OIDC login_hint (typically the user's email) to pre-fill the
+ // IdP login form.
+ Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"`
+ // hasGraphicalSession tells the daemon that the caller has a graphical session,
+ // which decides whether PKCE or the device code flow is preferred. The daemon
+ // cannot detect this itself: it does not inherit the session environment.
+ HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RequestExtendAuthSessionRequest) Reset() {
+ *x = RequestExtendAuthSessionRequest{}
+ mi := &file_daemon_proto_msgTypes[86]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RequestExtendAuthSessionRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RequestExtendAuthSessionRequest) ProtoMessage() {}
+
+func (x *RequestExtendAuthSessionRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[86]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RequestExtendAuthSessionRequest.ProtoReflect.Descriptor instead.
+func (*RequestExtendAuthSessionRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{86}
+}
+
+func (x *RequestExtendAuthSessionRequest) GetHint() string {
+ if x != nil && x.Hint != nil {
+ return *x.Hint
+ }
+ return ""
+}
+
+func (x *RequestExtendAuthSessionRequest) GetHasGraphicalSession() bool {
+ if x != nil {
+ return x.HasGraphicalSession
+ }
+ return false
+}
+
+// RequestExtendAuthSessionResponse carries the verification URI the UI
+// should open in a browser. The daemon retains the flow state and resolves
+// it via WaitExtendAuthSession.
+type RequestExtendAuthSessionResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // verification URI for the user to open in the browser
+ VerificationURI string `protobuf:"bytes,1,opt,name=verificationURI,proto3" json:"verificationURI,omitempty"`
+ // complete verification URI (with embedded user code)
+ VerificationURIComplete string `protobuf:"bytes,2,opt,name=verificationURIComplete,proto3" json:"verificationURIComplete,omitempty"`
+ // user code to enter on verification URI (for device-code flows)
+ UserCode string `protobuf:"bytes,3,opt,name=userCode,proto3" json:"userCode,omitempty"`
+ // device code for matching the WaitExtendAuthSession call to this flow
+ DeviceCode string `protobuf:"bytes,4,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"`
+ // expiration time in seconds for the device code / PKCE flow
+ ExpiresIn int64 `protobuf:"varint,5,opt,name=expiresIn,proto3" json:"expiresIn,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RequestExtendAuthSessionResponse) Reset() {
+ *x = RequestExtendAuthSessionResponse{}
+ mi := &file_daemon_proto_msgTypes[87]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RequestExtendAuthSessionResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RequestExtendAuthSessionResponse) ProtoMessage() {}
+
+func (x *RequestExtendAuthSessionResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[87]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RequestExtendAuthSessionResponse.ProtoReflect.Descriptor instead.
+func (*RequestExtendAuthSessionResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{87}
+}
+
+func (x *RequestExtendAuthSessionResponse) GetVerificationURI() string {
+ if x != nil {
+ return x.VerificationURI
+ }
+ return ""
+}
+
+func (x *RequestExtendAuthSessionResponse) GetVerificationURIComplete() string {
+ if x != nil {
+ return x.VerificationURIComplete
+ }
+ return ""
+}
+
+func (x *RequestExtendAuthSessionResponse) GetUserCode() string {
+ if x != nil {
+ return x.UserCode
+ }
+ return ""
+}
+
+func (x *RequestExtendAuthSessionResponse) GetDeviceCode() string {
+ if x != nil {
+ return x.DeviceCode
+ }
+ return ""
+}
+
+func (x *RequestExtendAuthSessionResponse) GetExpiresIn() int64 {
+ if x != nil {
+ return x.ExpiresIn
+ }
+ return 0
+}
+
+// WaitExtendAuthSessionRequest is sent by the UI after it opens the
+// verification URI. The daemon blocks on this call until the user
+// completes (or aborts) the SSO step.
+type WaitExtendAuthSessionRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // device code returned by RequestExtendAuthSession
+ DeviceCode string `protobuf:"bytes,1,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"`
+ // user code for verification
+ UserCode string `protobuf:"bytes,2,opt,name=userCode,proto3" json:"userCode,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *WaitExtendAuthSessionRequest) Reset() {
+ *x = WaitExtendAuthSessionRequest{}
+ mi := &file_daemon_proto_msgTypes[88]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *WaitExtendAuthSessionRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*WaitExtendAuthSessionRequest) ProtoMessage() {}
+
+func (x *WaitExtendAuthSessionRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[88]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use WaitExtendAuthSessionRequest.ProtoReflect.Descriptor instead.
+func (*WaitExtendAuthSessionRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{88}
+}
+
+func (x *WaitExtendAuthSessionRequest) GetDeviceCode() string {
+ if x != nil {
+ return x.DeviceCode
+ }
+ return ""
+}
+
+func (x *WaitExtendAuthSessionRequest) GetUserCode() string {
+ if x != nil {
+ return x.UserCode
+ }
+ return ""
+}
+
+// WaitExtendAuthSessionResponse carries the refreshed deadline returned
+// by the management server. Unset when the management server reports the
+// peer is not eligible for session extension.
+type WaitExtendAuthSessionResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *WaitExtendAuthSessionResponse) Reset() {
+ *x = WaitExtendAuthSessionResponse{}
+ mi := &file_daemon_proto_msgTypes[89]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *WaitExtendAuthSessionResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*WaitExtendAuthSessionResponse) ProtoMessage() {}
+
+func (x *WaitExtendAuthSessionResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[89]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use WaitExtendAuthSessionResponse.ProtoReflect.Descriptor instead.
+func (*WaitExtendAuthSessionResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{89}
+}
+
+func (x *WaitExtendAuthSessionResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.SessionExpiresAt
+ }
+ return nil
+}
+
+// DismissSessionWarningRequest is sent by the UI when the user clicks
+// "Dismiss" on the T-WarningLead notification.
+type DismissSessionWarningRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DismissSessionWarningRequest) Reset() {
+ *x = DismissSessionWarningRequest{}
+ mi := &file_daemon_proto_msgTypes[90]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DismissSessionWarningRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DismissSessionWarningRequest) ProtoMessage() {}
+
+func (x *DismissSessionWarningRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[90]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DismissSessionWarningRequest.ProtoReflect.Descriptor instead.
+func (*DismissSessionWarningRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{90}
+}
+
+// DismissSessionWarningResponse acknowledges the dismissal. Carries no
+// payload — the daemon's only obligation is to silence the upcoming
+// T-FinalWarningLead fallback for the current deadline.
+type DismissSessionWarningResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DismissSessionWarningResponse) Reset() {
+ *x = DismissSessionWarningResponse{}
+ mi := &file_daemon_proto_msgTypes[91]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DismissSessionWarningResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DismissSessionWarningResponse) ProtoMessage() {}
+
+func (x *DismissSessionWarningResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[91]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DismissSessionWarningResponse.ProtoReflect.Descriptor instead.
+func (*DismissSessionWarningResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{91}
+}
+
// StartCPUProfileRequest for starting CPU profiling
type StartCPUProfileRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -5674,7 +6232,7 @@ type StartCPUProfileRequest struct {
func (x *StartCPUProfileRequest) Reset() {
*x = StartCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[92]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5686,7 +6244,7 @@ func (x *StartCPUProfileRequest) String() string {
func (*StartCPUProfileRequest) ProtoMessage() {}
func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[92]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5699,7 +6257,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{82}
+ return file_daemon_proto_rawDescGZIP(), []int{92}
}
// StartCPUProfileResponse confirms CPU profiling has started
@@ -5711,7 +6269,7 @@ type StartCPUProfileResponse struct {
func (x *StartCPUProfileResponse) Reset() {
*x = StartCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[93]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5723,7 +6281,7 @@ func (x *StartCPUProfileResponse) String() string {
func (*StartCPUProfileResponse) ProtoMessage() {}
func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[93]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5736,7 +6294,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{83}
+ return file_daemon_proto_rawDescGZIP(), []int{93}
}
// StopCPUProfileRequest for stopping CPU profiling
@@ -5748,7 +6306,7 @@ type StopCPUProfileRequest struct {
func (x *StopCPUProfileRequest) Reset() {
*x = StopCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[94]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5760,7 +6318,7 @@ func (x *StopCPUProfileRequest) String() string {
func (*StopCPUProfileRequest) ProtoMessage() {}
func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[94]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5773,7 +6331,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{84}
+ return file_daemon_proto_rawDescGZIP(), []int{94}
}
// StopCPUProfileResponse confirms CPU profiling has stopped
@@ -5785,7 +6343,7 @@ type StopCPUProfileResponse struct {
func (x *StopCPUProfileResponse) Reset() {
*x = StopCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[95]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5797,7 +6355,7 @@ func (x *StopCPUProfileResponse) String() string {
func (*StopCPUProfileResponse) ProtoMessage() {}
func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[95]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5810,7 +6368,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{85}
+ return file_daemon_proto_rawDescGZIP(), []int{95}
}
type InstallerResultRequest struct {
@@ -5821,7 +6379,7 @@ type InstallerResultRequest struct {
func (x *InstallerResultRequest) Reset() {
*x = InstallerResultRequest{}
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[96]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5833,7 +6391,7 @@ func (x *InstallerResultRequest) String() string {
func (*InstallerResultRequest) ProtoMessage() {}
func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[96]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5846,7 +6404,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead.
func (*InstallerResultRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{86}
+ return file_daemon_proto_rawDescGZIP(), []int{96}
}
type InstallerResultResponse struct {
@@ -5859,7 +6417,7 @@ type InstallerResultResponse struct {
func (x *InstallerResultResponse) Reset() {
*x = InstallerResultResponse{}
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[97]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5871,7 +6429,7 @@ func (x *InstallerResultResponse) String() string {
func (*InstallerResultResponse) ProtoMessage() {}
func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[97]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5884,7 +6442,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead.
func (*InstallerResultResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{87}
+ return file_daemon_proto_rawDescGZIP(), []int{97}
}
func (x *InstallerResultResponse) GetSuccess() bool {
@@ -5917,7 +6475,7 @@ type ExposeServiceRequest struct {
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[98]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5929,7 +6487,7 @@ func (x *ExposeServiceRequest) String() string {
func (*ExposeServiceRequest) ProtoMessage() {}
func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[98]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5942,7 +6500,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead.
func (*ExposeServiceRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{88}
+ return file_daemon_proto_rawDescGZIP(), []int{98}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -6013,7 +6571,7 @@ type ExposeServiceEvent struct {
func (x *ExposeServiceEvent) Reset() {
*x = ExposeServiceEvent{}
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[99]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6025,7 +6583,7 @@ func (x *ExposeServiceEvent) String() string {
func (*ExposeServiceEvent) ProtoMessage() {}
func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[99]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6038,7 +6596,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead.
func (*ExposeServiceEvent) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{89}
+ return file_daemon_proto_rawDescGZIP(), []int{99}
}
func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event {
@@ -6079,7 +6637,7 @@ type ExposeServiceReady struct {
func (x *ExposeServiceReady) Reset() {
*x = ExposeServiceReady{}
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[100]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6091,7 +6649,7 @@ func (x *ExposeServiceReady) String() string {
func (*ExposeServiceReady) ProtoMessage() {}
func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[100]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6104,7 +6662,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead.
func (*ExposeServiceReady) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{90}
+ return file_daemon_proto_rawDescGZIP(), []int{100}
}
func (x *ExposeServiceReady) GetServiceName() string {
@@ -6149,7 +6707,7 @@ type StartCaptureRequest struct {
func (x *StartCaptureRequest) Reset() {
*x = StartCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[101]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6161,7 +6719,7 @@ func (x *StartCaptureRequest) String() string {
func (*StartCaptureRequest) ProtoMessage() {}
func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[101]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6174,7 +6732,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{91}
+ return file_daemon_proto_rawDescGZIP(), []int{101}
}
func (x *StartCaptureRequest) GetTextOutput() bool {
@@ -6228,7 +6786,7 @@ type CapturePacket struct {
func (x *CapturePacket) Reset() {
*x = CapturePacket{}
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[102]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6240,7 +6798,7 @@ func (x *CapturePacket) String() string {
func (*CapturePacket) ProtoMessage() {}
func (x *CapturePacket) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[102]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6253,7 +6811,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message {
// Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead.
func (*CapturePacket) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{92}
+ return file_daemon_proto_rawDescGZIP(), []int{102}
}
func (x *CapturePacket) GetData() []byte {
@@ -6274,7 +6832,7 @@ type StartBundleCaptureRequest struct {
func (x *StartBundleCaptureRequest) Reset() {
*x = StartBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[103]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6286,7 +6844,7 @@ func (x *StartBundleCaptureRequest) String() string {
func (*StartBundleCaptureRequest) ProtoMessage() {}
func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[103]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6299,7 +6857,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{93}
+ return file_daemon_proto_rawDescGZIP(), []int{103}
}
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
@@ -6317,7 +6875,7 @@ type StartBundleCaptureResponse struct {
func (x *StartBundleCaptureResponse) Reset() {
*x = StartBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[94]
+ mi := &file_daemon_proto_msgTypes[104]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6329,7 +6887,7 @@ func (x *StartBundleCaptureResponse) String() string {
func (*StartBundleCaptureResponse) ProtoMessage() {}
func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[94]
+ mi := &file_daemon_proto_msgTypes[104]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6342,7 +6900,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{94}
+ return file_daemon_proto_rawDescGZIP(), []int{104}
}
type StopBundleCaptureRequest struct {
@@ -6353,7 +6911,7 @@ type StopBundleCaptureRequest struct {
func (x *StopBundleCaptureRequest) Reset() {
*x = StopBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[105]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6365,7 +6923,7 @@ func (x *StopBundleCaptureRequest) String() string {
func (*StopBundleCaptureRequest) ProtoMessage() {}
func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[105]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6378,7 +6936,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{95}
+ return file_daemon_proto_rawDescGZIP(), []int{105}
}
type StopBundleCaptureResponse struct {
@@ -6389,7 +6947,7 @@ type StopBundleCaptureResponse struct {
func (x *StopBundleCaptureResponse) Reset() {
*x = StopBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[96]
+ mi := &file_daemon_proto_msgTypes[106]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6401,7 +6959,7 @@ func (x *StopBundleCaptureResponse) String() string {
func (*StopBundleCaptureResponse) ProtoMessage() {}
func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[96]
+ mi := &file_daemon_proto_msgTypes[106]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6414,7 +6972,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{96}
+ return file_daemon_proto_rawDescGZIP(), []int{106}
}
type PortInfo_Range struct {
@@ -6427,7 +6985,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
- mi := &file_daemon_proto_msgTypes[98]
+ mi := &file_daemon_proto_msgTypes[108]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6439,7 +6997,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[98]
+ mi := &file_daemon_proto_msgTypes[108]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6557,10 +7115,11 @@ const file_daemon_proto_rawDesc = "" +
"\buserCode\x18\x01 \x01(\tR\buserCode\x12\x1a\n" +
"\bhostname\x18\x02 \x01(\tR\bhostname\",\n" +
"\x14WaitSSOLoginResponse\x12\x14\n" +
- "\x05email\x18\x01 \x01(\tR\x05email\"v\n" +
+ "\x05email\x18\x01 \x01(\tR\x05email\"\x8c\x01\n" +
"\tUpRequest\x12%\n" +
"\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" +
- "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" +
+ "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01\x12\x14\n" +
+ "\x05async\x18\x04 \x01(\bR\x05asyncB\x0e\n" +
"\f_profileNameB\v\n" +
"\t_usernameJ\x04\b\x03\x10\x04\"\f\n" +
"\n" +
@@ -6569,13 +7128,14 @@ const file_daemon_proto_rawDesc = "" +
"\x11getFullPeerStatus\x18\x01 \x01(\bR\x11getFullPeerStatus\x12(\n" +
"\x0fshouldRunProbes\x18\x02 \x01(\bR\x0fshouldRunProbes\x12'\n" +
"\fwaitForReady\x18\x03 \x01(\bH\x00R\fwaitForReady\x88\x01\x01B\x0f\n" +
- "\r_waitForReady\"\x82\x01\n" +
+ "\r_waitForReady\"\xca\x01\n" +
"\x0eStatusResponse\x12\x16\n" +
"\x06status\x18\x01 \x01(\tR\x06status\x122\n" +
"\n" +
"fullStatus\x18\x02 \x01(\v2\x12.daemon.FullStatusR\n" +
"fullStatus\x12$\n" +
- "\rdaemonVersion\x18\x03 \x01(\tR\rdaemonVersion\"\r\n" +
+ "\rdaemonVersion\x18\x03 \x01(\tR\rdaemonVersion\x12F\n" +
+ "\x10sessionExpiresAt\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\r\n" +
"\vDownRequest\"\x0e\n" +
"\fDownResponse\"P\n" +
"\x10GetConfigRequest\x12 \n" +
@@ -6676,7 +7236,7 @@ const file_daemon_proto_rawDesc = "" +
"\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" +
"\x0eSSHServerState\x12\x18\n" +
"\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" +
- "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xaf\x04\n" +
+ "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdb\x04\n" +
"\n" +
"FullStatus\x12A\n" +
"\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" +
@@ -6690,7 +7250,8 @@ const file_daemon_proto_rawDesc = "" +
"\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" +
"\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" +
"\x0esshServerState\x18\n" +
- " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\"\x15\n" +
+ " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\x12*\n" +
+ "\x10networksRevision\x18\v \x01(\x04R\x10networksRevision\"\x15\n" +
"\x13ListNetworksRequest\"?\n" +
"\x14ListNetworksResponse\x12'\n" +
"\x06routes\x18\x01 \x03(\v2\x0f.daemon.NetworkR\x06routes\"a\n" +
@@ -6726,7 +7287,7 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" +
- "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" +
+ "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
@@ -6736,7 +7297,9 @@ const file_daemon_proto_rawDesc = "" +
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" +
"\n" +
"cliVersion\x18\x06 \x01(\tR\n" +
- "cliVersion\"}\n" +
+ "cliVersion\x12&\n" +
+ "\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\x12&\n" +
+ "\x0eanonymizeLevel\x18\b \x01(\tR\x0eanonymizeLevel\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
@@ -6746,7 +7309,10 @@ const file_daemon_proto_rawDesc = "" +
"\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"<\n" +
"\x12SetLogLevelRequest\x12&\n" +
"\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"\x15\n" +
- "\x13SetLogLevelResponse\"\x1b\n" +
+ "\x13SetLogLevelResponse\"*\n" +
+ "\x14RegisterUILogRequest\x12\x12\n" +
+ "\x04path\x18\x01 \x01(\tR\x04path\"\x17\n" +
+ "\x15RegisterUILogResponse\"\x1b\n" +
"\x05State\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\"\x13\n" +
"\x11ListStatesRequest\";\n" +
@@ -6935,12 +7501,16 @@ const file_daemon_proto_rawDesc = "" +
"\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" +
"\f_profileNameB\v\n" +
"\t_username\"\x10\n" +
- "\x0eLogoutResponse\"\x14\n" +
- "\x12GetFeaturesRequest\"\xa3\x01\n" +
+ "\x0eLogoutResponse\"\x15\n" +
+ "\x13WailsUIReadyRequest\"\x16\n" +
+ "\x14WailsUIReadyResponse\"\x14\n" +
+ "\x12GetFeaturesRequest\"\xf6\x01\n" +
"\x13GetFeaturesResponse\x12)\n" +
"\x10disable_profiles\x18\x01 \x01(\bR\x0fdisableProfiles\x126\n" +
"\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\x12)\n" +
- "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"3\n" +
+ "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\x127\n" +
+ "\x15disable_advanced_view\x18\x04 \x01(\bH\x00R\x13disableAdvancedView\x88\x01\x01B\x18\n" +
+ "\x16_disable_advanced_view\"3\n" +
"\x19MDMManagedFieldsViolation\x12\x16\n" +
"\x06fields\x18\x01 \x03(\tR\x06fields\"\x16\n" +
"\x14TriggerUpdateRequest\"M\n" +
@@ -6955,9 +7525,10 @@ const file_daemon_proto_rawDesc = "" +
"sshHostKey\x12\x16\n" +
"\x06peerIP\x18\x02 \x01(\tR\x06peerIP\x12\x1a\n" +
"\bpeerFQDN\x18\x03 \x01(\tR\bpeerFQDN\x12\x14\n" +
- "\x05found\x18\x04 \x01(\bR\x05found\"9\n" +
+ "\x05found\x18\x04 \x01(\bR\x05found\"k\n" +
"\x15RequestJWTAuthRequest\x12\x17\n" +
- "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" +
+ "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" +
+ "\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" +
"\x05_hint\"\x9a\x02\n" +
"\x16RequestJWTAuthResponse\x12(\n" +
"\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" +
@@ -6977,7 +7548,28 @@ const file_daemon_proto_rawDesc = "" +
"\x14WaitJWTTokenResponse\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\x12\x1c\n" +
"\ttokenType\x18\x02 \x01(\tR\ttokenType\x12\x1c\n" +
- "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"\x18\n" +
+ "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"u\n" +
+ "\x1fRequestExtendAuthSessionRequest\x12\x17\n" +
+ "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" +
+ "\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" +
+ "\x05_hint\"\xe0\x01\n" +
+ " RequestExtendAuthSessionResponse\x12(\n" +
+ "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" +
+ "\x17verificationURIComplete\x18\x02 \x01(\tR\x17verificationURIComplete\x12\x1a\n" +
+ "\buserCode\x18\x03 \x01(\tR\buserCode\x12\x1e\n" +
+ "\n" +
+ "deviceCode\x18\x04 \x01(\tR\n" +
+ "deviceCode\x12\x1c\n" +
+ "\texpiresIn\x18\x05 \x01(\x03R\texpiresIn\"Z\n" +
+ "\x1cWaitExtendAuthSessionRequest\x12\x1e\n" +
+ "\n" +
+ "deviceCode\x18\x01 \x01(\tR\n" +
+ "deviceCode\x12\x1a\n" +
+ "\buserCode\x18\x02 \x01(\tR\buserCode\"g\n" +
+ "\x1dWaitExtendAuthSessionResponse\x12F\n" +
+ "\x10sessionExpiresAt\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\x1e\n" +
+ "\x1cDismissSessionWarningRequest\"\x1f\n" +
+ "\x1dDismissSessionWarningResponse\"\x18\n" +
"\x16StartCPUProfileRequest\"\x19\n" +
"\x17StartCPUProfileResponse\"\x17\n" +
"\x15StopCPUProfileRequest\"\x18\n" +
@@ -7040,12 +7632,13 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"EXPOSE_UDP\x10\x03\x12\x0e\n" +
"\n" +
- "EXPOSE_TLS\x10\x042\xff\x17\n" +
+ "EXPOSE_TLS\x10\x042\xa3\x1c\n" +
"\rDaemonService\x126\n" +
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
"\x02Up\x12\x11.daemon.UpRequest\x1a\x12.daemon.UpResponse\"\x00\x129\n" +
- "\x06Status\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x00\x123\n" +
+ "\x06Status\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x00\x12D\n" +
+ "\x0fSubscribeStatus\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x000\x01\x123\n" +
"\x04Down\x12\x13.daemon.DownRequest\x1a\x14.daemon.DownResponse\"\x00\x12B\n" +
"\tGetConfig\x12\x18.daemon.GetConfigRequest\x1a\x19.daemon.GetConfigResponse\"\x00\x12K\n" +
"\fListNetworks\x12\x1b.daemon.ListNetworksRequest\x1a\x1c.daemon.ListNetworksResponse\"\x00\x12Q\n" +
@@ -7067,6 +7660,7 @@ const file_daemon_proto_rawDesc = "" +
"\x11StopBundleCapture\x12 .daemon.StopBundleCaptureRequest\x1a!.daemon.StopBundleCaptureResponse\"\x00\x12D\n" +
"\x0fSubscribeEvents\x12\x18.daemon.SubscribeRequest\x1a\x13.daemon.SystemEvent\"\x000\x01\x12B\n" +
"\tGetEvents\x12\x18.daemon.GetEventsRequest\x1a\x19.daemon.GetEventsResponse\"\x00\x12N\n" +
+ "\rRegisterUILog\x12\x1c.daemon.RegisterUILogRequest\x1a\x1d.daemon.RegisterUILogResponse\"\x00\x12N\n" +
"\rSwitchProfile\x12\x1c.daemon.SwitchProfileRequest\x1a\x1d.daemon.SwitchProfileResponse\"\x00\x12B\n" +
"\tSetConfig\x12\x18.daemon.SetConfigRequest\x1a\x19.daemon.SetConfigResponse\"\x00\x12E\n" +
"\n" +
@@ -7080,11 +7674,15 @@ const file_daemon_proto_rawDesc = "" +
"\rTriggerUpdate\x12\x1c.daemon.TriggerUpdateRequest\x1a\x1d.daemon.TriggerUpdateResponse\"\x00\x12Z\n" +
"\x11GetPeerSSHHostKey\x12 .daemon.GetPeerSSHHostKeyRequest\x1a!.daemon.GetPeerSSHHostKeyResponse\"\x00\x12Q\n" +
"\x0eRequestJWTAuth\x12\x1d.daemon.RequestJWTAuthRequest\x1a\x1e.daemon.RequestJWTAuthResponse\"\x00\x12K\n" +
- "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12T\n" +
+ "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12o\n" +
+ "\x18RequestExtendAuthSession\x12'.daemon.RequestExtendAuthSessionRequest\x1a(.daemon.RequestExtendAuthSessionResponse\"\x00\x12f\n" +
+ "\x15WaitExtendAuthSession\x12$.daemon.WaitExtendAuthSessionRequest\x1a%.daemon.WaitExtendAuthSessionResponse\"\x00\x12f\n" +
+ "\x15DismissSessionWarning\x12$.daemon.DismissSessionWarningRequest\x1a%.daemon.DismissSessionWarningResponse\"\x00\x12T\n" +
"\x0fStartCPUProfile\x12\x1e.daemon.StartCPUProfileRequest\x1a\x1f.daemon.StartCPUProfileResponse\"\x00\x12Q\n" +
"\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12W\n" +
"\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" +
- "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01B\bZ\x06/protob\x06proto3"
+ "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01\x12K\n" +
+ "\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00B\bZ\x06/protob\x06proto3"
var (
file_daemon_proto_rawDescOnce sync.Once
@@ -7099,7 +7697,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 100)
+var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
@@ -7142,195 +7740,219 @@ var file_daemon_proto_goTypes = []any{
(*GetLogLevelResponse)(nil), // 38: daemon.GetLogLevelResponse
(*SetLogLevelRequest)(nil), // 39: daemon.SetLogLevelRequest
(*SetLogLevelResponse)(nil), // 40: daemon.SetLogLevelResponse
- (*State)(nil), // 41: daemon.State
- (*ListStatesRequest)(nil), // 42: daemon.ListStatesRequest
- (*ListStatesResponse)(nil), // 43: daemon.ListStatesResponse
- (*CleanStateRequest)(nil), // 44: daemon.CleanStateRequest
- (*CleanStateResponse)(nil), // 45: daemon.CleanStateResponse
- (*DeleteStateRequest)(nil), // 46: daemon.DeleteStateRequest
- (*DeleteStateResponse)(nil), // 47: daemon.DeleteStateResponse
- (*SetSyncResponsePersistenceRequest)(nil), // 48: daemon.SetSyncResponsePersistenceRequest
- (*SetSyncResponsePersistenceResponse)(nil), // 49: daemon.SetSyncResponsePersistenceResponse
- (*TCPFlags)(nil), // 50: daemon.TCPFlags
- (*TracePacketRequest)(nil), // 51: daemon.TracePacketRequest
- (*TraceStage)(nil), // 52: daemon.TraceStage
- (*TracePacketResponse)(nil), // 53: daemon.TracePacketResponse
- (*SubscribeRequest)(nil), // 54: daemon.SubscribeRequest
- (*SystemEvent)(nil), // 55: daemon.SystemEvent
- (*GetEventsRequest)(nil), // 56: daemon.GetEventsRequest
- (*GetEventsResponse)(nil), // 57: daemon.GetEventsResponse
- (*SwitchProfileRequest)(nil), // 58: daemon.SwitchProfileRequest
- (*SwitchProfileResponse)(nil), // 59: daemon.SwitchProfileResponse
- (*SetConfigRequest)(nil), // 60: daemon.SetConfigRequest
- (*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse
- (*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest
- (*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse
- (*RenameProfileRequest)(nil), // 64: daemon.RenameProfileRequest
- (*RenameProfileResponse)(nil), // 65: daemon.RenameProfileResponse
- (*RemoveProfileRequest)(nil), // 66: daemon.RemoveProfileRequest
- (*RemoveProfileResponse)(nil), // 67: daemon.RemoveProfileResponse
- (*ListProfilesRequest)(nil), // 68: daemon.ListProfilesRequest
- (*ListProfilesResponse)(nil), // 69: daemon.ListProfilesResponse
- (*Profile)(nil), // 70: daemon.Profile
- (*GetActiveProfileRequest)(nil), // 71: daemon.GetActiveProfileRequest
- (*GetActiveProfileResponse)(nil), // 72: daemon.GetActiveProfileResponse
- (*LogoutRequest)(nil), // 73: daemon.LogoutRequest
- (*LogoutResponse)(nil), // 74: daemon.LogoutResponse
- (*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest
- (*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse
- (*MDMManagedFieldsViolation)(nil), // 77: daemon.MDMManagedFieldsViolation
- (*TriggerUpdateRequest)(nil), // 78: daemon.TriggerUpdateRequest
- (*TriggerUpdateResponse)(nil), // 79: daemon.TriggerUpdateResponse
- (*GetPeerSSHHostKeyRequest)(nil), // 80: daemon.GetPeerSSHHostKeyRequest
- (*GetPeerSSHHostKeyResponse)(nil), // 81: daemon.GetPeerSSHHostKeyResponse
- (*RequestJWTAuthRequest)(nil), // 82: daemon.RequestJWTAuthRequest
- (*RequestJWTAuthResponse)(nil), // 83: daemon.RequestJWTAuthResponse
- (*WaitJWTTokenRequest)(nil), // 84: daemon.WaitJWTTokenRequest
- (*WaitJWTTokenResponse)(nil), // 85: daemon.WaitJWTTokenResponse
- (*StartCPUProfileRequest)(nil), // 86: daemon.StartCPUProfileRequest
- (*StartCPUProfileResponse)(nil), // 87: daemon.StartCPUProfileResponse
- (*StopCPUProfileRequest)(nil), // 88: daemon.StopCPUProfileRequest
- (*StopCPUProfileResponse)(nil), // 89: daemon.StopCPUProfileResponse
- (*InstallerResultRequest)(nil), // 90: daemon.InstallerResultRequest
- (*InstallerResultResponse)(nil), // 91: daemon.InstallerResultResponse
- (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest
- (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent
- (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady
- (*StartCaptureRequest)(nil), // 95: daemon.StartCaptureRequest
- (*CapturePacket)(nil), // 96: daemon.CapturePacket
- (*StartBundleCaptureRequest)(nil), // 97: daemon.StartBundleCaptureRequest
- (*StartBundleCaptureResponse)(nil), // 98: daemon.StartBundleCaptureResponse
- (*StopBundleCaptureRequest)(nil), // 99: daemon.StopBundleCaptureRequest
- (*StopBundleCaptureResponse)(nil), // 100: daemon.StopBundleCaptureResponse
- nil, // 101: daemon.Network.ResolvedIPsEntry
- (*PortInfo_Range)(nil), // 102: daemon.PortInfo.Range
- nil, // 103: daemon.SystemEvent.MetadataEntry
- (*durationpb.Duration)(nil), // 104: google.protobuf.Duration
- (*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp
+ (*RegisterUILogRequest)(nil), // 41: daemon.RegisterUILogRequest
+ (*RegisterUILogResponse)(nil), // 42: daemon.RegisterUILogResponse
+ (*State)(nil), // 43: daemon.State
+ (*ListStatesRequest)(nil), // 44: daemon.ListStatesRequest
+ (*ListStatesResponse)(nil), // 45: daemon.ListStatesResponse
+ (*CleanStateRequest)(nil), // 46: daemon.CleanStateRequest
+ (*CleanStateResponse)(nil), // 47: daemon.CleanStateResponse
+ (*DeleteStateRequest)(nil), // 48: daemon.DeleteStateRequest
+ (*DeleteStateResponse)(nil), // 49: daemon.DeleteStateResponse
+ (*SetSyncResponsePersistenceRequest)(nil), // 50: daemon.SetSyncResponsePersistenceRequest
+ (*SetSyncResponsePersistenceResponse)(nil), // 51: daemon.SetSyncResponsePersistenceResponse
+ (*TCPFlags)(nil), // 52: daemon.TCPFlags
+ (*TracePacketRequest)(nil), // 53: daemon.TracePacketRequest
+ (*TraceStage)(nil), // 54: daemon.TraceStage
+ (*TracePacketResponse)(nil), // 55: daemon.TracePacketResponse
+ (*SubscribeRequest)(nil), // 56: daemon.SubscribeRequest
+ (*SystemEvent)(nil), // 57: daemon.SystemEvent
+ (*GetEventsRequest)(nil), // 58: daemon.GetEventsRequest
+ (*GetEventsResponse)(nil), // 59: daemon.GetEventsResponse
+ (*SwitchProfileRequest)(nil), // 60: daemon.SwitchProfileRequest
+ (*SwitchProfileResponse)(nil), // 61: daemon.SwitchProfileResponse
+ (*SetConfigRequest)(nil), // 62: daemon.SetConfigRequest
+ (*SetConfigResponse)(nil), // 63: daemon.SetConfigResponse
+ (*AddProfileRequest)(nil), // 64: daemon.AddProfileRequest
+ (*AddProfileResponse)(nil), // 65: daemon.AddProfileResponse
+ (*RenameProfileRequest)(nil), // 66: daemon.RenameProfileRequest
+ (*RenameProfileResponse)(nil), // 67: daemon.RenameProfileResponse
+ (*RemoveProfileRequest)(nil), // 68: daemon.RemoveProfileRequest
+ (*RemoveProfileResponse)(nil), // 69: daemon.RemoveProfileResponse
+ (*ListProfilesRequest)(nil), // 70: daemon.ListProfilesRequest
+ (*ListProfilesResponse)(nil), // 71: daemon.ListProfilesResponse
+ (*Profile)(nil), // 72: daemon.Profile
+ (*GetActiveProfileRequest)(nil), // 73: daemon.GetActiveProfileRequest
+ (*GetActiveProfileResponse)(nil), // 74: daemon.GetActiveProfileResponse
+ (*LogoutRequest)(nil), // 75: daemon.LogoutRequest
+ (*LogoutResponse)(nil), // 76: daemon.LogoutResponse
+ (*WailsUIReadyRequest)(nil), // 77: daemon.WailsUIReadyRequest
+ (*WailsUIReadyResponse)(nil), // 78: daemon.WailsUIReadyResponse
+ (*GetFeaturesRequest)(nil), // 79: daemon.GetFeaturesRequest
+ (*GetFeaturesResponse)(nil), // 80: daemon.GetFeaturesResponse
+ (*MDMManagedFieldsViolation)(nil), // 81: daemon.MDMManagedFieldsViolation
+ (*TriggerUpdateRequest)(nil), // 82: daemon.TriggerUpdateRequest
+ (*TriggerUpdateResponse)(nil), // 83: daemon.TriggerUpdateResponse
+ (*GetPeerSSHHostKeyRequest)(nil), // 84: daemon.GetPeerSSHHostKeyRequest
+ (*GetPeerSSHHostKeyResponse)(nil), // 85: daemon.GetPeerSSHHostKeyResponse
+ (*RequestJWTAuthRequest)(nil), // 86: daemon.RequestJWTAuthRequest
+ (*RequestJWTAuthResponse)(nil), // 87: daemon.RequestJWTAuthResponse
+ (*WaitJWTTokenRequest)(nil), // 88: daemon.WaitJWTTokenRequest
+ (*WaitJWTTokenResponse)(nil), // 89: daemon.WaitJWTTokenResponse
+ (*RequestExtendAuthSessionRequest)(nil), // 90: daemon.RequestExtendAuthSessionRequest
+ (*RequestExtendAuthSessionResponse)(nil), // 91: daemon.RequestExtendAuthSessionResponse
+ (*WaitExtendAuthSessionRequest)(nil), // 92: daemon.WaitExtendAuthSessionRequest
+ (*WaitExtendAuthSessionResponse)(nil), // 93: daemon.WaitExtendAuthSessionResponse
+ (*DismissSessionWarningRequest)(nil), // 94: daemon.DismissSessionWarningRequest
+ (*DismissSessionWarningResponse)(nil), // 95: daemon.DismissSessionWarningResponse
+ (*StartCPUProfileRequest)(nil), // 96: daemon.StartCPUProfileRequest
+ (*StartCPUProfileResponse)(nil), // 97: daemon.StartCPUProfileResponse
+ (*StopCPUProfileRequest)(nil), // 98: daemon.StopCPUProfileRequest
+ (*StopCPUProfileResponse)(nil), // 99: daemon.StopCPUProfileResponse
+ (*InstallerResultRequest)(nil), // 100: daemon.InstallerResultRequest
+ (*InstallerResultResponse)(nil), // 101: daemon.InstallerResultResponse
+ (*ExposeServiceRequest)(nil), // 102: daemon.ExposeServiceRequest
+ (*ExposeServiceEvent)(nil), // 103: daemon.ExposeServiceEvent
+ (*ExposeServiceReady)(nil), // 104: daemon.ExposeServiceReady
+ (*StartCaptureRequest)(nil), // 105: daemon.StartCaptureRequest
+ (*CapturePacket)(nil), // 106: daemon.CapturePacket
+ (*StartBundleCaptureRequest)(nil), // 107: daemon.StartBundleCaptureRequest
+ (*StartBundleCaptureResponse)(nil), // 108: daemon.StartBundleCaptureResponse
+ (*StopBundleCaptureRequest)(nil), // 109: daemon.StopBundleCaptureRequest
+ (*StopBundleCaptureResponse)(nil), // 110: daemon.StopBundleCaptureResponse
+ nil, // 111: daemon.Network.ResolvedIPsEntry
+ (*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range
+ nil, // 113: daemon.SystemEvent.MetadataEntry
+ (*durationpb.Duration)(nil), // 114: google.protobuf.Duration
+ (*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
- 104, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
- 105, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
- 105, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
- 104, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
- 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
- 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
- 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState
- 18, // 8: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
- 17, // 9: daemon.FullStatus.peers:type_name -> daemon.PeerState
- 21, // 10: daemon.FullStatus.relays:type_name -> daemon.RelayState
- 22, // 11: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
- 55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent
- 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
- 31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
- 101, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
- 102, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
- 32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
- 32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
- 33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
- 0, // 20: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel
- 0, // 21: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel
- 41, // 22: daemon.ListStatesResponse.states:type_name -> daemon.State
- 50, // 23: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
- 52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
- 2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
- 3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
- 105, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
- 103, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
- 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
- 104, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
- 70, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
- 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
- 94, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
- 104, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
- 104, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
- 30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
- 5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
- 7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
- 9, // 39: daemon.DaemonService.Up:input_type -> daemon.UpRequest
- 11, // 40: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
- 13, // 41: daemon.DaemonService.Down:input_type -> daemon.DownRequest
- 15, // 42: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
- 26, // 43: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
- 28, // 44: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
- 28, // 45: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
- 4, // 46: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
- 35, // 47: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
- 37, // 48: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
- 39, // 49: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
- 42, // 50: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
- 44, // 51: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
- 46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
- 48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
- 51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
- 95, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
- 97, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
- 99, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
- 54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
- 56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
- 58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
- 60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
- 62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
- 64, // 63: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
- 66, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
- 68, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
- 71, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
- 73, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
- 75, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
- 78, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
- 80, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
- 82, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
- 84, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
- 86, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
- 88, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
- 90, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
- 92, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
- 6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
- 8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
- 10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse
- 12, // 80: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
- 14, // 81: daemon.DaemonService.Down:output_type -> daemon.DownResponse
- 16, // 82: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
- 27, // 83: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
- 29, // 84: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
- 29, // 85: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
- 34, // 86: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
- 36, // 87: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
- 38, // 88: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
- 40, // 89: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
- 43, // 90: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
- 45, // 91: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
- 47, // 92: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
- 49, // 93: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
- 53, // 94: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
- 96, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
- 98, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
- 100, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
- 55, // 98: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
- 57, // 99: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
- 59, // 100: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
- 61, // 101: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
- 63, // 102: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
- 65, // 103: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
- 67, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
- 69, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
- 72, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
- 74, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
- 76, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
- 79, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
- 81, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
- 83, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
- 85, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
- 87, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
- 89, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
- 91, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
- 93, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
- 77, // [77:117] is the sub-list for method output_type
- 37, // [37:77] is the sub-list for method input_type
- 37, // [37:37] is the sub-list for extension type_name
- 37, // [37:37] is the sub-list for extension extendee
- 0, // [0:37] is the sub-list for field type_name
+ 115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+ 115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
+ 115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
+ 114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
+ 23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
+ 20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
+ 19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
+ 18, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
+ 17, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState
+ 21, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState
+ 22, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
+ 57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent
+ 24, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
+ 31, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
+ 111, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
+ 112, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
+ 32, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
+ 32, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
+ 33, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
+ 0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel
+ 0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel
+ 43, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State
+ 52, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
+ 54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
+ 2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
+ 3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
+ 115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
+ 113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
+ 57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
+ 114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
+ 115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+ 1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
+ 104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
+ 114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
+ 114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
+ 30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
+ 5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
+ 7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
+ 9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest
+ 11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
+ 11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
+ 13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest
+ 15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
+ 26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
+ 28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
+ 28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
+ 4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
+ 35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
+ 37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
+ 39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
+ 44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
+ 46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
+ 48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
+ 50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
+ 53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
+ 105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
+ 107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
+ 109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
+ 56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
+ 58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
+ 41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
+ 60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
+ 62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
+ 64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
+ 66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
+ 68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
+ 70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
+ 73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
+ 75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
+ 79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
+ 82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
+ 84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
+ 86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
+ 88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
+ 90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
+ 92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
+ 94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
+ 96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
+ 98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
+ 100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
+ 102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
+ 77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
+ 6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
+ 8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
+ 10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse
+ 12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
+ 12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
+ 14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse
+ 16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
+ 27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
+ 29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
+ 29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
+ 34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
+ 36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
+ 38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
+ 40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
+ 45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
+ 47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
+ 49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
+ 51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
+ 55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
+ 106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
+ 108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
+ 110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
+ 57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
+ 59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
+ 42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
+ 61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
+ 63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
+ 65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
+ 67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
+ 69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
+ 71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
+ 74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
+ 76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
+ 80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
+ 83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
+ 85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
+ 87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
+ 89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
+ 91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
+ 93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
+ 95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
+ 97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
+ 99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
+ 101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
+ 103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
+ 78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
+ 85, // [85:131] is the sub-list for method output_type
+ 39, // [39:85] is the sub-list for method input_type
+ 39, // [39:39] is the sub-list for extension type_name
+ 39, // [39:39] is the sub-list for extension extendee
+ 0, // [0:39] is the sub-list for field type_name
}
func init() { file_daemon_proto_init() }
@@ -7345,13 +7967,15 @@ func file_daemon_proto_init() {
(*PortInfo_Port)(nil),
(*PortInfo_Range_)(nil),
}
- file_daemon_proto_msgTypes[47].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[48].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[54].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[49].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[50].OneofWrappers = []any{}
file_daemon_proto_msgTypes[56].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[69].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[78].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[89].OneofWrappers = []any{
+ file_daemon_proto_msgTypes[58].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[71].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[76].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[82].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[86].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[99].OneofWrappers = []any{
(*ExposeServiceEvent_Ready)(nil),
}
type x struct{}
@@ -7360,7 +7984,7 @@ func file_daemon_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 4,
- NumMessages: 100,
+ NumMessages: 110,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/client/proto/daemon.pb.gw.go b/client/proto/daemon.pb.gw.go
new file mode 100644
index 000000000..b64dfeea1
--- /dev/null
+++ b/client/proto/daemon.pb.gw.go
@@ -0,0 +1,2921 @@
+// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT.
+// source: daemon.proto
+
+/*
+Package proto is a reverse proxy.
+
+It translates gRPC into RESTful JSON APIs.
+*/
+package proto
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+
+ "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
+ "github.com/grpc-ecosystem/grpc-gateway/v2/utilities"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/grpclog"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/proto"
+)
+
+// Suppress "imported and not used" errors
+var (
+ _ codes.Code
+ _ io.Reader
+ _ status.Status
+ _ = errors.New
+ _ = runtime.String
+ _ = utilities.NewDoubleArray
+ _ = metadata.Join
+)
+
+func request_DaemonService_Login_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq LoginRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.Login(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_Login_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq LoginRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.Login(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_WaitSSOLogin_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq WaitSSOLoginRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.WaitSSOLogin(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_WaitSSOLogin_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq WaitSSOLoginRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.WaitSSOLogin(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_Up_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq UpRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.Up(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_Up_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq UpRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.Up(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_Status_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq StatusRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.Status(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_Status_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq StatusRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.Status(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_SubscribeStatus_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_SubscribeStatusClient, runtime.ServerMetadata, error) {
+ var (
+ protoReq StatusRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ stream, err := client.SubscribeStatus(ctx, &protoReq)
+ if err != nil {
+ return nil, metadata, err
+ }
+ header, err := stream.Header()
+ if err != nil {
+ return nil, metadata, err
+ }
+ metadata.HeaderMD = header
+ return stream, metadata, nil
+}
+
+func request_DaemonService_Down_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq DownRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.Down(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_Down_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq DownRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.Down(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_GetConfig_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetConfigRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.GetConfig(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_GetConfig_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetConfigRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.GetConfig(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_ListNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq ListNetworksRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.ListNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_ListNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq ListNetworksRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.ListNetworks(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_SelectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SelectNetworksRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.SelectNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_SelectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SelectNetworksRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.SelectNetworks(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_DeselectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SelectNetworksRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.DeselectNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_DeselectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SelectNetworksRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.DeselectNetworks(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_ForwardingRules_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq EmptyRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.ForwardingRules(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_ForwardingRules_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq EmptyRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.ForwardingRules(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_DebugBundle_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq DebugBundleRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.DebugBundle(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_DebugBundle_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq DebugBundleRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.DebugBundle(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_GetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetLogLevelRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.GetLogLevel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_GetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetLogLevelRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.GetLogLevel(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_SetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SetLogLevelRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.SetLogLevel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_SetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SetLogLevelRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.SetLogLevel(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_ListStates_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq ListStatesRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.ListStates(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_ListStates_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq ListStatesRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.ListStates(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_CleanState_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq CleanStateRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.CleanState(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_CleanState_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq CleanStateRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.CleanState(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_DeleteState_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq DeleteStateRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.DeleteState(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_DeleteState_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq DeleteStateRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.DeleteState(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_SetSyncResponsePersistence_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SetSyncResponsePersistenceRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.SetSyncResponsePersistence(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_SetSyncResponsePersistence_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SetSyncResponsePersistenceRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.SetSyncResponsePersistence(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_TracePacket_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq TracePacketRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.TracePacket(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_TracePacket_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq TracePacketRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.TracePacket(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_StartCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_StartCaptureClient, runtime.ServerMetadata, error) {
+ var (
+ protoReq StartCaptureRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ stream, err := client.StartCapture(ctx, &protoReq)
+ if err != nil {
+ return nil, metadata, err
+ }
+ header, err := stream.Header()
+ if err != nil {
+ return nil, metadata, err
+ }
+ metadata.HeaderMD = header
+ return stream, metadata, nil
+}
+
+func request_DaemonService_StartBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq StartBundleCaptureRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.StartBundleCapture(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_StartBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq StartBundleCaptureRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.StartBundleCapture(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_StopBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq StopBundleCaptureRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.StopBundleCapture(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_StopBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq StopBundleCaptureRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.StopBundleCapture(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_SubscribeEvents_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_SubscribeEventsClient, runtime.ServerMetadata, error) {
+ var (
+ protoReq SubscribeRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ stream, err := client.SubscribeEvents(ctx, &protoReq)
+ if err != nil {
+ return nil, metadata, err
+ }
+ header, err := stream.Header()
+ if err != nil {
+ return nil, metadata, err
+ }
+ metadata.HeaderMD = header
+ return stream, metadata, nil
+}
+
+func request_DaemonService_GetEvents_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetEventsRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.GetEvents(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_GetEvents_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetEventsRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.GetEvents(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_RegisterUILog_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq RegisterUILogRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.RegisterUILog(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_RegisterUILog_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq RegisterUILogRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.RegisterUILog(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_SwitchProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SwitchProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.SwitchProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_SwitchProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SwitchProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.SwitchProfile(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_SetConfig_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SetConfigRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.SetConfig(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_SetConfig_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq SetConfigRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.SetConfig(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_AddProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq AddProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.AddProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_AddProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq AddProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.AddProfile(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_RenameProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq RenameProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.RenameProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_RenameProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq RenameProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.RenameProfile(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_RemoveProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq RemoveProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.RemoveProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_RemoveProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq RemoveProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.RemoveProfile(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_ListProfiles_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq ListProfilesRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.ListProfiles(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_ListProfiles_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq ListProfilesRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.ListProfiles(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_GetActiveProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetActiveProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.GetActiveProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_GetActiveProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetActiveProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.GetActiveProfile(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq LogoutRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.Logout(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq LogoutRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.Logout(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_GetFeatures_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetFeaturesRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.GetFeatures(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_GetFeatures_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetFeaturesRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.GetFeatures(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_TriggerUpdate_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq TriggerUpdateRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.TriggerUpdate(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_TriggerUpdate_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq TriggerUpdateRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.TriggerUpdate(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_GetPeerSSHHostKey_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetPeerSSHHostKeyRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.GetPeerSSHHostKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_GetPeerSSHHostKey_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq GetPeerSSHHostKeyRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.GetPeerSSHHostKey(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_RequestJWTAuth_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq RequestJWTAuthRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.RequestJWTAuth(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_RequestJWTAuth_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq RequestJWTAuthRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.RequestJWTAuth(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_WaitJWTToken_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq WaitJWTTokenRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.WaitJWTToken(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_WaitJWTToken_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq WaitJWTTokenRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.WaitJWTToken(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_RequestExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq RequestExtendAuthSessionRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.RequestExtendAuthSession(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_RequestExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq RequestExtendAuthSessionRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.RequestExtendAuthSession(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_WaitExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq WaitExtendAuthSessionRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.WaitExtendAuthSession(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_WaitExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq WaitExtendAuthSessionRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.WaitExtendAuthSession(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_DismissSessionWarning_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq DismissSessionWarningRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.DismissSessionWarning(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_DismissSessionWarning_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq DismissSessionWarningRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.DismissSessionWarning(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_StartCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq StartCPUProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.StartCPUProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_StartCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq StartCPUProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.StartCPUProfile(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_StopCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq StopCPUProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.StopCPUProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_StopCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq StopCPUProfileRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.StopCPUProfile(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_GetInstallerResult_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq InstallerResultRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.GetInstallerResult(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_GetInstallerResult_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq InstallerResultRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.GetInstallerResult(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+func request_DaemonService_ExposeService_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_ExposeServiceClient, runtime.ServerMetadata, error) {
+ var (
+ protoReq ExposeServiceRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ stream, err := client.ExposeService(ctx, &protoReq)
+ if err != nil {
+ return nil, metadata, err
+ }
+ header, err := stream.Header()
+ if err != nil {
+ return nil, metadata, err
+ }
+ metadata.HeaderMD = header
+ return stream, metadata, nil
+}
+
+func request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq WailsUIReadyRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := client.WailsUIReady(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+ return msg, metadata, err
+}
+
+func local_request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+ var (
+ protoReq WailsUIReadyRequest
+ metadata runtime.ServerMetadata
+ )
+ if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
+ return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ msg, err := server.WailsUIReady(ctx, &protoReq)
+ return msg, metadata, err
+}
+
+// RegisterDaemonServiceHandlerServer registers the http handlers for service DaemonService to "mux".
+// UnaryRPC :call DaemonServiceServer directly.
+// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
+// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterDaemonServiceHandlerFromEndpoint instead.
+// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call.
+func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server DaemonServiceServer) error {
+ mux.Handle(http.MethodPost, pattern_DaemonService_Login_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Login", runtime.WithHTTPPathPattern("/daemon.DaemonService/Login"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_Login_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_Login_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_WaitSSOLogin_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitSSOLogin", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitSSOLogin"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_WaitSSOLogin_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_WaitSSOLogin_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_Up_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Up", runtime.WithHTTPPathPattern("/daemon.DaemonService/Up"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_Up_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_Up_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_Status_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Status", runtime.WithHTTPPathPattern("/daemon.DaemonService/Status"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_Status_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_Status_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+
+ mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport")
+ _, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_Down_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Down", runtime.WithHTTPPathPattern("/daemon.DaemonService/Down"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_Down_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_Down_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetConfig"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_GetConfig_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_ListNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListNetworks"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_ListNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_ListNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SelectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SelectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/SelectNetworks"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_SelectNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SelectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_DeselectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DeselectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeselectNetworks"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_DeselectNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_DeselectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_ForwardingRules_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ForwardingRules", runtime.WithHTTPPathPattern("/daemon.DaemonService/ForwardingRules"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_ForwardingRules_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_ForwardingRules_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_DebugBundle_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DebugBundle", runtime.WithHTTPPathPattern("/daemon.DaemonService/DebugBundle"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_DebugBundle_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_DebugBundle_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetLogLevel"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_GetLogLevel_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetLogLevel"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_SetLogLevel_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_ListStates_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListStates", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListStates"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_ListStates_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_ListStates_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_CleanState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/CleanState", runtime.WithHTTPPathPattern("/daemon.DaemonService/CleanState"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_CleanState_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_CleanState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_DeleteState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DeleteState", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeleteState"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_DeleteState_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_DeleteState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SetSyncResponsePersistence_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetSyncResponsePersistence", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetSyncResponsePersistence"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_SetSyncResponsePersistence_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SetSyncResponsePersistence_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_TracePacket_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/TracePacket", runtime.WithHTTPPathPattern("/daemon.DaemonService/TracePacket"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_TracePacket_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_TracePacket_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+
+ mux.Handle(http.MethodPost, pattern_DaemonService_StartCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport")
+ _, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_StartBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StartBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartBundleCapture"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_StartBundleCapture_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_StartBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_StopBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StopBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopBundleCapture"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_StopBundleCapture_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_StopBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+
+ mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport")
+ _, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetEvents"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_GetEvents_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetEvents_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_RegisterUILog_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RegisterUILog", runtime.WithHTTPPathPattern("/daemon.DaemonService/RegisterUILog"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_RegisterUILog_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_RegisterUILog_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SwitchProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SwitchProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/SwitchProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_SwitchProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SwitchProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_SetConfig_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_AddProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/AddProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_AddProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_AddProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_RenameProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RenameProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RenameProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_RenameProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_RenameProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_RemoveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RemoveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RemoveProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_RemoveProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_RemoveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_ListProfiles_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListProfiles", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListProfiles"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_ListProfiles_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_ListProfiles_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetActiveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetActiveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetActiveProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_GetActiveProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Logout", runtime.WithHTTPPathPattern("/daemon.DaemonService/Logout"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_Logout_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_Logout_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetFeatures_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetFeatures", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetFeatures"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_GetFeatures_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetFeatures_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_TriggerUpdate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/TriggerUpdate", runtime.WithHTTPPathPattern("/daemon.DaemonService/TriggerUpdate"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_TriggerUpdate_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_TriggerUpdate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetPeerSSHHostKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetPeerSSHHostKey", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetPeerSSHHostKey"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_GetPeerSSHHostKey_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetPeerSSHHostKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_RequestJWTAuth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RequestJWTAuth", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestJWTAuth"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_RequestJWTAuth_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_RequestJWTAuth_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_WaitJWTToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitJWTToken", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitJWTToken"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_WaitJWTToken_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_WaitJWTToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_RequestExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RequestExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestExtendAuthSession"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_RequestExtendAuthSession_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_RequestExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_WaitExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitExtendAuthSession"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_WaitExtendAuthSession_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_WaitExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_DismissSessionWarning_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DismissSessionWarning", runtime.WithHTTPPathPattern("/daemon.DaemonService/DismissSessionWarning"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_DismissSessionWarning_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_DismissSessionWarning_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_StartCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StartCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCPUProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_StartCPUProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_StartCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_StopCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StopCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopCPUProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_StopCPUProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_StopCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetInstallerResult_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetInstallerResult", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetInstallerResult"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_GetInstallerResult_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetInstallerResult_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+
+ mux.Handle(http.MethodPost, pattern_DaemonService_ExposeService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport")
+ _, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_WailsUIReady_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ var stream runtime.ServerTransportStream
+ ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WailsUIReady", runtime.WithHTTPPathPattern("/daemon.DaemonService/WailsUIReady"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := local_request_DaemonService_WailsUIReady_0(annotatedContext, inboundMarshaler, server, req, pathParams)
+ md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+
+ return nil
+}
+
+// RegisterDaemonServiceHandlerFromEndpoint is same as RegisterDaemonServiceHandler but
+// automatically dials to "endpoint" and closes the connection when "ctx" gets done.
+func RegisterDaemonServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) {
+ conn, err := grpc.NewClient(endpoint, opts...)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ if cerr := conn.Close(); cerr != nil {
+ grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr)
+ }
+ return
+ }
+ go func() {
+ <-ctx.Done()
+ if cerr := conn.Close(); cerr != nil {
+ grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr)
+ }
+ }()
+ }()
+ return RegisterDaemonServiceHandler(ctx, mux, conn)
+}
+
+// RegisterDaemonServiceHandler registers the http handlers for service DaemonService to "mux".
+// The handlers forward requests to the grpc endpoint over "conn".
+func RegisterDaemonServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error {
+ return RegisterDaemonServiceHandlerClient(ctx, mux, NewDaemonServiceClient(conn))
+}
+
+// RegisterDaemonServiceHandlerClient registers the http handlers for service DaemonService
+// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "DaemonServiceClient".
+// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "DaemonServiceClient"
+// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in
+// "DaemonServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares.
+func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client DaemonServiceClient) error {
+ mux.Handle(http.MethodPost, pattern_DaemonService_Login_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Login", runtime.WithHTTPPathPattern("/daemon.DaemonService/Login"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_Login_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_Login_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_WaitSSOLogin_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitSSOLogin", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitSSOLogin"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_WaitSSOLogin_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_WaitSSOLogin_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_Up_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Up", runtime.WithHTTPPathPattern("/daemon.DaemonService/Up"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_Up_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_Up_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_Status_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Status", runtime.WithHTTPPathPattern("/daemon.DaemonService/Status"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_Status_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_Status_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SubscribeStatus", runtime.WithHTTPPathPattern("/daemon.DaemonService/SubscribeStatus"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_SubscribeStatus_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SubscribeStatus_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_Down_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Down", runtime.WithHTTPPathPattern("/daemon.DaemonService/Down"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_Down_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_Down_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetConfig"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_GetConfig_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_ListNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListNetworks"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_ListNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_ListNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SelectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SelectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/SelectNetworks"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_SelectNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SelectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_DeselectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DeselectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeselectNetworks"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_DeselectNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_DeselectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_ForwardingRules_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ForwardingRules", runtime.WithHTTPPathPattern("/daemon.DaemonService/ForwardingRules"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_ForwardingRules_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_ForwardingRules_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_DebugBundle_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DebugBundle", runtime.WithHTTPPathPattern("/daemon.DaemonService/DebugBundle"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_DebugBundle_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_DebugBundle_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetLogLevel"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_GetLogLevel_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetLogLevel"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_SetLogLevel_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_ListStates_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListStates", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListStates"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_ListStates_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_ListStates_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_CleanState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/CleanState", runtime.WithHTTPPathPattern("/daemon.DaemonService/CleanState"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_CleanState_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_CleanState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_DeleteState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DeleteState", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeleteState"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_DeleteState_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_DeleteState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SetSyncResponsePersistence_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetSyncResponsePersistence", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetSyncResponsePersistence"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_SetSyncResponsePersistence_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SetSyncResponsePersistence_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_TracePacket_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/TracePacket", runtime.WithHTTPPathPattern("/daemon.DaemonService/TracePacket"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_TracePacket_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_TracePacket_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_StartCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCapture"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_StartCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_StartCapture_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_StartBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartBundleCapture"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_StartBundleCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_StartBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_StopBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StopBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopBundleCapture"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_StopBundleCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_StopBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SubscribeEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/SubscribeEvents"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_SubscribeEvents_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SubscribeEvents_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetEvents"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_GetEvents_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetEvents_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_RegisterUILog_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RegisterUILog", runtime.WithHTTPPathPattern("/daemon.DaemonService/RegisterUILog"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_RegisterUILog_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_RegisterUILog_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SwitchProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SwitchProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/SwitchProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_SwitchProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SwitchProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_SetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_SetConfig_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_SetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_AddProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/AddProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_AddProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_AddProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_RenameProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RenameProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RenameProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_RenameProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_RenameProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_RemoveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RemoveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RemoveProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_RemoveProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_RemoveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_ListProfiles_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListProfiles", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListProfiles"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_ListProfiles_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_ListProfiles_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetActiveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetActiveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetActiveProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_GetActiveProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Logout", runtime.WithHTTPPathPattern("/daemon.DaemonService/Logout"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_Logout_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_Logout_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetFeatures_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetFeatures", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetFeatures"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_GetFeatures_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetFeatures_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_TriggerUpdate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/TriggerUpdate", runtime.WithHTTPPathPattern("/daemon.DaemonService/TriggerUpdate"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_TriggerUpdate_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_TriggerUpdate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetPeerSSHHostKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetPeerSSHHostKey", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetPeerSSHHostKey"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_GetPeerSSHHostKey_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetPeerSSHHostKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_RequestJWTAuth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RequestJWTAuth", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestJWTAuth"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_RequestJWTAuth_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_RequestJWTAuth_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_WaitJWTToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitJWTToken", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitJWTToken"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_WaitJWTToken_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_WaitJWTToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_RequestExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RequestExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestExtendAuthSession"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_RequestExtendAuthSession_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_RequestExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_WaitExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitExtendAuthSession"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_WaitExtendAuthSession_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_WaitExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_DismissSessionWarning_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DismissSessionWarning", runtime.WithHTTPPathPattern("/daemon.DaemonService/DismissSessionWarning"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_DismissSessionWarning_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_DismissSessionWarning_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_StartCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCPUProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_StartCPUProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_StartCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_StopCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StopCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopCPUProfile"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_StopCPUProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_StopCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_GetInstallerResult_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetInstallerResult", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetInstallerResult"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_GetInstallerResult_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_GetInstallerResult_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_ExposeService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ExposeService", runtime.WithHTTPPathPattern("/daemon.DaemonService/ExposeService"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_ExposeService_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_ExposeService_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...)
+ })
+ mux.Handle(http.MethodPost, pattern_DaemonService_WailsUIReady_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+ ctx, cancel := context.WithCancel(req.Context())
+ defer cancel()
+ inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+ annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WailsUIReady", runtime.WithHTTPPathPattern("/daemon.DaemonService/WailsUIReady"))
+ if err != nil {
+ runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ resp, md, err := request_DaemonService_WailsUIReady_0(annotatedContext, inboundMarshaler, client, req, pathParams)
+ annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
+ if err != nil {
+ runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
+ return
+ }
+ forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+ })
+ return nil
+}
+
+var (
+ pattern_DaemonService_Login_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Login"}, ""))
+ pattern_DaemonService_WaitSSOLogin_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitSSOLogin"}, ""))
+ pattern_DaemonService_Up_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Up"}, ""))
+ pattern_DaemonService_Status_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Status"}, ""))
+ pattern_DaemonService_SubscribeStatus_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SubscribeStatus"}, ""))
+ pattern_DaemonService_Down_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Down"}, ""))
+ pattern_DaemonService_GetConfig_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetConfig"}, ""))
+ pattern_DaemonService_ListNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListNetworks"}, ""))
+ pattern_DaemonService_SelectNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SelectNetworks"}, ""))
+ pattern_DaemonService_DeselectNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DeselectNetworks"}, ""))
+ pattern_DaemonService_ForwardingRules_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ForwardingRules"}, ""))
+ pattern_DaemonService_DebugBundle_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DebugBundle"}, ""))
+ pattern_DaemonService_GetLogLevel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetLogLevel"}, ""))
+ pattern_DaemonService_SetLogLevel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetLogLevel"}, ""))
+ pattern_DaemonService_ListStates_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListStates"}, ""))
+ pattern_DaemonService_CleanState_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "CleanState"}, ""))
+ pattern_DaemonService_DeleteState_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DeleteState"}, ""))
+ pattern_DaemonService_SetSyncResponsePersistence_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetSyncResponsePersistence"}, ""))
+ pattern_DaemonService_TracePacket_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TracePacket"}, ""))
+ pattern_DaemonService_StartCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartCapture"}, ""))
+ pattern_DaemonService_StartBundleCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartBundleCapture"}, ""))
+ pattern_DaemonService_StopBundleCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StopBundleCapture"}, ""))
+ pattern_DaemonService_SubscribeEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SubscribeEvents"}, ""))
+ pattern_DaemonService_GetEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetEvents"}, ""))
+ pattern_DaemonService_RegisterUILog_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RegisterUILog"}, ""))
+ pattern_DaemonService_SwitchProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SwitchProfile"}, ""))
+ pattern_DaemonService_SetConfig_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetConfig"}, ""))
+ pattern_DaemonService_AddProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "AddProfile"}, ""))
+ pattern_DaemonService_RenameProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RenameProfile"}, ""))
+ pattern_DaemonService_RemoveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RemoveProfile"}, ""))
+ pattern_DaemonService_ListProfiles_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListProfiles"}, ""))
+ pattern_DaemonService_GetActiveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetActiveProfile"}, ""))
+ pattern_DaemonService_Logout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Logout"}, ""))
+ pattern_DaemonService_GetFeatures_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetFeatures"}, ""))
+ pattern_DaemonService_TriggerUpdate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TriggerUpdate"}, ""))
+ pattern_DaemonService_GetPeerSSHHostKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetPeerSSHHostKey"}, ""))
+ pattern_DaemonService_RequestJWTAuth_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RequestJWTAuth"}, ""))
+ pattern_DaemonService_WaitJWTToken_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitJWTToken"}, ""))
+ pattern_DaemonService_RequestExtendAuthSession_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RequestExtendAuthSession"}, ""))
+ pattern_DaemonService_WaitExtendAuthSession_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitExtendAuthSession"}, ""))
+ pattern_DaemonService_DismissSessionWarning_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DismissSessionWarning"}, ""))
+ pattern_DaemonService_StartCPUProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartCPUProfile"}, ""))
+ pattern_DaemonService_StopCPUProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StopCPUProfile"}, ""))
+ pattern_DaemonService_GetInstallerResult_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetInstallerResult"}, ""))
+ pattern_DaemonService_ExposeService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ExposeService"}, ""))
+ pattern_DaemonService_WailsUIReady_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WailsUIReady"}, ""))
+)
+
+var (
+ forward_DaemonService_Login_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_WaitSSOLogin_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_Up_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_Status_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_SubscribeStatus_0 = runtime.ForwardResponseStream
+ forward_DaemonService_Down_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_GetConfig_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_ListNetworks_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_SelectNetworks_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_DeselectNetworks_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_ForwardingRules_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_DebugBundle_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_GetLogLevel_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_SetLogLevel_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_ListStates_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_CleanState_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_DeleteState_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_SetSyncResponsePersistence_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_TracePacket_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_StartCapture_0 = runtime.ForwardResponseStream
+ forward_DaemonService_StartBundleCapture_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_StopBundleCapture_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_SubscribeEvents_0 = runtime.ForwardResponseStream
+ forward_DaemonService_GetEvents_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_RegisterUILog_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_SwitchProfile_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_SetConfig_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_AddProfile_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_RenameProfile_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_RemoveProfile_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_ListProfiles_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_GetActiveProfile_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_Logout_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_GetFeatures_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_TriggerUpdate_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_GetPeerSSHHostKey_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_RequestJWTAuth_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_WaitJWTToken_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_RequestExtendAuthSession_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_WaitExtendAuthSession_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_DismissSessionWarning_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_StartCPUProfile_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_StopCPUProfile_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream
+ forward_DaemonService_WailsUIReady_0 = runtime.ForwardResponseMessage
+)
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index c1e3fe513..a3e3f4500 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -24,6 +24,12 @@ service DaemonService {
// Status of the service.
rpc Status(StatusRequest) returns (StatusResponse) {}
+ // SubscribeStatus pushes a fresh StatusResponse on connection state
+ // changes (Connected / Disconnected / Connecting / address change /
+ // peers list change). The first message on the stream is the current
+ // snapshot, so a freshly-subscribed UI doesn't need to also call Status.
+ rpc SubscribeStatus(StatusRequest) returns (stream StatusResponse) {}
+
// Down stops engine work in the daemon.
rpc Down(DownRequest) returns (DownResponse) {}
@@ -79,6 +85,11 @@ service DaemonService {
rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {}
+ // RegisterUILog records the desktop UI's absolute log path so the daemon's
+ // debug bundle can collect it (the daemon runs as root and can't resolve the
+ // user's config dir).
+ rpc RegisterUILog(RegisterUILogRequest) returns (RegisterUILogResponse) {}
+
rpc SwitchProfile(SwitchProfileRequest) returns (SwitchProfileResponse) {}
rpc SetConfig(SetConfigRequest) returns (SetConfigResponse) {}
@@ -111,6 +122,25 @@ service DaemonService {
// WaitJWTToken waits for JWT authentication completion
rpc WaitJWTToken(WaitJWTTokenRequest) returns (WaitJWTTokenResponse) {}
+ // RequestExtendAuthSession initiates an SSO session-extension flow.
+ // The daemon prepares a PKCE/device-code request against the IdP and
+ // returns the verification URI; the UI is expected to open it. The flow
+ // state is kept in the daemon until WaitExtendAuthSession completes it.
+ rpc RequestExtendAuthSession(RequestExtendAuthSessionRequest) returns (RequestExtendAuthSessionResponse) {}
+
+ // WaitExtendAuthSession blocks until the user finishes the SSO step
+ // started by RequestExtendAuthSession, then forwards the resulting JWT
+ // to the management server's ExtendAuthSession RPC. Returns the new
+ // session expiry deadline. The tunnel stays up the entire time.
+ rpc WaitExtendAuthSession(WaitExtendAuthSessionRequest) returns (WaitExtendAuthSessionResponse) {}
+
+ // DismissSessionWarning records that the user clicked "Dismiss" on the
+ // T-WarningLead interactive notification, suppressing the auto-opened
+ // SessionAboutToExpire dialog that would otherwise fire at
+ // T-FinalWarningLead for the current deadline. Idempotent and best-effort:
+ // a missed call only means the fallback dialog will still appear.
+ rpc DismissSessionWarning(DismissSessionWarningRequest) returns (DismissSessionWarningResponse) {}
+
// StartCPUProfile starts CPU profiling in the daemon
rpc StartCPUProfile(StartCPUProfileRequest) returns (StartCPUProfileResponse) {}
@@ -121,6 +151,11 @@ service DaemonService {
// ExposeService exposes a local port via the NetBird reverse proxy
rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {}
+
+ // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI
+ // only cares whether the daemon implements it: an Unimplemented response
+ // means the daemon predates this UI and is too old to drive it.
+ rpc WailsUIReady(WailsUIReadyRequest) returns (WailsUIReadyResponse) {}
}
@@ -229,6 +264,12 @@ message UpRequest {
optional string profileName = 1;
optional string username = 2;
reserved 3;
+ // async instructs the daemon to start the connection attempt and return
+ // immediately without waiting for the engine to become ready. Status updates
+ // are delivered via the SubscribeStatus stream. When false (the default) the
+ // RPC blocks until the engine is running or gives up, which is the behaviour
+ // needed by the CLI.
+ bool async = 4;
}
message UpResponse {}
@@ -246,6 +287,10 @@ message StatusResponse{
FullStatus fullStatus = 2;
// NetBird daemon version
string daemonVersion = 3;
+ // Absolute UTC instant at which the peer's SSO session expires.
+ // Unset when the peer is not SSO-registered or login expiration is disabled.
+ // The UI derives "warning active" from this value and its own clock.
+ google.protobuf.Timestamp sessionExpiresAt = 4;
}
message DownRequest {}
@@ -421,6 +466,12 @@ message FullStatus {
bool lazyConnectionEnabled = 9;
SSHServerState sshServerState = 10;
+
+ // networksRevision bumps whenever the set of routed networks (route and
+ // exit-node candidates) or their selected state changes. The UI fingerprints
+ // on it to know when to re-fetch ListNetworks via the push stream, instead
+ // of polling on every status snapshot.
+ uint64 networksRevision = 11;
}
// Networks
@@ -485,6 +536,15 @@ message DebugBundleRequest {
string uploadURL = 4;
uint32 logFileCount = 5;
string cliVersion = 6;
+ // uploadInsecure allows uploading to an http endpoint or one with an
+ // untrusted TLS certificate. Restricted to privileged callers; for
+ // self-hosted upload servers.
+ bool uploadInsecure = 7;
+ // anonymizeLevel selects how much the anonymizer redacts: "default"
+ // (or empty) keeps internal IP ranges, "strict" also anonymizes them.
+ // Unknown values are treated as "strict". Only meaningful with anonymize;
+ // "strict" implies it.
+ string anonymizeLevel = 8;
}
message DebugBundleResponse {
@@ -518,6 +578,13 @@ message SetLogLevelRequest {
message SetLogLevelResponse {
}
+message RegisterUILogRequest {
+ string path = 1;
+}
+
+message RegisterUILogResponse {
+}
+
// State represents a daemon state entry
message State {
string name = 1;
@@ -771,12 +838,22 @@ message LogoutRequest {
message LogoutResponse {}
+message WailsUIReadyRequest {}
+
+message WailsUIReadyResponse {}
+
message GetFeaturesRequest{}
message GetFeaturesResponse{
bool disable_profiles = 1;
bool disable_update_settings = 2;
bool disable_networks = 3;
+ // disableAdvancedView gates the upcoming UI revision's advanced
+ // section. Tristate: unset = no MDM directive, the UI applies its
+ // own default; true = MDM enforces disable; false = MDM enforces
+ // enable. Sourced exclusively from the MDM policy — no CLI /
+ // config flag backs this value.
+ optional bool disable_advanced_view = 4;
}
// MDMManagedFieldsViolation is attached as a gRPC error detail on a
@@ -817,6 +894,10 @@ message GetPeerSSHHostKeyResponse {
message RequestJWTAuthRequest {
// hint for OIDC login_hint parameter (typically email address)
optional string hint = 1;
+ // hasGraphicalSession tells the daemon that the caller has a graphical session,
+ // which decides whether PKCE or the device code flow is preferred. The daemon
+ // cannot detect this itself: it does not inherit the session environment.
+ bool hasGraphicalSession = 2;
}
// RequestJWTAuthResponse contains authentication flow information
@@ -855,6 +936,59 @@ message WaitJWTTokenResponse {
int64 expiresIn = 3;
}
+// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow.
+message RequestExtendAuthSessionRequest {
+ // Optional OIDC login_hint (typically the user's email) to pre-fill the
+ // IdP login form.
+ optional string hint = 1;
+ // hasGraphicalSession tells the daemon that the caller has a graphical session,
+ // which decides whether PKCE or the device code flow is preferred. The daemon
+ // cannot detect this itself: it does not inherit the session environment.
+ bool hasGraphicalSession = 2;
+}
+
+// RequestExtendAuthSessionResponse carries the verification URI the UI
+// should open in a browser. The daemon retains the flow state and resolves
+// it via WaitExtendAuthSession.
+message RequestExtendAuthSessionResponse {
+ // verification URI for the user to open in the browser
+ string verificationURI = 1;
+ // complete verification URI (with embedded user code)
+ string verificationURIComplete = 2;
+ // user code to enter on verification URI (for device-code flows)
+ string userCode = 3;
+ // device code for matching the WaitExtendAuthSession call to this flow
+ string deviceCode = 4;
+ // expiration time in seconds for the device code / PKCE flow
+ int64 expiresIn = 5;
+}
+
+// WaitExtendAuthSessionRequest is sent by the UI after it opens the
+// verification URI. The daemon blocks on this call until the user
+// completes (or aborts) the SSO step.
+message WaitExtendAuthSessionRequest {
+ // device code returned by RequestExtendAuthSession
+ string deviceCode = 1;
+ // user code for verification
+ string userCode = 2;
+}
+
+// WaitExtendAuthSessionResponse carries the refreshed deadline returned
+// by the management server. Unset when the management server reports the
+// peer is not eligible for session extension.
+message WaitExtendAuthSessionResponse {
+ google.protobuf.Timestamp sessionExpiresAt = 1;
+}
+
+// DismissSessionWarningRequest is sent by the UI when the user clicks
+// "Dismiss" on the T-WarningLead notification.
+message DismissSessionWarningRequest {}
+
+// DismissSessionWarningResponse acknowledges the dismissal. Carries no
+// payload — the daemon's only obligation is to silence the upcoming
+// T-FinalWarningLead fallback for the current deadline.
+message DismissSessionWarningResponse {}
+
// StartCPUProfileRequest for starting CPU profiling
message StartCPUProfileRequest {}
diff --git a/client/proto/daemon_gateway_test.go b/client/proto/daemon_gateway_test.go
new file mode 100644
index 000000000..20031e9d9
--- /dev/null
+++ b/client/proto/daemon_gateway_test.go
@@ -0,0 +1,80 @@
+package proto
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ gatewayruntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/test/bufconn"
+)
+
+func TestGatewayServerRoutesCoverDaemonRPCs(t *testing.T) {
+ mux := gatewayruntime.NewServeMux()
+ if err := RegisterDaemonServiceHandlerServer(context.Background(), mux, UnimplementedDaemonServiceServer{}); err != nil {
+ t.Fatalf("register daemon gateway server handlers: %v", err)
+ }
+
+ assertAllDaemonGatewayRoutesRegistered(t, mux)
+}
+
+func TestGatewayClientRoutesCoverDaemonRPCs(t *testing.T) {
+ listener := bufconn.Listen(1024 * 1024)
+ server := grpc.NewServer()
+ RegisterDaemonServiceServer(server, UnimplementedDaemonServiceServer{})
+ go func() {
+ if err := server.Serve(listener); err != nil && err != grpc.ErrServerStopped {
+ t.Errorf("serve bufconn gRPC server: %v", err)
+ }
+ }()
+ t.Cleanup(func() {
+ server.Stop()
+ _ = listener.Close()
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ mux := gatewayruntime.NewServeMux()
+ opts := []grpc.DialOption{
+ grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
+ return listener.Dial()
+ }),
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+ }
+ if err := RegisterDaemonServiceHandlerFromEndpoint(ctx, mux, "passthrough:///bufnet", opts); err != nil {
+ t.Fatalf("register daemon gateway client handlers: %v", err)
+ }
+
+ assertAllDaemonGatewayRoutesRegistered(t, mux)
+}
+
+func assertAllDaemonGatewayRoutesRegistered(t *testing.T, mux http.Handler) {
+ t.Helper()
+ for _, method := range DaemonService_ServiceDesc.Methods {
+ assertGatewayRouteRegistered(t, mux, method.MethodName)
+ }
+ for _, stream := range DaemonService_ServiceDesc.Streams {
+ assertGatewayRouteRegistered(t, mux, stream.StreamName)
+ }
+}
+
+func assertGatewayRouteRegistered(t *testing.T, mux http.Handler, methodName string) {
+ t.Helper()
+
+ path := "/daemon.DaemonService/" + methodName
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("{}"))
+ req.Header.Set("Content-Type", "application/json")
+ res := httptest.NewRecorder()
+
+ mux.ServeHTTP(res, req)
+
+ if res.Code == http.StatusNotFound {
+ t.Fatalf("gateway route for %s is not registered", methodName)
+ }
+}
diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go
index 5f585aafc..2d01d474d 100644
--- a/client/proto/daemon_grpc.pb.go
+++ b/client/proto/daemon_grpc.pb.go
@@ -23,6 +23,7 @@ const (
DaemonService_WaitSSOLogin_FullMethodName = "/daemon.DaemonService/WaitSSOLogin"
DaemonService_Up_FullMethodName = "/daemon.DaemonService/Up"
DaemonService_Status_FullMethodName = "/daemon.DaemonService/Status"
+ DaemonService_SubscribeStatus_FullMethodName = "/daemon.DaemonService/SubscribeStatus"
DaemonService_Down_FullMethodName = "/daemon.DaemonService/Down"
DaemonService_GetConfig_FullMethodName = "/daemon.DaemonService/GetConfig"
DaemonService_ListNetworks_FullMethodName = "/daemon.DaemonService/ListNetworks"
@@ -42,6 +43,7 @@ const (
DaemonService_StopBundleCapture_FullMethodName = "/daemon.DaemonService/StopBundleCapture"
DaemonService_SubscribeEvents_FullMethodName = "/daemon.DaemonService/SubscribeEvents"
DaemonService_GetEvents_FullMethodName = "/daemon.DaemonService/GetEvents"
+ DaemonService_RegisterUILog_FullMethodName = "/daemon.DaemonService/RegisterUILog"
DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile"
DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig"
DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile"
@@ -55,10 +57,14 @@ const (
DaemonService_GetPeerSSHHostKey_FullMethodName = "/daemon.DaemonService/GetPeerSSHHostKey"
DaemonService_RequestJWTAuth_FullMethodName = "/daemon.DaemonService/RequestJWTAuth"
DaemonService_WaitJWTToken_FullMethodName = "/daemon.DaemonService/WaitJWTToken"
+ DaemonService_RequestExtendAuthSession_FullMethodName = "/daemon.DaemonService/RequestExtendAuthSession"
+ DaemonService_WaitExtendAuthSession_FullMethodName = "/daemon.DaemonService/WaitExtendAuthSession"
+ DaemonService_DismissSessionWarning_FullMethodName = "/daemon.DaemonService/DismissSessionWarning"
DaemonService_StartCPUProfile_FullMethodName = "/daemon.DaemonService/StartCPUProfile"
DaemonService_StopCPUProfile_FullMethodName = "/daemon.DaemonService/StopCPUProfile"
DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult"
DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService"
+ DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady"
)
// DaemonServiceClient is the client API for DaemonService service.
@@ -74,6 +80,11 @@ type DaemonServiceClient interface {
Up(ctx context.Context, in *UpRequest, opts ...grpc.CallOption) (*UpResponse, error)
// Status of the service.
Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error)
+ // SubscribeStatus pushes a fresh StatusResponse on connection state
+ // changes (Connected / Disconnected / Connecting / address change /
+ // peers list change). The first message on the stream is the current
+ // snapshot, so a freshly-subscribed UI doesn't need to also call Status.
+ SubscribeStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StatusResponse], error)
// Down stops engine work in the daemon.
Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error)
// GetConfig of the daemon.
@@ -110,6 +121,10 @@ type DaemonServiceClient interface {
StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error)
SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error)
GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error)
+ // RegisterUILog records the desktop UI's absolute log path so the daemon's
+ // debug bundle can collect it (the daemon runs as root and can't resolve the
+ // user's config dir).
+ RegisterUILog(ctx context.Context, in *RegisterUILogRequest, opts ...grpc.CallOption) (*RegisterUILogResponse, error)
SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error)
SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error)
AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error)
@@ -129,6 +144,22 @@ type DaemonServiceClient interface {
RequestJWTAuth(ctx context.Context, in *RequestJWTAuthRequest, opts ...grpc.CallOption) (*RequestJWTAuthResponse, error)
// WaitJWTToken waits for JWT authentication completion
WaitJWTToken(ctx context.Context, in *WaitJWTTokenRequest, opts ...grpc.CallOption) (*WaitJWTTokenResponse, error)
+ // RequestExtendAuthSession initiates an SSO session-extension flow.
+ // The daemon prepares a PKCE/device-code request against the IdP and
+ // returns the verification URI; the UI is expected to open it. The flow
+ // state is kept in the daemon until WaitExtendAuthSession completes it.
+ RequestExtendAuthSession(ctx context.Context, in *RequestExtendAuthSessionRequest, opts ...grpc.CallOption) (*RequestExtendAuthSessionResponse, error)
+ // WaitExtendAuthSession blocks until the user finishes the SSO step
+ // started by RequestExtendAuthSession, then forwards the resulting JWT
+ // to the management server's ExtendAuthSession RPC. Returns the new
+ // session expiry deadline. The tunnel stays up the entire time.
+ WaitExtendAuthSession(ctx context.Context, in *WaitExtendAuthSessionRequest, opts ...grpc.CallOption) (*WaitExtendAuthSessionResponse, error)
+ // DismissSessionWarning records that the user clicked "Dismiss" on the
+ // T-WarningLead interactive notification, suppressing the auto-opened
+ // SessionAboutToExpire dialog that would otherwise fire at
+ // T-FinalWarningLead for the current deadline. Idempotent and best-effort:
+ // a missed call only means the fallback dialog will still appear.
+ DismissSessionWarning(ctx context.Context, in *DismissSessionWarningRequest, opts ...grpc.CallOption) (*DismissSessionWarningResponse, error)
// StartCPUProfile starts CPU profiling in the daemon
StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error)
// StopCPUProfile stops CPU profiling in the daemon
@@ -136,6 +167,10 @@ type DaemonServiceClient interface {
GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error)
// ExposeService exposes a local port via the NetBird reverse proxy
ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error)
+ // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI
+ // only cares whether the daemon implements it: an Unimplemented response
+ // means the daemon predates this UI and is too old to drive it.
+ WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error)
}
type daemonServiceClient struct {
@@ -186,6 +221,25 @@ func (c *daemonServiceClient) Status(ctx context.Context, in *StatusRequest, opt
return out, nil
}
+func (c *daemonServiceClient) SubscribeStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StatusResponse], error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_SubscribeStatus_FullMethodName, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &grpc.GenericClientStream[StatusRequest, StatusResponse]{ClientStream: stream}
+ if err := x.ClientStream.SendMsg(in); err != nil {
+ return nil, err
+ }
+ if err := x.ClientStream.CloseSend(); err != nil {
+ return nil, err
+ }
+ return x, nil
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type DaemonService_SubscribeStatusClient = grpc.ServerStreamingClient[StatusResponse]
+
func (c *daemonServiceClient) Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DownResponse)
@@ -328,7 +382,7 @@ func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRe
func (c *daemonServiceClient) StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CapturePacket], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_StartCapture_FullMethodName, cOpts...)
+ stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_StartCapture_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
@@ -367,7 +421,7 @@ func (c *daemonServiceClient) StopBundleCapture(ctx context.Context, in *StopBun
func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_SubscribeEvents_FullMethodName, cOpts...)
+ stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], DaemonService_SubscribeEvents_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
@@ -394,6 +448,16 @@ func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsReques
return out, nil
}
+func (c *daemonServiceClient) RegisterUILog(ctx context.Context, in *RegisterUILogRequest, opts ...grpc.CallOption) (*RegisterUILogResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(RegisterUILogResponse)
+ err := c.cc.Invoke(ctx, DaemonService_RegisterUILog_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
func (c *daemonServiceClient) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SwitchProfileResponse)
@@ -524,6 +588,36 @@ func (c *daemonServiceClient) WaitJWTToken(ctx context.Context, in *WaitJWTToken
return out, nil
}
+func (c *daemonServiceClient) RequestExtendAuthSession(ctx context.Context, in *RequestExtendAuthSessionRequest, opts ...grpc.CallOption) (*RequestExtendAuthSessionResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(RequestExtendAuthSessionResponse)
+ err := c.cc.Invoke(ctx, DaemonService_RequestExtendAuthSession_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *daemonServiceClient) WaitExtendAuthSession(ctx context.Context, in *WaitExtendAuthSessionRequest, opts ...grpc.CallOption) (*WaitExtendAuthSessionResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(WaitExtendAuthSessionResponse)
+ err := c.cc.Invoke(ctx, DaemonService_WaitExtendAuthSession_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *daemonServiceClient) DismissSessionWarning(ctx context.Context, in *DismissSessionWarningRequest, opts ...grpc.CallOption) (*DismissSessionWarningResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(DismissSessionWarningResponse)
+ err := c.cc.Invoke(ctx, DaemonService_DismissSessionWarning_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
func (c *daemonServiceClient) StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StartCPUProfileResponse)
@@ -556,7 +650,7 @@ func (c *daemonServiceClient) GetInstallerResult(ctx context.Context, in *Instal
func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], DaemonService_ExposeService_FullMethodName, cOpts...)
+ stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[3], DaemonService_ExposeService_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
@@ -573,6 +667,16 @@ func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServi
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type DaemonService_ExposeServiceClient = grpc.ServerStreamingClient[ExposeServiceEvent]
+func (c *daemonServiceClient) WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(WailsUIReadyResponse)
+ err := c.cc.Invoke(ctx, DaemonService_WailsUIReady_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
// DaemonServiceServer is the server API for DaemonService service.
// All implementations must embed UnimplementedDaemonServiceServer
// for forward compatibility.
@@ -586,6 +690,11 @@ type DaemonServiceServer interface {
Up(context.Context, *UpRequest) (*UpResponse, error)
// Status of the service.
Status(context.Context, *StatusRequest) (*StatusResponse, error)
+ // SubscribeStatus pushes a fresh StatusResponse on connection state
+ // changes (Connected / Disconnected / Connecting / address change /
+ // peers list change). The first message on the stream is the current
+ // snapshot, so a freshly-subscribed UI doesn't need to also call Status.
+ SubscribeStatus(*StatusRequest, grpc.ServerStreamingServer[StatusResponse]) error
// Down stops engine work in the daemon.
Down(context.Context, *DownRequest) (*DownResponse, error)
// GetConfig of the daemon.
@@ -622,6 +731,10 @@ type DaemonServiceServer interface {
StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error)
SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error
GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error)
+ // RegisterUILog records the desktop UI's absolute log path so the daemon's
+ // debug bundle can collect it (the daemon runs as root and can't resolve the
+ // user's config dir).
+ RegisterUILog(context.Context, *RegisterUILogRequest) (*RegisterUILogResponse, error)
SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error)
SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error)
AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error)
@@ -641,6 +754,22 @@ type DaemonServiceServer interface {
RequestJWTAuth(context.Context, *RequestJWTAuthRequest) (*RequestJWTAuthResponse, error)
// WaitJWTToken waits for JWT authentication completion
WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error)
+ // RequestExtendAuthSession initiates an SSO session-extension flow.
+ // The daemon prepares a PKCE/device-code request against the IdP and
+ // returns the verification URI; the UI is expected to open it. The flow
+ // state is kept in the daemon until WaitExtendAuthSession completes it.
+ RequestExtendAuthSession(context.Context, *RequestExtendAuthSessionRequest) (*RequestExtendAuthSessionResponse, error)
+ // WaitExtendAuthSession blocks until the user finishes the SSO step
+ // started by RequestExtendAuthSession, then forwards the resulting JWT
+ // to the management server's ExtendAuthSession RPC. Returns the new
+ // session expiry deadline. The tunnel stays up the entire time.
+ WaitExtendAuthSession(context.Context, *WaitExtendAuthSessionRequest) (*WaitExtendAuthSessionResponse, error)
+ // DismissSessionWarning records that the user clicked "Dismiss" on the
+ // T-WarningLead interactive notification, suppressing the auto-opened
+ // SessionAboutToExpire dialog that would otherwise fire at
+ // T-FinalWarningLead for the current deadline. Idempotent and best-effort:
+ // a missed call only means the fallback dialog will still appear.
+ DismissSessionWarning(context.Context, *DismissSessionWarningRequest) (*DismissSessionWarningResponse, error)
// StartCPUProfile starts CPU profiling in the daemon
StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error)
// StopCPUProfile stops CPU profiling in the daemon
@@ -648,6 +777,10 @@ type DaemonServiceServer interface {
GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error)
// ExposeService exposes a local port via the NetBird reverse proxy
ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error
+ // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI
+ // only cares whether the daemon implements it: an Unimplemented response
+ // means the daemon predates this UI and is too old to drive it.
+ WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error)
mustEmbedUnimplementedDaemonServiceServer()
}
@@ -670,6 +803,9 @@ func (UnimplementedDaemonServiceServer) Up(context.Context, *UpRequest) (*UpResp
func (UnimplementedDaemonServiceServer) Status(context.Context, *StatusRequest) (*StatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Status not implemented")
}
+func (UnimplementedDaemonServiceServer) SubscribeStatus(*StatusRequest, grpc.ServerStreamingServer[StatusResponse]) error {
+ return status.Error(codes.Unimplemented, "method SubscribeStatus not implemented")
+}
func (UnimplementedDaemonServiceServer) Down(context.Context, *DownRequest) (*DownResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Down not implemented")
}
@@ -727,6 +863,9 @@ func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, grpc.
func (UnimplementedDaemonServiceServer) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetEvents not implemented")
}
+func (UnimplementedDaemonServiceServer) RegisterUILog(context.Context, *RegisterUILogRequest) (*RegisterUILogResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method RegisterUILog not implemented")
+}
func (UnimplementedDaemonServiceServer) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SwitchProfile not implemented")
}
@@ -766,6 +905,15 @@ func (UnimplementedDaemonServiceServer) RequestJWTAuth(context.Context, *Request
func (UnimplementedDaemonServiceServer) WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) {
return nil, status.Error(codes.Unimplemented, "method WaitJWTToken not implemented")
}
+func (UnimplementedDaemonServiceServer) RequestExtendAuthSession(context.Context, *RequestExtendAuthSessionRequest) (*RequestExtendAuthSessionResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method RequestExtendAuthSession not implemented")
+}
+func (UnimplementedDaemonServiceServer) WaitExtendAuthSession(context.Context, *WaitExtendAuthSessionRequest) (*WaitExtendAuthSessionResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method WaitExtendAuthSession not implemented")
+}
+func (UnimplementedDaemonServiceServer) DismissSessionWarning(context.Context, *DismissSessionWarningRequest) (*DismissSessionWarningResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method DismissSessionWarning not implemented")
+}
func (UnimplementedDaemonServiceServer) StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method StartCPUProfile not implemented")
}
@@ -778,6 +926,9 @@ func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *Ins
func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error {
return status.Error(codes.Unimplemented, "method ExposeService not implemented")
}
+func (UnimplementedDaemonServiceServer) WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method WailsUIReady not implemented")
+}
func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {}
func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {}
@@ -871,6 +1022,17 @@ func _DaemonService_Status_Handler(srv interface{}, ctx context.Context, dec fun
return interceptor(ctx, in, info, handler)
}
+func _DaemonService_SubscribeStatus_Handler(srv interface{}, stream grpc.ServerStream) error {
+ m := new(StatusRequest)
+ if err := stream.RecvMsg(m); err != nil {
+ return err
+ }
+ return srv.(DaemonServiceServer).SubscribeStatus(m, &grpc.GenericServerStream[StatusRequest, StatusResponse]{ServerStream: stream})
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type DaemonService_SubscribeStatusServer = grpc.ServerStreamingServer[StatusResponse]
+
func _DaemonService_Down_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DownRequest)
if err := dec(in); err != nil {
@@ -1199,6 +1361,24 @@ func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
+func _DaemonService_RegisterUILog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(RegisterUILogRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).RegisterUILog(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_RegisterUILog_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).RegisterUILog(ctx, req.(*RegisterUILogRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
func _DaemonService_SwitchProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SwitchProfileRequest)
if err := dec(in); err != nil {
@@ -1433,6 +1613,60 @@ func _DaemonService_WaitJWTToken_Handler(srv interface{}, ctx context.Context, d
return interceptor(ctx, in, info, handler)
}
+func _DaemonService_RequestExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(RequestExtendAuthSessionRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).RequestExtendAuthSession(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_RequestExtendAuthSession_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).RequestExtendAuthSession(ctx, req.(*RequestExtendAuthSessionRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _DaemonService_WaitExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(WaitExtendAuthSessionRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).WaitExtendAuthSession(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_WaitExtendAuthSession_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).WaitExtendAuthSession(ctx, req.(*WaitExtendAuthSessionRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _DaemonService_DismissSessionWarning_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(DismissSessionWarningRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).DismissSessionWarning(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_DismissSessionWarning_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).DismissSessionWarning(ctx, req.(*DismissSessionWarningRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
func _DaemonService_StartCPUProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartCPUProfileRequest)
if err := dec(in); err != nil {
@@ -1498,6 +1732,24 @@ func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStr
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type DaemonService_ExposeServiceServer = grpc.ServerStreamingServer[ExposeServiceEvent]
+func _DaemonService_WailsUIReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(WailsUIReadyRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).WailsUIReady(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_WailsUIReady_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).WailsUIReady(ctx, req.(*WailsUIReadyRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
// DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -1589,6 +1841,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetEvents",
Handler: _DaemonService_GetEvents_Handler,
},
+ {
+ MethodName: "RegisterUILog",
+ Handler: _DaemonService_RegisterUILog_Handler,
+ },
{
MethodName: "SwitchProfile",
Handler: _DaemonService_SwitchProfile_Handler,
@@ -1641,6 +1897,18 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "WaitJWTToken",
Handler: _DaemonService_WaitJWTToken_Handler,
},
+ {
+ MethodName: "RequestExtendAuthSession",
+ Handler: _DaemonService_RequestExtendAuthSession_Handler,
+ },
+ {
+ MethodName: "WaitExtendAuthSession",
+ Handler: _DaemonService_WaitExtendAuthSession_Handler,
+ },
+ {
+ MethodName: "DismissSessionWarning",
+ Handler: _DaemonService_DismissSessionWarning_Handler,
+ },
{
MethodName: "StartCPUProfile",
Handler: _DaemonService_StartCPUProfile_Handler,
@@ -1653,8 +1921,17 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetInstallerResult",
Handler: _DaemonService_GetInstallerResult_Handler,
},
+ {
+ MethodName: "WailsUIReady",
+ Handler: _DaemonService_WailsUIReady_Handler,
+ },
},
Streams: []grpc.StreamDesc{
+ {
+ StreamName: "SubscribeStatus",
+ Handler: _DaemonService_SubscribeStatus_Handler,
+ ServerStreams: true,
+ },
{
StreamName: "StartCapture",
Handler: _DaemonService_StartCapture_Handler,
diff --git a/client/proto/generate.sh b/client/proto/generate.sh
index 1ae55e380..cea8ae912 100755
--- a/client/proto/generate.sh
+++ b/client/proto/generate.sh
@@ -12,5 +12,11 @@ script_path=$(dirname "$(realpath "$0")")
cd "$script_path"
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
-protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
+go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.26.3
+protoc -I ./ ./daemon.proto \
+ --go_out=../ \
+ --go-grpc_out=../ \
+ --grpc-gateway_out=../ \
+ --grpc-gateway_opt=generate_unbound_methods=true \
+ --experimental_allow_proto3_optional
cd "$old_pwd"
diff --git a/client/proto/metadata.go b/client/proto/metadata.go
new file mode 100644
index 000000000..9b1dbd16e
--- /dev/null
+++ b/client/proto/metadata.go
@@ -0,0 +1,61 @@
+package proto
+
+// SystemEvent metadata markers. The daemon stamps these on internal control
+// events it publishes over SubscribeEvents (profile-list refresh, log-level
+// change); the desktop UI recognises them and acts on them instead of
+// surfacing them as user-facing notifications.
+//
+// These live in the proto package — the shared contract both the daemon
+// (client/server) and the UI (client/ui/services) already import — so producer
+// and consumer reference the same constant rather than duplicating literals.
+// This file is hand-written and not touched by protoc.
+const (
+ // MetadataKindKey is the SystemEvent.metadata key carrying the event-kind
+ // marker (one of the MetadataKind* values below).
+ MetadataKindKey = "kind"
+
+ // MetadataKindProfileListChanged marks a CLI-driven profile add/remove that
+ // should nudge the UI's profile views to refresh.
+ MetadataKindProfileListChanged = "profile-list-changed"
+ // MetadataKindLogLevelChanged marks a daemon log-level change (or the
+ // per-subscription snapshot) that drives the GUI's file logging on/off.
+ MetadataKindLogLevelChanged = "log-level-changed"
+
+ // MetadataProfileKey carries the profile name for
+ // MetadataKindProfileListChanged.
+ MetadataProfileKey = "profile"
+ // MetadataLevelKey carries the lowercase logrus level name for
+ // MetadataKindLogLevelChanged.
+ MetadataLevelKey = "level"
+)
+
+// SystemEvent metadata markers for daemon config-change events. The daemon
+// publishes a SYSTEM-category event whenever its effective Config is
+// replaced (engine spawn, Up RPC, MDM policy diff); the UI re-fetches its
+// cached config/features in response and, for the MDM source, shows a
+// localised toast. Producer (client/server) and consumer (client/ui) share
+// these so neither duplicates the wire literals.
+const (
+ // MetadataTypeKey is the SystemEvent.metadata key carrying the
+ // config-change event type (one of the MetadataType* values below).
+ MetadataTypeKey = "type"
+ // MetadataTypeConfigChanged marks a config replacement that should nudge
+ // UIs to re-fetch their cached config + features. UserMessage is empty so
+ // the change is silent; the source is carried in MetadataSourceKey.
+ MetadataTypeConfigChanged = "config_changed"
+ // MetadataTypePolicyApplied marks an MDM-policy-driven config change. The
+ // daemon stamps it with a (non-localised) UserMessage; the UI suppresses
+ // that and builds its own localised toast off the paired config_changed
+ // event instead.
+ MetadataTypePolicyApplied = "policy_applied"
+
+ // MetadataSourceKey is the SystemEvent.metadata key carrying what
+ // triggered a config_changed event (one of the MetadataSource* values).
+ MetadataSourceKey = "source"
+ // MetadataSourceStartup marks a config_changed from the daemon Start path.
+ MetadataSourceStartup = "startup"
+ // MetadataSourceUpRPC marks a config_changed from the Up RPC.
+ MetadataSourceUpRPC = "up_rpc"
+ // MetadataSourceMDM marks a config_changed driven by an MDM policy diff.
+ MetadataSourceMDM = "mdm"
+)
diff --git a/client/server/debug.go b/client/server/debug.go
index 14dcaba33..8f4a506b4 100644
--- a/client/server/debug.go
+++ b/client/server/debug.go
@@ -7,18 +7,63 @@ import (
"context"
"errors"
"fmt"
+ "path/filepath"
"runtime/pprof"
+ "strings"
+ "time"
log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+ "github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/internal/debug"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/version"
)
// DebugBundle creates a debug bundle and returns the location.
-func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
+func (s *Server) DebugBundle(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
+ if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure()); err != nil {
+ return nil, err
+ }
+
+ // The UI log is opened as whoever asked for this bundle, so a caller only
+ // collects a log it owns (privileged callers excepted). ok is false on a
+ // socket that carries no identity, which skips the UI log.
+ callerID, callerIdentified := ipcauth.CallerIdentity(callerCtx)
+
+ path, managementURL, err := s.generateDebugBundle(req, uiLogOpener(callerID, callerIdentified))
+ if err != nil {
+ return nil, err
+ }
+
+ if req.GetUploadURL() == "" {
+ return &proto.DebugBundleResponse{Path: path}, nil
+ }
+
+ // The upload runs without s.mutex held: it does network I/O to a possibly
+ // slow destination and must not block the other RPCs that take the lock. The
+ // bounded context is a backstop against a hung connection.
+ uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ key, err := debug.UploadDebugBundle(uploadCtx, req.GetUploadURL(), managementURL, path, req.GetUploadInsecure())
+ if err != nil {
+ log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
+ return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
+ }
+
+ log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key)
+
+ return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
+}
+
+// generateDebugBundle builds the bundle under s.mutex and returns its path plus
+// the management URL captured under the lock, so the caller can run the upload
+// without holding the lock.
+func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener debug.LogOpener) (path string, managementURL string, err error) {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -53,7 +98,10 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
if engine != nil {
refreshStatus = func() {
log.Debug("refreshing system health status for debug bundle")
- engine.RunHealthProbes(true)
+ // Background ctx: the bundle wants a full, fresh probe regardless
+ // of the DebugBundle RPC client's lifetime. The engine's own ctx
+ // still aborts it on shutdown.
+ engine.RunHealthProbes(context.Background(), true)
}
}
}
@@ -64,6 +112,8 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
StatusRecorder: s.statusRecorder,
SyncResponse: syncResponse,
LogPath: s.logFile,
+ UILogPath: s.uiLogPath,
+ UILogOpener: uiOpener,
CPUProfile: cpuProfileData,
CapturePath: capturePath,
RefreshStatus: refreshStatus,
@@ -73,28 +123,22 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
},
debug.BundleConfig{
Anonymize: req.GetAnonymize(),
+ AnonymizeLevel: anonymize.ParseLevel(req.GetAnonymizeLevel()),
IncludeSystemInfo: req.GetSystemInfo(),
LogFileCount: req.GetLogFileCount(),
},
)
- path, err := bundleGenerator.Generate()
+ path, err = bundleGenerator.Generate()
if err != nil {
- return nil, fmt.Errorf("generate debug bundle: %w", err)
+ return "", "", fmt.Errorf("generate debug bundle: %w", err)
}
- if req.GetUploadURL() == "" {
- return &proto.DebugBundleResponse{Path: path}, nil
- }
- key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path)
- if err != nil {
- log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
- return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
+ if s.config != nil && s.config.ManagementURL != nil {
+ managementURL = s.config.ManagementURL.String()
}
- log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key)
-
- return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
+ return path, managementURL, nil
}
// GetLogLevel gets the current logging level for the server.
@@ -124,9 +168,48 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (
log.Infof("Log level set to %s", level.String())
+ // Signal the desktop UI so it can attach/detach its gui-client.log. Rides
+ // the SubscribeEvents stream as a marked event (see publishLogLevelChanged).
+ s.publishLogLevelChanged(level.String())
+
return &proto.SetLogLevelResponse{}, nil
}
+// RegisterUILog records the desktop UI's absolute log path so DebugBundle can
+// collect the GUI log. The daemon runs as root and can't resolve the user's
+// config dir, so the UI reports it. Last-writer-wins (one UI per socket).
+//
+// The path arrives over an IPC any local user can reach and is later opened by
+// a root daemon, so it is constrained to the file name the UI writes and to a
+// local absolute path. Authorization happens when DebugBundle opens it: the
+// bundle refuses a file its requester does not own. A caller the daemon cannot
+// identify cannot register a path at all.
+func (s *Server) RegisterUILog(callerCtx context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
+ if _, ok := ipcauth.CallerIdentity(callerCtx); !ok {
+ return nil, gstatus.Error(codes.PermissionDenied,
+ "registering a UI log path requires a control channel that carries the caller's identity")
+ }
+
+ path := filepath.Clean(req.GetPath())
+ if !filepath.IsAbs(path) || filepath.Base(path) != uiLogFileName {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "UI log path must be an absolute path ending in %s", uiLogFileName)
+ }
+ // filepath.IsAbs accepts a Windows UNC path (\\host\share\...) and a device
+ // path (\\.\, \\?\); opening one would make the root daemon reach a remote
+ // or device namespace. Require a plain local path.
+ if strings.HasPrefix(path, `\\`) {
+ return nil, gstatus.Error(codes.InvalidArgument, "UI log path must be a local path, not a UNC or device path")
+ }
+
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ s.uiLogPath = path
+ log.Infof("registered UI log path %s", s.uiLogPath)
+
+ return &proto.RegisterUILogResponse{}, nil
+}
+
// SetSyncResponsePersistence sets the sync response persistence for the server.
func (s *Server) SetSyncResponsePersistence(_ context.Context, req *proto.SetSyncResponsePersistenceRequest) (*proto.SetSyncResponsePersistenceResponse, error) {
s.mutex.Lock()
diff --git a/client/server/debug_gate.go b/client/server/debug_gate.go
new file mode 100644
index 000000000..983a13aaf
--- /dev/null
+++ b/client/server/debug_gate.go
@@ -0,0 +1,99 @@
+//go:build !android && !ios
+
+package server
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "strings"
+
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/configs"
+ "github.com/netbirdio/netbird/client/internal/debug"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/upload-server/types"
+)
+
+// uiLogFileName is the only file name the daemon accepts as a UI log path. The
+// UI (writer), this validation, and the bundle collector all read it from
+// configs so they cannot drift.
+const uiLogFileName = configs.UILogFile
+
+// uiLogOpener opens the registered UI log, and its rotated siblings, on behalf
+// of the caller requesting the bundle: OpenOwnedFile then collects the log only
+// when that caller owns it (or is privileged). identified is false on a socket
+// that carries no caller identity, in which case nothing is opened.
+func uiLogOpener(id ipcauth.Identity, identified bool) debug.LogOpener {
+ return func(path string) (*os.File, error) {
+ if !identified {
+ return nil, fmt.Errorf("bundle requester has no verified identity")
+ }
+ return ipcauth.OpenOwnedFile(id, path)
+ }
+}
+
+// requirePrivilegeForUploadURL restricts where the daemon may send a debug
+// bundle. The bundle holds the daemon's own logs and state, and the daemon
+// fetches the upload URL itself, so an unrestricted endpoint turns the daemon
+// into both an exfiltration channel and a request forwarder that reaches
+// services only it can talk to.
+//
+// The upload service NetBird publishes is open to any caller, since that is what
+// the CLI and the desktop UI use. Any other endpoint, self-hosted upload servers
+// included, requires a privileged caller. Plaintext is refused for everyone: the
+// daemon fetches the URL and then PUTs the bundle to whatever that fetch returns,
+// so an http hop is a place to intercept the bundle or the redirect.
+//
+// insecure relaxes transport security (http, or an untrusted TLS certificate)
+// for a self-hosted server. It weakens a root-privileged upload, so it is
+// refused for an unprivileged caller regardless of the host.
+func requirePrivilegeForUploadURL(ctx context.Context, rawURL string, insecure bool) error {
+ if rawURL == "" {
+ return nil
+ }
+
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return gstatus.Errorf(codes.InvalidArgument, "parse upload URL: %v", err)
+ }
+
+ // --insecure relaxes https to http or an untrusted certificate; it does not
+ // widen the URL to arbitrary schemes, so a host and http/https are required
+ // before the insecure branch takes over.
+ if parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
+ return gstatus.Errorf(codes.InvalidArgument, "upload URL must be http or https with a host")
+ }
+
+ if insecure {
+ return denyPrivileged(ctx,
+ "uploading a debug bundle without transport security (--upload-bundle-insecure)",
+ ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-insecure --upload-bundle-url "))
+ }
+
+ if parsed.Scheme != "https" {
+ return gstatus.Errorf(codes.InvalidArgument, "upload URL must use https, got scheme %q", parsed.Scheme)
+ }
+
+ if isDefaultUploadService(parsed) {
+ return nil
+ }
+
+ return denyPrivileged(ctx,
+ "uploading a debug bundle to an upload service other than the default one",
+ ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-url "))
+}
+
+// isDefaultUploadService reports whether the URL points at the upload service
+// NetBird runs. Only the host is compared: the service's path may differ between
+// releases, and the host is what decides who receives the bundle.
+func isDefaultUploadService(parsed *url.URL) bool {
+ defaultURL, err := url.Parse(types.DefaultBundleURL)
+ if err != nil {
+ return false
+ }
+ return parsed.Scheme == defaultURL.Scheme && strings.EqualFold(parsed.Host, defaultURL.Host)
+}
diff --git a/client/server/debug_gate_test.go b/client/server/debug_gate_test.go
new file mode 100644
index 000000000..e958fc581
--- /dev/null
+++ b/client/server/debug_gate_test.go
@@ -0,0 +1,157 @@
+//go:build !android && !ios
+
+package server
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/upload-server/types"
+)
+
+func TestRegisterUILogRefusesUnidentifiedCaller(t *testing.T) {
+ s := &Server{}
+
+ _, err := s.RegisterUILog(noIdentityCtx(), &proto.RegisterUILogRequest{
+ Path: filepath.Join(t.TempDir(), uiLogFileName),
+ })
+
+ if gstatus.Code(err) != codes.PermissionDenied {
+ t.Fatalf("code = %v, want PermissionDenied", gstatus.Code(err))
+ }
+}
+
+func TestRegisterUILogRefusesForeignPath(t *testing.T) {
+ secret := "/etc/shadow"
+ if runtime.GOOS == "windows" {
+ secret = `C:\Windows\System32\config\SAM`
+ }
+
+ tests := []struct {
+ name string
+ path string
+ }{
+ {"empty", ""},
+ {"relative", filepath.Join("netbird", uiLogFileName)},
+ {"another file", secret},
+ {"directory of the log", t.TempDir()},
+ {"unc path", `\\attacker\share\` + uiLogFileName},
+ {"device path", `\\.\C:\` + uiLogFileName},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ s := &Server{}
+
+ _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: tc.path})
+
+ if gstatus.Code(err) != codes.InvalidArgument {
+ t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
+ }
+ if s.uiLogPath != "" {
+ t.Fatalf("path %q was recorded despite the refusal", s.uiLogPath)
+ }
+ })
+ }
+}
+
+func TestRegisterUILogRecordsPath(t *testing.T) {
+ s := &Server{}
+ path := filepath.Join(t.TempDir(), uiLogFileName)
+
+ if _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: path}); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+
+ if s.uiLogPath != path {
+ t.Fatalf("path = %q, want %q", s.uiLogPath, path)
+ }
+}
+
+// The UI log is opened as the bundle requester, so a second local user cannot
+// collect a log they do not own, and an unidentified requester collects nothing.
+func TestUILogOpenerBindsToRequester(t *testing.T) {
+ path := filepath.Join(t.TempDir(), uiLogFileName)
+ if err := os.WriteFile(path, []byte("log line"), 0600); err != nil {
+ t.Fatalf("write log: %v", err)
+ }
+
+ // A different unprivileged user than the file's owner: refused.
+ if _, err := uiLogOpener(unprivilegedIdentity(), true)(path); err == nil {
+ t.Fatal("expected a file the requester does not own to be refused")
+ }
+
+ // No verified identity: refused.
+ if _, err := uiLogOpener(ipcauth.Identity{}, false)(path); err == nil {
+ t.Fatal("expected an unidentified requester to be refused")
+ }
+
+ // The requester that owns the file: allowed. The test process created it, so
+ // its own identity is the owner (and a privileged runner is exempt anyway).
+ owner, err := ipcauth.CurrentProcessIdentity()
+ if err != nil {
+ t.Fatalf("current identity: %v", err)
+ }
+ f, err := uiLogOpener(owner, true)(path)
+ if err != nil {
+ t.Fatalf("expected the owning requester to be allowed, got %v", err)
+ }
+ _ = f.Close()
+}
+
+func TestRequirePrivilegeForUploadURL(t *testing.T) {
+ tests := []struct {
+ name string
+ url string
+ insecure bool
+ unprivOK bool
+ invalid bool
+ rootAlso bool
+ }{
+ {name: "no upload", url: "", unprivOK: true},
+ {name: "default service", url: types.DefaultBundleURL, unprivOK: true},
+ {name: "default service, other path", url: "https://upload.debug.netbird.io/other", unprivOK: true},
+ {name: "loopback exfiltration endpoint", url: "https://127.0.0.1:8080/upload-url", rootAlso: true},
+ {name: "custom upload service", url: "https://attacker.example/upload-url", rootAlso: true},
+ {name: "plaintext default host", url: "http://upload.debug.netbird.io/upload-url", invalid: true},
+ {name: "plaintext custom host", url: "http://attacker.example/upload-url", invalid: true},
+ {name: "unsupported scheme", url: "file:///etc/shadow", invalid: true},
+ // insecure relaxes transport security; privileged only, whatever the host.
+ {name: "insecure http custom", url: "http://selfhosted.local/upload-url", insecure: true, rootAlso: true},
+ {name: "insecure https custom", url: "https://selfhosted.local/upload-url", insecure: true, rootAlso: true},
+ {name: "insecure default host", url: types.DefaultBundleURL, insecure: true, rootAlso: true},
+ // --insecure must not widen the URL to non-http(s) schemes or a hostless URL.
+ {name: "insecure file scheme", url: "file:///etc/shadow", insecure: true, invalid: true},
+ {name: "insecure hostless", url: "https:///upload-url", insecure: true, invalid: true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := requirePrivilegeForUploadURL(userCtx(), tc.url, tc.insecure)
+
+ switch {
+ case tc.invalid:
+ if gstatus.Code(err) != codes.InvalidArgument {
+ t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
+ }
+ return
+ case tc.unprivOK:
+ assertAllowed(t, err)
+ return
+ default:
+ assertDenied(t, err)
+ }
+
+ if tc.rootAlso {
+ assertAllowed(t, requirePrivilegeForUploadURL(rootCtx(), tc.url, tc.insecure))
+ }
+ })
+ }
+}
diff --git a/client/server/event.go b/client/server/event.go
index d93151c96..753a051e7 100644
--- a/client/server/event.go
+++ b/client/server/event.go
@@ -1,7 +1,9 @@
package server
import (
+ "github.com/google/uuid"
log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/client/proto"
)
@@ -16,6 +18,15 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
log.Debug("client subscribed to events")
s.startUpdateManagerForGUI()
+ // Replay the current log level to this subscriber so a freshly-connected UI
+ // learns it even when the daemon was already started with --log-level debug
+ // (the change-driven publishLogLevelChanged only fires on SetLogLevel). Sent
+ // directly on this stream rather than via PublishEvent so it reaches only
+ // the new subscriber, not every connected client.
+ if err := s.sendCurrentLogLevel(stream); err != nil {
+ return err
+ }
+
for {
select {
case event := <-subscription.Events():
@@ -28,3 +39,24 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
}
}
}
+
+// sendCurrentLogLevel sends a marked log-level-changed SystemEvent carrying the
+// daemon's current level directly to one subscriber. Mirrors the shape
+// publishLogLevelChanged emits so the UI's dispatchSystemEvent handles both the
+// same way.
+func (s *Server) sendCurrentLogLevel(stream proto.DaemonService_SubscribeEventsServer) error {
+ level := log.GetLevel().String()
+ event := &proto.SystemEvent{
+ Id: uuid.New().String(),
+ Severity: proto.SystemEvent_INFO,
+ Category: proto.SystemEvent_SYSTEM,
+ Message: "Log level changed",
+ Metadata: map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level},
+ Timestamp: timestamppb.Now(),
+ }
+ if err := stream.Send(event); err != nil {
+ log.Warnf("error sending initial log level event: %v", err)
+ return err
+ }
+ return nil
+}
diff --git a/client/server/extend_authsession_test.go b/client/server/extend_authsession_test.go
new file mode 100644
index 000000000..a1a048a7c
--- /dev/null
+++ b/client/server/extend_authsession_test.go
@@ -0,0 +1,42 @@
+package server
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+)
+
+func TestInnermostStatus(t *testing.T) {
+ t.Run("wrapped gRPC status", func(t *testing.T) {
+ inner := gstatus.Error(codes.PermissionDenied, "peer is already registered by a different User or a Setup Key")
+ // Mirror the daemon wrap chain: engine wraps with %w, mgm error is the inner status.
+ wrapped := fmt.Errorf("extend auth session on management: %w", inner)
+
+ st := innermostStatus(wrapped)
+ require.NotNil(t, st)
+ require.Equal(t, codes.PermissionDenied, st.Code())
+ require.Equal(t, "peer is already registered by a different User or a Setup Key", st.Message())
+ })
+
+ t.Run("deepest status wins over an outer one", func(t *testing.T) {
+ inner := gstatus.Error(codes.PermissionDenied, "deepest")
+ chain := fmt.Errorf("outer: %w", fmt.Errorf("mid: %w", inner))
+
+ st := innermostStatus(chain)
+ require.NotNil(t, st)
+ require.Equal(t, codes.PermissionDenied, st.Code())
+ require.Equal(t, "deepest", st.Message())
+ })
+
+ t.Run("no status in chain", func(t *testing.T) {
+ require.Nil(t, innermostStatus(errors.New("plain error")))
+ })
+
+ t.Run("nil error", func(t *testing.T) {
+ require.Nil(t, innermostStatus(nil))
+ })
+}
diff --git a/client/server/lock_order_test.go b/client/server/lock_order_test.go
new file mode 100644
index 000000000..457e3db34
--- /dev/null
+++ b/client/server/lock_order_test.go
@@ -0,0 +1,51 @@
+package server
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// The daemon takes guardedConfigMu before s.mutex. authorizeAndPrepareLogin
+// takes s.mutex while holding guardedConfigMu, so a SetConfig that grabbed
+// s.mutex first and then waited for guardedConfigMu would deadlock the daemon
+// against a concurrent login: two unprivileged IPC calls are enough.
+//
+// The held guardedConfigMu below stands in for that login. While SetConfig waits
+// for it, s.mutex must stay free, otherwise the login waiting for s.mutex could
+// never release guardedConfigMu.
+func TestSetConfig_TakesGuardedConfigMuBeforeServerMutex(t *testing.T) {
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ s.guardedConfigMu.Lock()
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ })
+ done <- err
+ }()
+
+ require.Never(t, func() bool {
+ if !s.mutex.TryLock() {
+ return true
+ }
+ s.mutex.Unlock()
+ return false
+ }, 500*time.Millisecond, 10*time.Millisecond,
+ "SetConfig held s.mutex while waiting for guardedConfigMu, which deadlocks against a concurrent login")
+
+ s.guardedConfigMu.Unlock()
+
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("SetConfig did not finish after guardedConfigMu was released")
+ }
+}
diff --git a/client/server/login_gate_test.go b/client/server/login_gate_test.go
new file mode 100644
index 000000000..de62a8180
--- /dev/null
+++ b/client/server/login_gate_test.go
@@ -0,0 +1,127 @@
+package server
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// A refused login must not leave the profile switched. Login can both switch
+// profiles and carry the guarded config fields, so the gate has to run before the
+// switch: otherwise a caller whose change is refused still gets the side effect of
+// activating whichever profile the request named.
+func TestLogin_RefusedChangeLeavesTheProfileAlone(t *testing.T) {
+ s, _, activeProfile, username, _ := setupServerWithProfile(t)
+
+ // Login reads process state off the daemon's root context.
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ // A second profile that runs the SSH server, which is what makes repointing
+ // its management binding a privileged change.
+ target := "ssh-enabled"
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
+ ManagementURL: "https://api.netbird.io:443",
+ ServerSSHAllowed: boolPtr(true),
+ })
+ require.NoError(t, err)
+
+ _, err = s.Login(userCtx(), &proto.LoginRequest{
+ ProfileName: &target,
+ Username: &username,
+ ManagementUrl: "https://mgmt.attacker.example:443",
+ })
+ require.Error(t, err, "an unprivileged caller must not move the management URL of an SSH-enabled profile")
+ require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
+
+ active, err := s.profileManager.GetActiveProfileState()
+ require.NoError(t, err)
+ require.Equal(t, profilemanager.ID(activeProfile), active.ID,
+ "the refused login switched the active profile anyway")
+}
+
+// A caller whose change becomes privileged only after its first check must be
+// refused without having cancelled a login or switched profiles: the first check is
+// unsynchronized, so the SSH server can be enabled by a concurrent privileged
+// request in between, and the authoritative check happens before any side effect.
+func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing.T) {
+ s, _, activeProfile, username, _ := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ // The target profile has SSH off, so the first check lets the request through.
+ target := "ssh-later"
+ targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json")
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: targetPath,
+ ManagementURL: "https://api.netbird.io:443",
+ ServerSSHAllowed: boolPtr(false),
+ })
+ require.NoError(t, err)
+
+ cancelled := false
+ s.actCancel = func() { cancelled = true }
+
+ // Stand in for a privileged SetConfig that enables the SSH server between the
+ // two checks, which is the interleaving the lock has to make safe.
+ afterLoginPreCheck = func() {
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: targetPath,
+ ServerSSHAllowed: boolPtr(true),
+ })
+ require.NoError(t, err)
+ }
+ t.Cleanup(func() { afterLoginPreCheck = nil })
+
+ _, err = s.Login(userCtx(), &proto.LoginRequest{
+ ProfileName: &target,
+ Username: &username,
+ ManagementUrl: "https://mgmt.attacker.example:443",
+ })
+ require.Error(t, err)
+ require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
+ require.False(t, cancelled, "the refused login cancelled the login already in progress")
+
+ active, err := s.profileManager.GetActiveProfileState()
+ require.NoError(t, err)
+ require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway")
+
+ stored, err := profilemanager.ReadConfig(targetPath)
+ require.NoError(t, err)
+ require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL")
+}
+
+// Login cancels whatever login is already in progress before starting its own. A
+// refused caller must not get that far, otherwise anyone able to reach the socket
+// can abort someone else's login by sending a request that is denied.
+func TestLogin_RefusedChangeLeavesAnInProgressLoginAlone(t *testing.T) {
+ s, _, _, username, _ := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ target := "ssh-enabled"
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
+ ManagementURL: "https://api.netbird.io:443",
+ ServerSSHAllowed: boolPtr(true),
+ })
+ require.NoError(t, err)
+
+ cancelled := false
+ s.actCancel = func() { cancelled = true }
+
+ _, err = s.Login(userCtx(), &proto.LoginRequest{
+ ProfileName: &target,
+ Username: &username,
+ ManagementUrl: "https://mgmt.attacker.example:443",
+ })
+ require.Error(t, err)
+ require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
+ require.False(t, cancelled, "the refused login cancelled the login already in progress")
+}
diff --git a/client/server/login_outcome_test.go b/client/server/login_outcome_test.go
new file mode 100644
index 000000000..7ebf04f92
--- /dev/null
+++ b/client/server/login_outcome_test.go
@@ -0,0 +1,110 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// A login that never reached Management is not a decision about the peer's
+// credentials, so it must come back as a retryable error rather than an SSO
+// prompt: the user cannot finish a browser login while Management is down, and
+// the CLI's own backoff resolves the outage on its own once the daemon reports
+// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
+// because Management happened to be restarting when the daemon dialed it.
+func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
+ s, _, _, username, _ := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ unreachable := errors.New("create connection: dial context: context deadline exceeded")
+ attempts := 0
+ s.isLoginRequiredFn = func(context.Context) (bool, error) {
+ attempts++
+ return false, unreachable
+ }
+
+ resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
+ require.Error(t, err)
+ require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
+ require.Nil(t, resp, "a failed login must not answer with a login response")
+ require.Equal(t, 1, attempts)
+ require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
+
+ status, err := internal.CtxGetState(s.rootCtx).Status()
+ require.NoError(t, err)
+ require.Equal(t, internal.StatusLoginFailed, status,
+ "a peer that could not reach Management is not waiting on a login")
+}
+
+// The counterpart: Management refusing the peer's credentials is a decision, and
+// the SSO flow still has to start for it. The profile carries an unusable
+// private key so the flow setup fails immediately instead of dialing, which is
+// enough to show the branch was entered — the refusal itself is never what comes
+// back out.
+func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
+ s, _, _, username, cfgPath := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+ breakProfilePrivateKey(t, cfgPath)
+
+ s.isLoginRequiredFn = func(context.Context) (bool, error) {
+ return true, nil
+ }
+
+ _, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
+ require.Error(t, err)
+
+ status, stateErr := internal.CtxGetState(s.rootCtx).Status()
+ require.NoError(t, stateErr)
+ require.Equal(t, internal.StatusLoginFailed, status,
+ "the SSO flow setup was never reached with the broken key")
+}
+
+func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) {
+ s, _, _, username, _ := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ s.isLoginRequiredFn = func(context.Context) (bool, error) {
+ return true, nil
+ }
+
+ var keysTried []string
+ s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) {
+ keysTried = append(keysTried, setupKey)
+ return "", nil
+ }
+
+ setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F"
+ resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey})
+ require.NoError(t, err, "the probe's outcome leaked out as the login result")
+ require.NotNil(t, resp)
+ require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt")
+ require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow")
+
+ status, err := internal.CtxGetState(s.rootCtx).Status()
+ require.NoError(t, err)
+ require.Equal(t, internal.StatusIdle, status)
+}
+
+// breakProfilePrivateKey replaces the profile's private key with an unparseable
+// one, which makes any attempt to build a Management client fail on the spot.
+func breakProfilePrivateKey(t *testing.T, cfgPath string) {
+ t.Helper()
+
+ raw, err := os.ReadFile(cfgPath)
+ require.NoError(t, err)
+
+ var cfg map[string]any
+ require.NoError(t, json.Unmarshal(raw, &cfg))
+ cfg["PrivateKey"] = "not-a-key"
+
+ patched, err := json.Marshal(cfg)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
+}
diff --git a/client/server/mdm.go b/client/server/mdm.go
index 0da0ec5d1..9836c6bea 100644
--- a/client/server/mdm.go
+++ b/client/server/mdm.go
@@ -3,6 +3,7 @@ package server
import (
"context"
"fmt"
+ "net/url"
"time"
log "github.com/sirupsen/logrus"
@@ -99,7 +100,10 @@ func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
proto.SystemEvent_SYSTEM,
"MDM policy applied",
"NetBird configuration was updated by your IT policy.",
- map[string]string{"source": "mdm", "type": "policy_applied"},
+ map[string]string{
+ proto.MetadataSourceKey: proto.MetadataSourceMDM,
+ proto.MetadataTypeKey: proto.MetadataTypePolicyApplied,
+ },
)
return nil
}
@@ -124,8 +128,8 @@ func (s *Server) publishConfigChangedEvent(source string) {
fmt.Sprintf("daemon config changed (source=%s)", source),
"",
map[string]string{
- "source": source,
- "type": "config_changed",
+ proto.MetadataSourceKey: source,
+ proto.MetadataTypeKey: proto.MetadataTypeConfigChanged,
},
)
}
@@ -152,7 +156,6 @@ func (s *Server) restartEngineForMDMLocked() error {
s.config = config
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
- s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled)
ctx, cancel := context.WithCancel(s.rootCtx)
s.actCancel = cancel
@@ -161,7 +164,7 @@ func (s *Server) restartEngineForMDMLocked() error {
s.clientGiveUpChan = make(chan struct{})
log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config")
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
- s.publishConfigChangedEvent("mdm")
+ s.publishConfigChangedEvent(proto.MetadataSourceMDM)
return nil
}
@@ -182,6 +185,37 @@ func conflictBool(key string, p *bool) conflictCheck {
}
}
+func canonicalURL(s string) string {
+ u, err := url.ParseRequestURI(s)
+ if err != nil {
+ return s
+ }
+ if u.Port() == "" {
+ switch u.Scheme {
+ case "https":
+ u.Host += ":443"
+ case "http":
+ u.Host += ":80"
+ }
+ }
+ return u.String()
+}
+
+// conflictURL is conflictString for URL-typed keys: both sides are
+// normalized via canonicalURL before comparison.
+func conflictURL(key, got string) conflictCheck {
+ return conflictCheck{
+ key: key,
+ check: func(pol *mdm.Policy) bool {
+ if got == "" {
+ return true
+ }
+ want, ok := pol.GetString(key)
+ return ok && canonicalURL(want) == canonicalURL(got)
+ },
+ }
+}
+
// conflictString builds a conflictCheck for a string MDM key. An empty
// `got` is treated as "field not set" (no override requested); otherwise
// the check returns true only when the policy contains the key and its
@@ -257,7 +291,7 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
}
return resolveConflicts(policy, []conflictCheck{
- conflictString(mdm.KeyManagementURL, msg.ManagementUrl),
+ conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
conflictString(mdm.KeyPreSharedKey, pskGot),
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
@@ -305,7 +339,6 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
msg.DisableFirewall != nil ||
msg.BlockLanAccess != nil ||
msg.DisableNotifications != nil ||
- msg.LazyConnectionEnabled != nil ||
msg.BlockInbound != nil ||
msg.DisableIpv6 != nil ||
msg.EnableSSHRoot != nil ||
@@ -348,7 +381,6 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
msg.BlockLanAccess != nil ||
msg.DisableNotifications != nil ||
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
- msg.LazyConnectionEnabled != nil ||
msg.BlockInbound != nil
}
@@ -380,7 +412,7 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
}
return resolveConflicts(policy, []conflictCheck{
- conflictString(mdm.KeyManagementURL, msg.ManagementUrl),
+ conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
conflictString(mdm.KeyPreSharedKey, pskGot),
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
diff --git a/client/server/network.go b/client/server/network.go
index 7a3c08f2e..c390b8180 100644
--- a/client/server/network.go
+++ b/client/server/network.go
@@ -8,7 +8,6 @@ import (
"sort"
"strings"
- "golang.org/x/exp/maps"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
@@ -161,19 +160,11 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
return nil, fmt.Errorf("no route manager")
}
- routeSelector := routeManager.GetRouteSelector()
if req.GetAll() {
- routeSelector.SelectAllRoutes()
- } else {
- routes := toNetIDs(req.GetNetworkIDs())
- routesMap := routeManager.GetClientRoutesWithNetID()
- routes = route.ExpandV6ExitPairs(routes, routesMap)
- netIdRoutes := maps.Keys(routesMap)
- if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
- return nil, fmt.Errorf("select routes: %w", err)
- }
+ routeManager.SelectAllRoutes()
+ } else if err := routeManager.SelectRoutes(toNetIDs(req.GetNetworkIDs()), req.GetAppend()); err != nil {
+ return nil, err
}
- routeManager.TriggerSelection(routeManager.GetClientRoutes())
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
@@ -213,19 +204,11 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
return nil, fmt.Errorf("no route manager")
}
- routeSelector := routeManager.GetRouteSelector()
if req.GetAll() {
- routeSelector.DeselectAllRoutes()
- } else {
- routes := toNetIDs(req.GetNetworkIDs())
- routesMap := routeManager.GetClientRoutesWithNetID()
- routes = route.ExpandV6ExitPairs(routes, routesMap)
- netIdRoutes := maps.Keys(routesMap)
- if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil {
- return nil, fmt.Errorf("deselect routes: %w", err)
- }
+ routeManager.DeselectAllRoutes()
+ } else if err := routeManager.DeselectRoutes(toNetIDs(req.GetNetworkIDs())); err != nil {
+ return nil, err
}
- routeManager.TriggerSelection(routeManager.GetClientRoutes())
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
@@ -249,3 +232,4 @@ func toNetIDs(routes []string) []route.NetID {
}
return netIDs
}
+
diff --git a/client/server/panic_windows.go b/client/server/panic_windows.go
index 8592f12ad..4bed6662f 100644
--- a/client/server/panic_windows.go
+++ b/client/server/panic_windows.go
@@ -3,6 +3,7 @@
package server
import (
+ "errors"
"fmt"
"os"
"path"
@@ -69,7 +70,7 @@ func setStdHandle(f *os.File) error {
handle := f.Fd()
r0, _, e1 := setStdHandleFn.Call(stdErrorHandle, handle)
if r0 == 0 {
- if e1 != nil {
+ if !errors.Is(e1, syscall.Errno(0)) {
return e1
}
return syscall.EINVAL
diff --git a/client/server/probe_throttle.go b/client/server/probe_throttle.go
new file mode 100644
index 000000000..ec6137e15
--- /dev/null
+++ b/client/server/probe_throttle.go
@@ -0,0 +1,88 @@
+package server
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// healthProbeRunner runs the full, expensive probe (network round-trips to
+// management, signal and the relays) and reports whether every component was
+// healthy. ctx cancels the probe when the caller gives up. Satisfied by
+// *internal.Engine.
+type healthProbeRunner interface {
+ RunHealthProbes(ctx context.Context, waitForResult bool) bool
+}
+
+// statsRefresher does the cheap WireGuard-stats refresh callers fall back to
+// when a fresh probe isn't warranted. Satisfied by *peer.Status.
+type statsRefresher interface {
+ RefreshWireGuardStats() error
+}
+
+// probeThrottle rate-limits and single-flights the daemon's health probes.
+//
+// Health probes are expensive (network round-trips to management, signal and
+// the relays), while Status(GetFullPeerStatus=true) RPCs can arrive frequently
+// and concurrently — the desktop UI alone issues one per connect/disconnect.
+// probeThrottle keeps that load bounded with two rules:
+//
+// - Single-flight: only one probe runs at a time. Callers that pile up while
+// a probe is in flight share its result instead of each launching another,
+// even when that probe failed. A failed probe therefore does not make every
+// waiter re-probe in turn; the next, non-overlapping caller can try again.
+// - Throttle: after a fully successful probe the result is cached for
+// interval. While any component is unhealthy the cache is not advanced, so
+// later callers keep probing frequently and notice recovery quickly — the
+// intentional "probe often while unhealthy" behaviour from the original
+// design.
+type probeThrottle struct {
+ interval time.Duration
+
+ mu sync.Mutex
+ lastOK time.Time // last fully-successful probe; drives the throttle window
+ completedAt time.Time // when the most recent probe finished; drives single-flight sharing
+}
+
+func newProbeThrottle(interval time.Duration) *probeThrottle {
+ return &probeThrottle{interval: interval}
+}
+
+// Run decides whether to run a fresh health probe or serve the most recent
+// result. It serialises concurrent callers: at most one runner.RunHealthProbes
+// executes at a time and the rest call refresher.RefreshWireGuardStats and read
+// the snapshot it produced.
+//
+// Both calls run while the throttle's lock is held, so a slow probe blocks
+// other callers until it completes — that blocking is the single-flight
+// guarantee. ctx is forwarded to RunHealthProbes so a caller that gives up
+// cancels the in-flight probe (and any caller still queued on the lock falls
+// through quickly once it acquires it, since the probe ctx is already done).
+func (t *probeThrottle) Run(ctx context.Context, runner healthProbeRunner, refresher statsRefresher, waitForResult bool) {
+ entered := time.Now()
+
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ // A probe that finished after we entered ran while we were waiting on the
+ // lock — i.e. a peer in the same burst already probed for us, so share its
+ // result rather than launch another. This holds even when that probe
+ // failed, so a failed probe doesn't make every waiter re-probe in turn.
+ sharedRecentProbe := t.completedAt.After(entered)
+ throttled := time.Since(t.lastOK) <= t.interval
+
+ if sharedRecentProbe || throttled {
+ if err := refresher.RefreshWireGuardStats(); err != nil {
+ log.Debugf("failed to refresh WireGuard stats: %v", err)
+ }
+ return
+ }
+
+ healthy := runner.RunHealthProbes(ctx, waitForResult)
+ t.completedAt = time.Now()
+ if healthy {
+ t.lastOK = t.completedAt
+ }
+}
diff --git a/client/server/probe_throttle_test.go b/client/server/probe_throttle_test.go
new file mode 100644
index 000000000..cae776fa4
--- /dev/null
+++ b/client/server/probe_throttle_test.go
@@ -0,0 +1,109 @@
+package server
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// fakeProber implements both healthProbeRunner and statsRefresher with
+// caller-supplied behaviour.
+type fakeProber struct {
+ onProbe func() bool
+ onRefresh func()
+}
+
+func (f fakeProber) RunHealthProbes(context.Context, bool) bool {
+ return f.onProbe()
+}
+
+func (f fakeProber) RefreshWireGuardStats() error {
+ if f.onRefresh != nil {
+ f.onRefresh()
+ }
+ return nil
+}
+
+func TestProbeThrottle_CachesAfterSuccess(t *testing.T) {
+ pt := newProbeThrottle(time.Minute)
+
+ var probes, refreshes int
+ prober := fakeProber{
+ onProbe: func() bool { probes++; return true },
+ onRefresh: func() { refreshes++ },
+ }
+
+ pt.Run(context.Background(), prober, prober, false)
+ pt.Run(context.Background(), prober, prober, false)
+
+ if probes != 1 {
+ t.Fatalf("expected 1 probe within the throttle window, got %d", probes)
+ }
+ if refreshes != 1 {
+ t.Fatalf("expected the throttled caller to refresh stats once, got %d", refreshes)
+ }
+}
+
+func TestProbeThrottle_StaysOpenWhileUnhealthy(t *testing.T) {
+ pt := newProbeThrottle(time.Minute)
+
+ var probes int
+ prober := fakeProber{onProbe: func() bool { probes++; return false }} // never healthy
+
+ // Sequential, non-overlapping callers must each re-probe while unhealthy:
+ // a failed probe does not advance the throttle window.
+ pt.Run(context.Background(), prober, prober, false)
+ pt.Run(context.Background(), prober, prober, false)
+ pt.Run(context.Background(), prober, prober, false)
+
+ if probes != 3 {
+ t.Fatalf("expected every non-overlapping caller to probe while unhealthy, got %d", probes)
+ }
+}
+
+func TestProbeThrottle_SingleFlightSharesResult(t *testing.T) {
+ pt := newProbeThrottle(time.Minute)
+
+ var probes int32
+ release := make(chan struct{})
+ started := make(chan struct{})
+
+ // First caller blocks inside the probe until released, holding the lock so
+ // the others pile up behind it.
+ prober := fakeProber{onProbe: func() bool {
+ if atomic.AddInt32(&probes, 1) == 1 {
+ close(started)
+ <-release
+ }
+ return false // unhealthy — the share must happen regardless of result
+ }}
+
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ pt.Run(context.Background(), prober, prober, false)
+ }()
+
+ <-started // ensure the first probe is in flight before the burst arrives
+
+ const waiters = 9
+ wg.Add(waiters)
+ for i := 0; i < waiters; i++ {
+ go func() {
+ defer wg.Done()
+ pt.Run(context.Background(), prober, prober, false)
+ }()
+ }
+
+ // Give the waiters time to block on the lock, then let the first finish.
+ time.Sleep(50 * time.Millisecond)
+ close(release)
+ wg.Wait()
+
+ if got := atomic.LoadInt32(&probes); got != 1 {
+ t.Fatalf("expected a concurrent burst to run exactly 1 probe, got %d", got)
+ }
+}
diff --git a/client/server/server.go b/client/server/server.go
index a4d53a823..f33e19075 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -19,6 +19,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
gstatus "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/expose"
@@ -67,7 +68,28 @@ type Server struct {
logFile string
+ // uiLogPath is the desktop UI's absolute log path, reported via
+ // RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle
+ // can collect the GUI log even though the daemon runs as root and can't
+ // resolve the user's config dir. Last-writer-wins (one UI per socket).
+ // DebugBundle opens it on behalf of the bundle requester and refuses a file
+ // that caller does not own, so a local user cannot read another user's log
+ // or a root-only file through it.
+ uiLogPath string
+
oauthAuthFlow oauthAuthFlow
+ // extendAuthSessionFlow holds the pending PKCE flow created by
+ // RequestExtendAuthSession until WaitExtendAuthSession resolves it.
+ // Kept separate from oauthAuthFlow (which is reserved for the SSH
+ // JWT path) so a concurrent SSH auth doesn't clobber the session
+ // extend flow or vice versa.
+ extendAuthSessionFlow *auth.PendingFlow
+
+ // guardedConfigMu serializes a privilege check against the write it
+ // authorizes. Without it the two are separate steps over the same file, and a
+ // change that was allowed because the profile had the SSH server disabled
+ // could land after a concurrent privileged request enabled it.
+ guardedConfigMu sync.Mutex
mutex sync.Mutex
config *profilemanager.Config
@@ -87,7 +109,7 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
- lastProbe time.Time
+ probeThrottle *probeThrottle
persistSyncResponse bool
isSessionActive atomic.Bool
@@ -113,6 +135,13 @@ type Server struct {
updateManager *updater.Manager
jwtCache *jwtCache
+
+ // loginAttemptFn stands in for the Management login round trip. Tests set
+ // it to drive the login outcomes that need a server on the other end;
+ // production leaves it nil, and every login goes through loginAttempt.
+ loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
+
+ isLoginRequiredFn func(ctx context.Context) (bool, error)
}
type oauthAuthFlow struct {
@@ -135,6 +164,8 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
captureEnabled: captureEnabled,
networksDisabled: networksDisabled,
jwtCache: newJWTCache(),
+ extendAuthSessionFlow: auth.NewPendingFlow(),
+ probeThrottle: newProbeThrottle(probeThreshold),
}
agent := &serverAgent{s}
s.sleepHandler = sleephandler.New(agent)
@@ -152,12 +183,21 @@ func (s *Server) Start() error {
}
state := internal.CtxGetState(s.rootCtx)
+ // Every contextState.Set in the connect/login/server paths must push a
+ // SubscribeStatus snapshot, otherwise transitions that don't happen to
+ // be accompanied by a Mark{Management,Signal,...} call (e.g. plain
+ // StatusNeedsLogin after a PermissionDenied login, StatusLoginFailed
+ // after OAuth init failure, StatusIdle in the Login defer) leave the
+ // UI stuck on the previous status until the next unrelated peer event.
+ // Binding the recorder here means new state.Set callsites don't have
+ // to opt in individually.
+ state.SetOnChange(s.statusRecorder.NotifyStateChange)
if err := handlePanicLog(); err != nil {
log.Warnf("failed to redirect stderr: %v", err)
}
- if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
+ if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
log.Warnf(errRestoreResidualState, err)
}
@@ -214,7 +254,6 @@ func (s *Server) Start() error {
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
- s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled)
if s.sessionWatcher == nil {
s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder)
@@ -236,7 +275,7 @@ func (s *Server) Start() error {
s.clientRunningChan = make(chan struct{})
s.clientGiveUpChan = make(chan struct{})
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
- s.publishConfigChangedEvent("startup")
+ s.publishConfigChangedEvent(proto.MetadataSourceStartup)
return nil
}
@@ -253,6 +292,10 @@ func (s *Server) Start() error {
// "intent" (clientRunning) is maintained by the RPC handlers, not by this
// goroutine.
func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) {
+ // close(giveUpChan) MUST run on every exit path (DisableAutoConnect
+ // return, backoff.Retry return, panic) — Down() blocks for up to 5s
+ // waiting on this signal before flipping the state to Idle, and a
+ // missed close leaves Down() always hitting the timeout.
defer func() {
if giveUpChan != nil {
close(giveUpChan)
@@ -291,6 +334,15 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
runOperation := func() error {
err := s.connect(ctx, profileConfig, statusRecorder, runningChan)
if err != nil {
+ // PermissionDenied means the daemon transitioned to NeedsLogin
+ // inside connect(). Without backoff.Permanent the outer retry
+ // re-enters connect(), which resets the state to Connecting and
+ // makes the tray flicker between NeedsLogin and Connecting until
+ // the user logs in. Stop retrying and let the state stick.
+ if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied {
+ log.Debugf("run client connection exited with PermissionDenied, waiting for login")
+ return backoff.Permanent(err)
+ }
log.Debugf("run client connection exited with error: %v. Will retry in the background", err)
return err
}
@@ -325,7 +377,34 @@ func (s *Server) connectionGoroutineRunning() bool {
}
}
-// loginAttempt attempts to login using the provided information. it returns a status in case something fails
+// attemptLogin runs a login round trip against Management, or the stand-in a
+// test installed in place of it.
+func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
+ if s.loginAttemptFn != nil {
+ return s.loginAttemptFn(ctx, setupKey, jwtToken)
+ }
+ return s.loginAttempt(ctx, setupKey, jwtToken)
+}
+
+func (s *Server) isLoginRequired(ctx context.Context) (bool, error) {
+ if s.isLoginRequiredFn != nil {
+ return s.isLoginRequiredFn(ctx)
+ }
+
+ authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
+ if err != nil {
+ log.Errorf("failed to create auth client: %v", err)
+ return false, err
+ }
+ defer authClient.Close()
+
+ return authClient.IsLoginRequired(ctx)
+}
+
+// loginAttempt attempts to login using the provided information. It returns
+// StatusNeedsLogin when Management refused the peer's credentials and
+// StatusLoginFailed for every other failure, so callers can tell an
+// authentication decision apart from a login that never got made.
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
if err != nil {
@@ -351,6 +430,16 @@ func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (i
// Login uses setup key to prepare configuration for the daemon.
func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigRequest) (*proto.SetConfigResponse, error) {
+ // Privilege gate: refuse the parts of the request that would let a local
+ // user turn the root daemon into a root shell. Held across the write so the
+ // config cannot gain the SSH server between the decision and the update.
+ //
+ // Taken before s.mutex: authorizeAndPrepareLogin takes s.mutex while holding
+ // guardedConfigMu, so acquiring the two in the other order here would let a
+ // concurrent login deadlock the daemon.
+ s.guardedConfigMu.Lock()
+ defer s.guardedConfigMu.Unlock()
+
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -375,6 +464,14 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
return nil, err
}
+ stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username)
+ if err != nil {
+ return nil, err
+ }
+ if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil {
+ return nil, err
+ }
+
config, err := s.setConfigInputFromRequest(msg)
if err != nil {
return nil, err
@@ -425,7 +522,7 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
wgPort := int(*msg.WireguardPort)
config.WireguardPort = &wgPort
}
- if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != "" {
+ if msg.OptionalPreSharedKey != nil {
config.PreSharedKey = msg.OptionalPreSharedKey
}
@@ -463,7 +560,6 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
config.DisableFirewall = msg.DisableFirewall
config.BlockLANAccess = msg.BlockLanAccess
config.DisableNotifications = msg.DisableNotifications
- config.LazyConnectionEnabled = msg.LazyConnectionEnabled
config.BlockInbound = msg.BlockInbound
config.DisableIPv6 = msg.DisableIpv6
config.EnableSSHRoot = msg.EnableSSHRoot
@@ -502,22 +598,23 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
}
- s.mutex.Lock()
- if s.actCancel != nil {
- s.actCancel()
- }
- ctx, cancel := context.WithCancel(callerCtx)
-
- md, ok := metadata.FromIncomingContext(callerCtx)
- if ok {
- ctx = metadata.NewOutgoingContext(ctx, md)
+ activeProf, err := s.profileManager.GetActiveProfileState()
+ if err != nil {
+ log.Errorf("failed to get active profile state: %v", err)
+ return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
- s.actCancel = cancel
- s.mutex.Unlock()
-
- if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
- log.Warnf(errRestoreResidualState, err)
+ // Privilege gate: same restrictions as SetConfig, since LoginRequest can carry
+ // the same fields. It runs before anything here changes daemon state, so a
+ // refused login neither switches the profile nor cancels a login already in
+ // progress, and it reads the profile the request targets, which is the one the
+ // switch below would activate.
+ stored, err := s.storedLoginConfig(activeProf, msg)
+ if err != nil {
+ return nil, err
+ }
+ if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
+ return nil, err
}
state := internal.CtxGetState(s.rootCtx)
@@ -528,23 +625,16 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
}()
- activeProf, err := s.profileManager.GetActiveProfileState()
+ ctx, activeProf, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf)
if err != nil {
- log.Errorf("failed to get active profile state: %v", err)
- return nil, fmt.Errorf("failed to get active profile state: %w", err)
- }
-
- if msg.ProfileName != nil {
- if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
- log.Errorf("failed to switch profile: %v", err)
- return nil, err
+ // The RPC boundary is where this gets recorded: nothing logs handler
+ // errors for us, and a caller that retries would otherwise leave no
+ // trace in the daemon log. A refusal is skipped because the gate has
+ // already logged the decision, with the caller's identity.
+ if gstatus.Code(err) != codes.PermissionDenied {
+ log.Errorf("failed to prepare login: %v", err)
}
- }
-
- activeProf, err = s.profileManager.GetActiveProfileState()
- if err != nil {
- log.Errorf("failed to get active profile state: %v", err)
- return nil, fmt.Errorf("failed to get active profile state: %w", err)
+ return nil, err
}
log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
@@ -558,11 +648,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
s.mutex.Unlock()
- if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil {
- log.Errorf("failed to persist login overrides: %v", err)
- return nil, fmt.Errorf("persist login overrides: %w", err)
- }
-
config, _, err := s.getConfig(activeProf)
if err != nil {
log.Errorf("failed to get active profile config: %v", err)
@@ -572,13 +657,23 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
s.config = config
s.mutex.Unlock()
- if _, err := s.loginAttempt(ctx, "", ""); err == nil {
+ // A probe that errors leaves the login undecided: Management unreachable, a
+ // restart mid-request, an internal error. Those are returned for the caller
+ // to retry, because turning them into an SSO prompt asks the user to solve
+ // something that is not theirs to solve, and a browser login cannot succeed
+ // while Management is unreachable anyway. Only Management refusing the
+ // peer's key is a decision, and IsLoginRequired reports that as
+ // needsLogin=true rather than an error.
+ needsLogin, err := s.isLoginRequired(ctx)
+ if err != nil {
+ state.Set(internal.StatusLoginFailed)
+ return nil, err
+ }
+ if !needsLogin {
state.Set(internal.StatusIdle)
return &proto.LoginResponse{}, nil
}
- state.Set(internal.StatusConnecting)
-
if msg.SetupKey == "" {
hint := ""
if msg.Hint != nil {
@@ -593,6 +688,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) {
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
log.Debugf("using previous oauth flow info")
+ state.Set(internal.StatusNeedsLogin)
return &proto.LoginResponse{
NeedsSSOLogin: true,
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
@@ -629,7 +725,12 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}, nil
}
- if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
+ // Setup-key path: we are about to dial Management with the key, so the
+ // Connecting paint is meaningful here — unlike the SSO branch above,
+ // which returns NeedsLogin and parks on the browser leg.
+ state.Set(internal.StatusConnecting)
+
+ if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
state.Set(loginStatus)
return nil, err
}
@@ -637,8 +738,43 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
return &proto.LoginResponse{}, nil
}
-// WaitSSOLogin uses the userCode to validate the TokenInfo and
-// waits for the user to continue with the login on a browser
+// WaitSSOLogin validates the supplied userCode against the in-flight OAuth
+// device/PKCE flow and blocks until the user finishes the browser leg.
+//
+// The daemon holds StatusNeedsLogin for the whole browser wait (set on
+// entry): the login is not done until the token returns, so a client that
+// (re)attaches mid-wait — a restarted UI, a second `netbird up` — reads
+// "login required" and offers the affordance, instead of a Connecting that
+// never resolves. The wait is also tied to the caller's context (see the
+// goroutine below), so a client that goes away cancels the wait instead of
+// orphaning it on rootCtx until the device-code window expires.
+//
+// State transitions on exit:
+//
+// ┌──────────────────────────────────────────┬──────────────────────────────────┐
+// │ Outcome │ contextState │
+// ├──────────────────────────────────────────┼──────────────────────────────────┤
+// │ Success → loginAttempt ok │ NeedsLogin held; the caller's Up │
+// │ │ drives Connecting → Connected │
+// │ Success → loginAttempt → still-NeedsLogin│ StatusNeedsLogin (loginAttempt) │
+// │ Success → loginAttempt error │ StatusLoginFailed (loginAttempt) │
+// │ UserCode mismatch │ StatusLoginFailed │
+// │ WaitToken: context.Canceled │ NeedsLogin held. Caller gone │
+// │ (caller went away — UI restart / │ (UI/CLI) → a fresh client │
+// │ Ctrl+C — or internal abort: profile │ shows the login affordance; │
+// │ switch / app quit / another │ internal aborts are │
+// │ WaitSSOLogin via actCancel/waitCancel) │ overwritten by the next Up. │
+// │ WaitToken: context.DeadlineExceeded │ StatusNeedsLogin │
+// │ (OAuth device-code window expired │ (retryable; the UI's "Connect" │
+// │ while waiting on the browser leg) │ re-enters the Login flow) │
+// │ WaitToken: any other error │ StatusLoginFailed │
+// │ (access_denied, expired_token, HTTP │ (genuine auth/IO failure; │
+// │ failure, token validation rejection) │ surfaced verbatim to caller) │
+// └──────────────────────────────────────────┴──────────────────────────────────┘
+//
+// The defer still applies a StatusIdle fallback for the early
+// oauth-flow-not-initialized return (before the entry Set), so a half state
+// doesn't leak when there is nothing to wait on.
func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLoginRequest) (*proto.WaitSSOLoginResponse, error) {
s.mutex.Lock()
if s.actCancel != nil {
@@ -646,6 +782,21 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
}
ctx, cancel := context.WithCancel(s.rootCtx)
+ // Tie the in-flight browser wait to the caller. ctx stays rooted in
+ // rootCtx so CtxGetState resolves the daemon's contextState, but if the
+ // UI window or CLI that drove the login goes away mid-flow (restart,
+ // Ctrl+C) the gRPC callerCtx cancels and we cancel the wait instead of
+ // orphaning it on rootCtx until the OAuth device-code window expires.
+ // The goroutine exits as soon as either context completes, so it can't
+ // outlive the RPC.
+ go func() {
+ select {
+ case <-callerCtx.Done():
+ cancel()
+ case <-ctx.Done():
+ }
+ }()
+
md, ok := metadata.FromIncomingContext(callerCtx)
if ok {
ctx = metadata.NewOutgoingContext(ctx, md)
@@ -671,7 +822,11 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
}
}()
- state.Set(internal.StatusConnecting)
+ // Hold NeedsLogin for the whole browser wait — the login is not done
+ // until the token returns, so a client that (re)attaches mid-wait
+ // (restarted UI, second `netbird up`) reads "login required" and offers
+ // the affordance instead of a Connecting that never resolves.
+ state.Set(internal.StatusNeedsLogin)
s.mutex.Lock()
flowInfo := s.oauthAuthFlow.info
@@ -698,7 +853,30 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
s.mutex.Lock()
s.oauthAuthFlow.expiresAt = time.Now()
s.mutex.Unlock()
- state.Set(internal.StatusLoginFailed)
+ switch {
+ case errors.Is(err, context.Canceled):
+ // External abort. If our caller cancelled (the client closed
+ // the browser-login popup, or the UI went away — callerCtx is
+ // done), clear the abandoned OAuth flow so a fresh Login starts
+ // a new device code instead of reusing this one. The entry
+ // NeedsLogin stays in place, so a reattaching client shows the
+ // login affordance. An internal abort (actCancel from a new
+ // Login/WaitSSOLogin, callerCtx still live) leaves the flow for
+ // the new owner — don't clobber it.
+ if callerCtx.Err() != nil {
+ s.mutex.Lock()
+ s.oauthAuthFlow = oauthAuthFlow{}
+ s.mutex.Unlock()
+ }
+ case errors.Is(err, context.DeadlineExceeded):
+ // OAuth device-code window expired with no user action.
+ // Retryable — leave the daemon in NeedsLogin so the UI
+ // keeps the Login affordance instead of reading as a
+ // hard failure.
+ state.Set(internal.StatusNeedsLogin)
+ default:
+ state.Set(internal.StatusLoginFailed)
+ }
log.Errorf("waiting for browser login failed: %v", err)
return nil, err
}
@@ -707,11 +885,12 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
s.oauthAuthFlow.expiresAt = time.Now()
s.mutex.Unlock()
- if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
+ if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
state.Set(loginStatus)
return nil, err
}
+ log.Infof("SSO login flow finished, returning success to caller")
return &proto.WaitSSOLoginResponse{
Email: tokenInfo.Email,
}, nil
@@ -719,6 +898,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
// Up starts engine work in the daemon.
func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) {
+ log.Infof("up request received")
s.mutex.Lock()
// clientRunning is the daemon-intent flag (set by previous Up/Start, cleared
// by Down). connectionGoroutineRunning() reports whether the previous retry-loop
@@ -740,7 +920,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
return s.waitForUp(callerCtx)
}
- if err := restoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil {
+ if err := RestoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil {
log.Warnf(errRestoreResidualState, err)
}
@@ -755,6 +935,22 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
return nil, err
}
+ // StatusNeedsLogin is a legitimate fresh-start entry state: a successful
+ // WaitSSOLogin deliberately leaves the daemon in NeedsLogin (the login is
+ // done, the token is in hand, but the engine hasn't been brought up yet —
+ // see WaitSSOLogin's state-transition table). The same holds after a
+ // mid-session expiry tore the engine down (clientRunning == false) and the
+ // user re-authenticated. In both cases the caller's Up is expected to drive
+ // the connection; treat NeedsLogin like Idle and reset to Idle so the
+ // engine's own StatusConnecting → StatusConnected progression starts from a
+ // clean slate. Without this, the first Up after an SSO login fails with
+ // "up already in progress" and the user has to trigger Up a second time
+ // (CLI: re-run `netbird up`; GUI: click Connect again).
+ if status == internal.StatusNeedsLogin {
+ status = internal.StatusIdle
+ state.Set(internal.StatusIdle)
+ }
+
if status != internal.StatusIdle {
s.mutex.Unlock()
return nil, fmt.Errorf("up already in progress: current status %s", status)
@@ -817,9 +1013,12 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
s.clientGiveUpChan = make(chan struct{})
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
- s.publishConfigChangedEvent("up_rpc")
+ s.publishConfigChangedEvent(proto.MetadataSourceUpRPC)
s.mutex.Unlock()
+ if msg.GetAsync() {
+ return &proto.UpResponse{}, nil
+ }
return s.waitForUp(callerCtx)
}
@@ -843,6 +1042,63 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error)
}
}
+// storedProfileConfig loads the on-disk config of the profile a request
+// targets, so a privileged-change decision can be made against the values the
+// profile currently holds. A profile that has no config file yet yields nil,
+// which every caller must read as "nothing enabled yet".
+func (s *Server) storedProfileConfig(handle, username string) (*profilemanager.Config, error) {
+ resolved, err := s.resolveProfileHandle(handle, username)
+ if err != nil {
+ return nil, err
+ }
+
+ path := resolved.Path
+ if path == "" {
+ path = profilemanager.DefaultConfigPath
+ }
+
+ return s.storedConfigAtPath(path)
+}
+
+// storedLoginConfig loads the on-disk config of the profile a login request
+// targets: the one it names, or the active one when it names none. Used to decide
+// a privileged change before the request is allowed to switch profiles.
+func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) (*profilemanager.Config, error) {
+ if msg.ProfileName == nil {
+ cfgPath, err := activeProf.FilePath()
+ if err != nil {
+ return nil, fmt.Errorf("active profile file path: %w", err)
+ }
+ return s.storedConfigAtPath(cfgPath)
+ }
+
+ // Mirrors switchProfileIfNeeded: the default profile resolves without a
+ // username, so this reads the same profile the switch would activate.
+ handle := *msg.ProfileName
+ username := ""
+ if handle != profilemanager.DefaultProfileName {
+ username = msg.GetUsername()
+ }
+ return s.storedProfileConfig(handle, username)
+}
+
+// storedConfigAtPath reads a profile config file, yielding nil when it does not
+// exist yet.
+func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) {
+ if _, err := os.Stat(path); err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil //nolint:nilnil
+ }
+ return nil, fmt.Errorf("stat profile config: %w", err)
+ }
+
+ cfg, err := profilemanager.GetConfig(path)
+ if err != nil {
+ return nil, fmt.Errorf("read profile config: %w", err)
+ }
+ return cfg, nil
+}
+
// resolveProfileHandle resolves a wire-level profile handle (display
// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC
// status errors so handlers can return them directly.
@@ -929,6 +1185,10 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
s.config = config
+ if msg != nil && msg.ProfileName != nil {
+ s.publishProfileListChanged(*msg.ProfileName)
+ }
+
return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil
}
@@ -940,28 +1200,45 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
if err := s.cleanupConnection(); err != nil {
s.mutex.Unlock()
- // todo review to update the status in case any type of error
+ if errors.Is(err, ErrServiceNotUp) {
+ log.Debugf("Down called while service not up: %v", err)
+ return nil, err
+ }
log.Errorf("failed to shut down properly: %v", err)
return nil, err
}
- state := internal.CtxGetState(s.rootCtx)
- state.Set(internal.StatusIdle)
-
s.mutex.Unlock()
// Wait for the connectWithRetryRuns goroutine to finish with a short timeout.
// This prevents the goroutine from setting ErrResetConnection after Down() returns.
- // The giveUpChan is closed at the end of connectWithRetryRuns.
+ // The giveUpChan is closed by the goroutine's deferred cleanup (see
+ // connectWithRetryRuns) on every exit path. A timeout here typically
+ // means the goroutine is still wedged inside a slow teardown step.
if giveUpChan != nil {
select {
case <-giveUpChan:
- log.Debugf("client goroutine finished successfully")
+ log.Debugf("client goroutine finished, giveUpChan closed")
case <-time.After(5 * time.Second):
log.Warnf("timeout waiting for client goroutine to finish, proceeding anyway")
}
}
+ // Set Idle only after the retry goroutine has exited (or timed out).
+ // Setting it earlier races with the goroutine's own Set(StatusConnecting)
+ // at the top of each retry attempt, which would leave the snapshot
+ // stuck at Connecting long after the user asked to disconnect.
+ internal.CtxGetState(s.rootCtx).Set(internal.StatusIdle)
+
+ // Clear stale management/signal errors so the next Up() (typically for a
+ // different profile) starts with a clean status snapshot. Without this,
+ // a managementError left over from a LoginFailed cycle persists in the
+ // statusRecorder and appears in the new profile's initial
+ // SubscribeStatus snapshot, making the new profile look like it also
+ // failed to log in.
+ s.statusRecorder.MarkManagementDisconnected(nil)
+ s.statusRecorder.MarkSignalDisconnected(nil)
+
return &proto.DownResponse{}, nil
}
@@ -993,9 +1270,13 @@ func (s *Server) cleanupConnection() error {
return nil
}
+ // TODO: consider calling s.connectClient.Stop() instead of engine.Stop().
+ // actCancel() lets the run loop stop the engine too, so both stop it
+ // concurrently; ConnectClient.Stop cancels and waits for the run loop,
+ // making the run loop the sole owner of engine shutdown.
if engine != nil {
if err := engine.Stop(); err != nil {
- return err
+ log.Errorf("failed to stop engine during cleanup: %v", err)
}
}
@@ -1035,6 +1316,12 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
if err := s.logoutFromProfile(ctx, resolved); err != nil {
log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
+ // A refused deregistration is already a status error carrying the reason
+ // and the command to run; rewrapping it as Internal would flatten both
+ // into a gRPC dump for the user.
+ if _, isStatus := gstatus.FromError(err); isStatus {
+ return nil, err
+ }
return nil, gstatus.Errorf(codes.Internal, "logout: %v", err)
}
@@ -1156,6 +1443,13 @@ func (s *Server) sendLogoutRequest(ctx context.Context) error {
}
func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profilemanager.Config) error {
+ // Privilege gate: deregistering frees this machine's key to be registered
+ // against another management server, which is only restricted while the SSH
+ // server makes that a privilege handover.
+ if err := requirePrivilegeForDeregistration(ctx, config); err != nil {
+ return err
+ }
+
key, err := wgtypes.ParseKey(config.PrivateKey)
if err != nil {
return fmt.Errorf("parse private key: %w", err)
@@ -1172,7 +1466,19 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil
}
}()
- return mgmClient.Logout()
+ if err := mgmClient.Logout(); err != nil {
+ // The peer is already gone from the management server (e.g. deleted
+ // from the dashboard). The logout's goal — deregistering this peer —
+ // is therefore already satisfied, so treat NotFound as success rather
+ // than blocking the logout/profile-removal flow.
+ if logoutPeerGone(err) {
+ log.Infof("peer already removed from management server, treating logout as successful")
+ return nil
+ }
+ return err
+ }
+
+ return nil
}
// Status returns the daemon status
@@ -1225,9 +1531,24 @@ func (s *Server) Status(
}
}
- status, err := internal.CtxGetState(s.rootCtx).Status()
+ return s.buildStatusResponse(ctx, msg)
+}
+
+// buildStatusResponse composes a StatusResponse from the current daemon
+// state. Shared between the unary Status RPC and the SubscribeStatus
+// stream so both paths return identical snapshots. ctx scopes the health
+// probe runProbes may trigger — a caller that disconnects cancels it.
+func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusRequest) (*proto.StatusResponse, error) {
+ state := internal.CtxGetState(s.rootCtx)
+ status, err := state.Status()
if err != nil {
- return nil, err
+ // state.Status() blanks the status when err is set (e.g. management
+ // retry loop wrapped a connection error). The underlying status is
+ // still meaningful and the failure is already surfaced via
+ // FullStatus.ManagementState.Error, so don't propagate err — that
+ // would tear down the SubscribeStatus stream and cause the UI to
+ // mark the daemon as unreachable on every retry.
+ status = state.CurrentStatus()
}
if status == internal.StatusNeedsLogin && s.isSessionActive.Load() {
@@ -1238,15 +1559,20 @@ func (s *Server) Status(
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
+ if deadline := s.statusRecorder.GetSessionExpiresAt(); !deadline.IsZero() {
+ statusResponse.SessionExpiresAt = timestamppb.New(deadline)
+ }
+
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
if msg.GetFullPeerStatus {
- s.runProbes(msg.ShouldRunProbes)
+ s.runProbes(ctx, msg.ShouldRunProbes)
fullStatus := s.statusRecorder.GetFullStatus()
pbFullStatus := fullStatus.ToProto()
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
pbFullStatus.SshServerState = s.getSSHServerState()
+ pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision()
statusResponse.FullStatus = pbFullStatus
}
@@ -1397,8 +1723,8 @@ func (s *Server) RequestJWTAuth(
hint = profilemanager.GetLoginHint()
}
- isDesktop := isUnixRunningDesktop()
- oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint)
+ // the daemon has no graphical session of its own, only the caller can answer this
+ oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint)
if err != nil {
return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
}
@@ -1467,6 +1793,154 @@ func (s *Server) WaitJWTToken(
}, nil
}
+// RequestExtendAuthSession initiates the SSO session-extension flow and
+// returns the verification URI the UI should open. The flow state is held
+// in s.extendAuthSessionFlow until WaitExtendAuthSession resolves it.
+func (s *Server) RequestExtendAuthSession(
+ ctx context.Context,
+ msg *proto.RequestExtendAuthSessionRequest,
+) (*proto.RequestExtendAuthSessionResponse, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ s.mutex.Lock()
+ config := s.config
+ connectClient := s.connectClient
+ s.mutex.Unlock()
+
+ if config == nil {
+ return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not configured")
+ }
+ if connectClient == nil {
+ return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
+ }
+ if connectClient.Engine() == nil {
+ return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect")
+ }
+
+ hint := ""
+ if msg.Hint != nil {
+ hint = *msg.Hint
+ }
+ if hint == "" {
+ hint = profilemanager.GetLoginHint()
+ }
+
+ // the daemon has no graphical session of its own, only the caller can answer this
+ oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint)
+ if err != nil {
+ return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
+ }
+
+ authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
+ if err != nil {
+ return nil, gstatus.Errorf(codes.Internal, "failed to request auth info: %v", err)
+ }
+
+ s.extendAuthSessionFlow.Set(oAuthFlow, authInfo)
+
+ return &proto.RequestExtendAuthSessionResponse{
+ VerificationURI: authInfo.VerificationURI,
+ VerificationURIComplete: authInfo.VerificationURIComplete,
+ UserCode: authInfo.UserCode,
+ DeviceCode: authInfo.DeviceCode,
+ ExpiresIn: int64(authInfo.ExpiresIn),
+ }, nil
+}
+
+// WaitExtendAuthSession blocks until the user completes the SSO step
+// initiated by RequestExtendAuthSession, then forwards the resulting JWT
+// to the management server's ExtendAuthSession RPC. The returned deadline
+// is also applied locally via the engine so SubscribeStatus consumers see
+// the refreshed state.
+func (s *Server) WaitExtendAuthSession(
+ ctx context.Context,
+ req *proto.WaitExtendAuthSessionRequest,
+) (*proto.WaitExtendAuthSessionResponse, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ oAuthFlow, authInfo, ok := s.extendAuthSessionFlow.Get()
+
+ s.mutex.Lock()
+ connectClient := s.connectClient
+ s.mutex.Unlock()
+
+ if !ok || authInfo.DeviceCode != req.DeviceCode {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "invalid device code or no active extend-session flow")
+ }
+
+ // Preempt a previous WaitExtendAuthSession (e.g. when the tray
+ // notification and the about-to-expire dialog both start a flow on
+ // the same deadline). The older waiter exits via context.Canceled;
+ // the new one takes over the IdP poll.
+ s.extendAuthSessionFlow.CancelWait()
+
+ waitCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ s.extendAuthSessionFlow.SetWaitCancel(cancel)
+
+ tokenInfo, err := oAuthFlow.WaitToken(waitCtx, authInfo)
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ return nil, gstatus.Errorf(codes.Canceled, "extend-session flow preempted")
+ }
+ return nil, gstatus.Errorf(codes.Internal, "failed to obtain JWT token: %v", err)
+ }
+
+ // Clear pending flow before talking to mgm so a retry can re-initiate.
+ s.extendAuthSessionFlow.Clear()
+
+ if connectClient == nil {
+ return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
+ }
+ engine := connectClient.Engine()
+ if engine == nil {
+ return nil, gstatus.Errorf(codes.FailedPrecondition, "engine is not initialised")
+ }
+
+ deadline, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse())
+ if err != nil {
+ // Log the full wrapped chain, but return only the innermost gRPC
+ // status (code + clean desc) so the UI shows the root cause, not
+ // the daemon's wrapping layers.
+ log.Errorf("management ExtendAuthSession failed: %v", err)
+ if st := innermostStatus(err); st != nil {
+ return nil, gstatus.Error(st.Code(), st.Message())
+ }
+ return nil, gstatus.Errorf(codes.Internal, "%v", err)
+ }
+
+ resp := &proto.WaitExtendAuthSessionResponse{}
+ if !deadline.IsZero() {
+ resp.SessionExpiresAt = timestamppb.New(deadline)
+ }
+ return resp, nil
+}
+
+// DismissSessionWarning forwards the user's "Dismiss" click on the
+// T-WarningLead notification down to the engine's sessionWatcher so the
+// T-FinalWarningLead fallback is suppressed for the current deadline.
+// Best-effort: when the client/engine is not yet running the call is a
+// successful no-op (the watcher has no deadline to dismiss anyway).
+func (s *Server) DismissSessionWarning(
+ _ context.Context,
+ _ *proto.DismissSessionWarningRequest,
+) (*proto.DismissSessionWarningResponse, error) {
+ s.mutex.Lock()
+ connectClient := s.connectClient
+ s.mutex.Unlock()
+ if connectClient == nil {
+ return &proto.DismissSessionWarningResponse{}, nil
+ }
+ if engine := connectClient.Engine(); engine != nil {
+ engine.DismissSessionWarning()
+ }
+ return &proto.DismissSessionWarningResponse{}, nil
+}
+
// ExposeService exposes a local port via the NetBird reverse proxy.
func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error {
s.mutex.Lock()
@@ -1526,14 +2000,7 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon
return nil
}
-func isUnixRunningDesktop() bool {
- if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
- return false
- }
- return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
-}
-
-func (s *Server) runProbes(waitForProbeResult bool) {
+func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) {
if s.connectClient == nil {
return
}
@@ -1543,15 +2010,7 @@ func (s *Server) runProbes(waitForProbeResult bool) {
return
}
- if time.Since(s.lastProbe) > probeThreshold {
- if engine.RunHealthProbes(waitForProbeResult) {
- s.lastProbe = time.Now()
- }
- } else {
- if err := s.statusRecorder.RefreshWireGuardStats(); err != nil {
- log.Debugf("failed to refresh WireGuard stats: %v", err)
- }
- }
+ s.probeThrottle.Run(ctx, engine, s.statusRecorder, waitForProbeResult)
}
// GetConfig of the daemon.
@@ -1643,7 +2102,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
ServerSSHAllowed: *cfg.ServerSSHAllowed,
RosenpassEnabled: cfg.RosenpassEnabled,
RosenpassPermissive: cfg.RosenpassPermissive,
- LazyConnectionEnabled: cfg.LazyConnectionEnabled,
BlockInbound: cfg.BlockInbound,
DisableNotifications: disableNotifications,
NetworkMonitor: networkMonitor,
@@ -1681,6 +2139,8 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
return nil, fmt.Errorf("failed to create profile: %w", err)
}
+ s.publishProfileListChanged(msg.ProfileName)
+
return &proto.AddProfileResponse{Id: created.ID.String()}, nil
}
@@ -1707,6 +2167,8 @@ func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequ
return nil, fmt.Errorf("failed to rename profile: %w", err)
}
+ s.publishProfileListChanged(msg.NewProfileName)
+
return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil
}
@@ -1729,7 +2191,10 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
}
if err := s.logoutFromProfile(ctx, resolved); err != nil {
- log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err)
+ // Deregistration is best-effort here: the local profile is removed
+ // either way, so an unprivileged caller leaves the peer registered on
+ // the management server rather than being blocked from removing it.
+ log.Warnf("removing profile %s locally without deregistering it: %v", resolved.ID, err)
}
if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil {
@@ -1737,9 +2202,51 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
return nil, fmt.Errorf("failed to remove profile: %w", err)
}
+ s.publishProfileListChanged(msg.ProfileName)
+
return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil
}
+// publishProfileListChanged nudges the desktop UI to refresh its profile list
+// after a CLI-driven add/remove. The daemon exposes no dedicated
+// profile-changed RPC event, and a profile add/remove doesn't move the
+// connection status, so the UI's SubscribeStatus path never fires for it (and
+// the tray's status-string guard would swallow it anyway). Instead we publish
+// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent
+// recognises the metadata "kind" marker and translates it into its internal
+// profile-changed signal that both the tray menu and the React profile views
+// already subscribe to (see proto.MetadataKindProfileListChanged, recognised in
+// client/ui/services/daemon_feed.go). userMessage is intentionally empty so this
+// stays a silent refresh signal rather than a user-facing notification.
+func (s *Server) publishProfileListChanged(profileName string) {
+ s.statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ "Profile list changed",
+ "",
+ map[string]string{proto.MetadataKindKey: proto.MetadataKindProfileListChanged, proto.MetadataProfileKey: profileName},
+ )
+}
+
+// publishLogLevelChanged signals the desktop UI that the daemon log level
+// changed, so it can attach/detach its rotated gui-client.log. Like
+// publishProfileListChanged, this rides the SubscribeEvents stream as a marked
+// INFO/SYSTEM event (kind "log-level-changed", level the lowercase logrus
+// name); the UI's dispatchSystemEvent recognises the marker and routes it to
+// the logging toggle instead of an OS toast (userMessage is empty so it stays
+// a silent control signal). The "level" value matches log.Level.String()
+// (e.g. "debug", "info") so the UI can parse it directly. See
+// proto.MetadataKindLogLevelChanged, recognised in client/ui/services/daemon_feed.go.
+func (s *Server) publishLogLevelChanged(level string) {
+ s.statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ "Log level changed",
+ "",
+ map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level},
+ )
+}
+
// ListProfiles lists all profiles in the daemon.
func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) {
s.mutex.Lock()
@@ -1811,11 +2318,33 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
DisableProfiles: s.checkProfilesDisabled(),
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
DisableNetworks: s.checkNetworksDisabled(),
+ DisableAdvancedView: s.checkDisableAdvancedView(),
}
return features, nil
}
+// WailsUIReady is a no-op the Wails UI probes at startup; merely answering it
+// (rather than returning Unimplemented) tells the UI this daemon is new enough.
+func (s *Server) WailsUIReady(context.Context, *proto.WailsUIReadyRequest) (*proto.WailsUIReadyResponse, error) {
+ return &proto.WailsUIReadyResponse{}, nil
+}
+
+// checkDisableAdvancedView reports the MDM-policy directive for the
+// upcoming UI's advanced-view section. Tristate: returns nil when no
+// MDM directive is set so the UI applies its own default; returns
+// &true / &false when MDM explicitly enforces. No CLI flag backs
+// this feature — MDM is the sole source.
+func (s *Server) checkDisableAdvancedView() *bool {
+ if s.config == nil {
+ return nil
+ }
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableAdvancedView); ok {
+ return &v
+ }
+ return nil
+}
+
func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}) error {
log.Tracef("running client connection")
client := internal.NewConnectClient(ctx, config, statusRecorder)
@@ -1962,6 +2491,69 @@ func sendTerminalNotification() error {
// persistLoginOverrides writes management URL and pre-shared key from a LoginRequest to the
// active profile config so that subsequent reads pick them up. Empty/nil values are ignored.
+// afterLoginPreCheck is a seam for tests to run a concurrent config change
+// between Login's first privilege check and the authoritative one.
+var afterLoginPreCheck func()
+
+// authorizeAndPrepareLogin makes the authoritative privilege decision for a login
+// and, when it passes, carries out every state change that decision authorizes:
+// cancelling an login already in progress, switching to the requested profile, and
+// persisting the config overrides the request carries.
+//
+// All of it happens under guardedConfigMu, which SetConfig also holds across its
+// own check and write. Login's earlier check refuses the ordinary case before any
+// of this is reached; this one exists because that check is not synchronized
+// against a concurrent privileged request that enables the SSH server, and a
+// caller refused here must not have cancelled or switched anything either.
+func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, error) {
+ if afterLoginPreCheck != nil {
+ afterLoginPreCheck()
+ }
+
+ s.guardedConfigMu.Lock()
+ defer s.guardedConfigMu.Unlock()
+
+ stored, err := s.storedLoginConfig(activeProf, msg)
+ if err != nil {
+ return nil, nil, err
+ }
+ if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
+ return nil, nil, err
+ }
+
+ s.mutex.Lock()
+ if s.actCancel != nil {
+ s.actCancel()
+ }
+ ctx, cancel := context.WithCancel(callerCtx)
+ if md, ok := metadata.FromIncomingContext(callerCtx); ok {
+ ctx = metadata.NewOutgoingContext(ctx, md)
+ }
+ s.actCancel = cancel
+ s.mutex.Unlock()
+
+ if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
+ log.Warnf(errRestoreResidualState, err)
+ }
+
+ if msg.ProfileName != nil {
+ if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
+ return nil, nil, fmt.Errorf("switch profile: %w", err)
+ }
+ }
+
+ activeProf, err = s.profileManager.GetActiveProfileState()
+ if err != nil {
+ return nil, nil, fmt.Errorf("active profile state: %w", err)
+ }
+
+ if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil {
+ return nil, nil, fmt.Errorf("persist login overrides: %w", err)
+ }
+
+ return ctx, activeProf, nil
+}
+
func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error {
if preSharedKey != nil && *preSharedKey == "" {
preSharedKey = nil
@@ -1985,3 +2577,28 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage
}
return nil
}
+
+// logoutPeerGone reports whether a management Logout failed because the peer
+// no longer exists server-side (gRPC NotFound), walking the wrap chain since
+// the client wraps the gRPC status with fmt.Errorf.
+func logoutPeerGone(err error) bool {
+ for e := err; e != nil; e = errors.Unwrap(e) {
+ if s, ok := gstatus.FromError(e); ok && s.Code() == codes.NotFound {
+ return true
+ }
+ }
+ return false
+}
+
+// innermostStatus walks the wrap chain and returns the deepest gRPC status,
+// or nil when none is present. gstatus.FromError does not unwrap, so a status
+// wrapped with fmt.Errorf %w would otherwise be missed.
+func innermostStatus(err error) *gstatus.Status {
+ var found *gstatus.Status
+ for e := err; e != nil; e = errors.Unwrap(e) {
+ if s, ok := gstatus.FromError(e); ok {
+ found = s
+ }
+ }
+ return found
+}
diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go
new file mode 100644
index 000000000..0366ccb31
--- /dev/null
+++ b/client/server/server_privileged_test.go
@@ -0,0 +1,252 @@
+//go:build privileged
+
+package server
+
+import (
+ "context"
+ "net"
+ "os/user"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "go.uber.org/mock/gomock"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
+
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server/job"
+
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ "github.com/netbirdio/netbird/management/server/groups"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/management/server"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbcache "github.com/netbirdio/netbird/management/server/cache"
+ "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/telemetry"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ signalServer "github.com/netbirdio/netbird/signal/server"
+)
+
+var (
+ kaep = keepalive.EnforcementPolicy{
+ MinTime: 15 * time.Second,
+ PermitWithoutStream: true,
+ }
+
+ kasp = keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Second,
+ MaxConnectionAgeGrace: 5 * time.Second,
+ Time: 5 * time.Second,
+ Timeout: 2 * time.Second,
+ }
+)
+
+// TestConnectStopsRetryOnPermissionDenied verifies connectWithRetryRuns stops after a single login
+// attempt on PermissionDenied, despite the fast retry config that would otherwise drive several.
+func TestConnectStopsRetryOnPermissionDenied(t *testing.T) {
+ // Redirect profile paths to a temp dir so the test does not need root.
+ tempDir := t.TempDir()
+ origDefaultProfileDir := profilemanager.DefaultConfigPathDir
+ origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
+ origDefaultConfigPath := profilemanager.DefaultConfigPath
+ profilemanager.ConfigDirOverride = tempDir
+ profilemanager.DefaultConfigPathDir = tempDir
+ profilemanager.ActiveProfileStatePath = filepath.Join(tempDir, "active_profile.json")
+ profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
+ t.Cleanup(func() {
+ profilemanager.DefaultConfigPathDir = origDefaultProfileDir
+ profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
+ profilemanager.DefaultConfigPath = origDefaultConfigPath
+ profilemanager.ConfigDirOverride = ""
+ })
+
+ // start the signal server
+ _, signalAddr, err := startSignal(t)
+ if err != nil {
+ t.Fatalf("failed to start signal server: %v", err)
+ }
+
+ counter := 0
+ // start the management server
+ _, mgmtAddr, err := startManagement(t, signalAddr, &counter)
+ if err != nil {
+ t.Fatalf("failed to start management server: %v", err)
+ }
+
+ ctx := internal.CtxInitState(context.Background())
+
+ ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second))
+ defer cancel()
+ // create new server
+ ic := profilemanager.ConfigInput{
+ ManagementURL: "http://" + mgmtAddr,
+ ConfigPath: t.TempDir() + "/test-profile.json",
+ }
+
+ config, err := profilemanager.UpdateOrCreateConfig(ic)
+ if err != nil {
+ t.Fatalf("failed to create config: %v", err)
+ }
+
+ currUser, err := user.Current()
+ require.NoError(t, err)
+
+ pm := profilemanager.ServiceManager{}
+ err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
+ ID: "test-profile",
+ Username: currUser.Username,
+ })
+ if err != nil {
+ t.Fatalf("failed to set active profile state: %v", err)
+ }
+
+ s := New(ctx, "debug", "", false, false, false, false)
+
+ s.config = config
+
+ s.statusRecorder = peer.NewRecorder(config.ManagementURL.String())
+ t.Setenv(retryInitialIntervalVar, "1s")
+ t.Setenv(maxRetryIntervalVar, "2s")
+ t.Setenv(maxRetryTimeVar, "5s")
+ t.Setenv(retryMultiplierVar, "1")
+
+ s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
+ if counter != 1 {
+ t.Fatalf("expected exactly 1 login attempt (PermissionDenied must stop the retry loop), got %d", counter)
+ }
+}
+
+type mockServer struct {
+ mgmtProto.ManagementServiceServer
+ counter *int
+}
+
+func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
+ *m.counter++
+ return m.ManagementServiceServer.Login(ctx, req)
+}
+
+func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) {
+ t.Helper()
+ dataDir := t.TempDir()
+
+ config := &config.Config{
+ Stuns: []*config.Host{},
+ TURNConfig: &config.TURNConfig{},
+ Signal: &config.Host{
+ Proto: "http",
+ URI: signalAddr,
+ },
+ Datadir: dataDir,
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+ store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir)
+ if err != nil {
+ return nil, "", err
+ }
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+ if err != nil {
+ return nil, "", err
+ }
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ permissionsManagerMock := permissions.NewMockManager(ctrl)
+ peersManager := peers.NewManager(store, permissionsManagerMock)
+ settingsManagerMock := settings.NewMockManager(ctrl)
+
+ jobManager := job.NewJobManager(nil, store, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
+
+ metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+ require.NoError(t, err)
+
+ settingsMockManager := settings.NewMockManager(ctrl)
+ groupsManager := groups.NewManagerMock()
+
+ requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
+ peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
+ networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
+ if err != nil {
+ return nil, "", err
+ }
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ mock := &mockServer{
+ ManagementServiceServer: mgmtServer,
+ counter: counter,
+ }
+ mgmtProto.RegisterManagementServiceServer(s, mock)
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+func startSignal(t *testing.T) (*grpc.Server, string, error) {
+ t.Helper()
+
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+
+ srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ proto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
diff --git a/client/server/server_test.go b/client/server/server_test.go
index fa9599818..7717cfcf8 100644
--- a/client/server/server_test.go
+++ b/client/server/server_test.go
@@ -2,124 +2,22 @@ package server
import (
"context"
- "net"
"net/url"
"os/user"
"path/filepath"
"testing"
"time"
- "github.com/golang/mock/gomock"
- "github.com/stretchr/testify/require"
- "go.opentelemetry.io/otel"
-
- "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
-
- "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
- "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
- "github.com/netbirdio/netbird/management/internals/modules/peers"
- "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
- nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
- "github.com/netbirdio/netbird/management/server/job"
-
- "github.com/netbirdio/netbird/management/internals/server/config"
- "github.com/netbirdio/netbird/management/server/groups"
-
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"google.golang.org/grpc"
- "google.golang.org/grpc/keepalive"
"github.com/netbirdio/netbird/client/internal"
- "github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
daemonProto "github.com/netbirdio/netbird/client/proto"
- "github.com/netbirdio/netbird/management/server"
- "github.com/netbirdio/netbird/management/server/activity"
- nbcache "github.com/netbirdio/netbird/management/server/cache"
- "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
- "github.com/netbirdio/netbird/management/server/permissions"
- "github.com/netbirdio/netbird/management/server/settings"
- "github.com/netbirdio/netbird/management/server/store"
- "github.com/netbirdio/netbird/management/server/telemetry"
- mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
- "github.com/netbirdio/netbird/shared/signal/proto"
- signalServer "github.com/netbirdio/netbird/signal/server"
)
-var (
- kaep = keepalive.EnforcementPolicy{
- MinTime: 15 * time.Second,
- PermitWithoutStream: true,
- }
-
- kasp = keepalive.ServerParameters{
- MaxConnectionIdle: 15 * time.Second,
- MaxConnectionAgeGrace: 5 * time.Second,
- Time: 5 * time.Second,
- Timeout: 2 * time.Second,
- }
-)
-
-// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables
-// we will use a management server started via to simulate the server and capture the number of retries
-func TestConnectWithRetryRuns(t *testing.T) {
- // start the signal server
- _, signalAddr, err := startSignal(t)
- if err != nil {
- t.Fatalf("failed to start signal server: %v", err)
- }
-
- counter := 0
- // start the management server
- _, mgmtAddr, err := startManagement(t, signalAddr, &counter)
- if err != nil {
- t.Fatalf("failed to start management server: %v", err)
- }
-
- ctx := internal.CtxInitState(context.Background())
-
- ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second))
- defer cancel()
- // create new server
- ic := profilemanager.ConfigInput{
- ManagementURL: "http://" + mgmtAddr,
- ConfigPath: t.TempDir() + "/test-profile.json",
- }
-
- config, err := profilemanager.UpdateOrCreateConfig(ic)
- if err != nil {
- t.Fatalf("failed to create config: %v", err)
- }
-
- currUser, err := user.Current()
- require.NoError(t, err)
-
- pm := profilemanager.ServiceManager{}
- err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- ID: "test-profile",
- Username: currUser.Username,
- })
- if err != nil {
- t.Fatalf("failed to set active profile state: %v", err)
- }
-
- s := New(ctx, "debug", "", false, false, false, false)
-
- s.config = config
-
- s.statusRecorder = peer.NewRecorder(config.ManagementURL.String())
- t.Setenv(retryInitialIntervalVar, "1s")
- t.Setenv(maxRetryIntervalVar, "2s")
- t.Setenv(maxRetryTimeVar, "5s")
- t.Setenv(retryMultiplierVar, "1")
-
- s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
- if counter < 3 {
- t.Fatalf("expected counter > 2, got %d", counter)
- }
-}
-
func TestServer_Up(t *testing.T) {
tempDir := t.TempDir()
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
@@ -259,119 +157,3 @@ func TestServer_SubcribeEvents(t *testing.T) {
assert.NoError(t, err)
}
-
-type mockServer struct {
- mgmtProto.ManagementServiceServer
- counter *int
-}
-
-func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
- *m.counter++
- return m.ManagementServiceServer.Login(ctx, req)
-}
-
-func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) {
- t.Helper()
- dataDir := t.TempDir()
-
- config := &config.Config{
- Stuns: []*config.Host{},
- TURNConfig: &config.TURNConfig{},
- Signal: &config.Host{
- Proto: "http",
- URI: signalAddr,
- },
- Datadir: dataDir,
- HttpConfig: nil,
- }
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- return nil, "", err
- }
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
- store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir)
- if err != nil {
- return nil, "", err
- }
- t.Cleanup(cleanUp)
-
- eventStore := &activity.InMemoryEventStore{}
- if err != nil {
- return nil, "", err
- }
-
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
-
- permissionsManagerMock := permissions.NewMockManager(ctrl)
- peersManager := peers.NewManager(store, permissionsManagerMock)
- settingsManagerMock := settings.NewMockManager(ctrl)
-
- jobManager := job.NewJobManager(nil, store, peersManager)
-
- cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
- if err != nil {
- return nil, "", err
- }
-
- ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
-
- metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
- require.NoError(t, err)
-
- settingsMockManager := settings.NewMockManager(ctrl)
- groupsManager := groups.NewManagerMock()
-
- requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
- peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
- networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
- accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
- if err != nil {
- return nil, "", err
- }
-
- secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
- if err != nil {
- return nil, "", err
- }
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
- if err != nil {
- return nil, "", err
- }
- mock := &mockServer{
- ManagementServiceServer: mgmtServer,
- counter: counter,
- }
- mgmtProto.RegisterManagementServiceServer(s, mock)
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
-
-func startSignal(t *testing.T) (*grpc.Server, string, error) {
- t.Helper()
-
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- log.Fatalf("failed to listen: %v", err)
- }
-
- srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
- require.NoError(t, err)
- proto.RegisterSignalExchangeServer(s, srv)
-
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go
index 9818f9fdf..ae323ea8c 100644
--- a/client/server/setconfig_mdm_test.go
+++ b/client/server/setconfig_mdm_test.go
@@ -66,7 +66,11 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN
Username: currUser.Username,
}))
- ctx = context.Background()
+ // The privileged-change gate reads the caller's kernel identity from the
+ // context, which a real caller gets from the daemon's transport credentials.
+ // This test drives the handler directly, so it stands in for a root caller;
+ // without an identity the gate would (correctly) refuse the SSH fields.
+ ctx = privilegedTestCtx()
s = New(ctx, "console", "", false, false, false, false)
return s, ctx, profName, currUser.Username, cfgPath
}
@@ -181,6 +185,43 @@ func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
require.NotNil(t, resp)
}
+// TestSetConfig_MDMAllow_ManagementURLPortNormalized covers the
+// regression from discussion #6483: MDM URL without explicit port vs
+// UI echo with the parseURL-appended default port must be treated as
+// a no-op echo, not a conflict.
+func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) {
+ tests := []struct {
+ name string
+ mdmURL string
+ submitURL string
+ }{
+ {"policy_no_port_submit_with_443", "https://netbird.corp.example", "https://netbird.corp.example:443"},
+ {"policy_with_443_submit_no_port", "https://netbird.corp.example:443", "https://netbird.corp.example"},
+ {"http_policy_no_port_submit_with_80", "http://netbird.corp.example", "http://netbird.corp.example:80"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: tc.mdmURL,
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ rosenpassEnabled := true
+ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: tc.submitURL,
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ require.NoError(t, err, "port-normalized URL echo must not trip MDM conflict gate")
+ require.NotNil(t, resp)
+ })
+ }
+}
+
func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) {
// No MDM policy active: any field can be written.
withMDMPolicy(t, mdm.NewPolicy(nil))
diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go
index 7c85d16ce..db7a26f03 100644
--- a/client/server/setconfig_test.go
+++ b/client/server/setconfig_test.go
@@ -1,7 +1,6 @@
package server
import (
- "context"
"os/user"
"path/filepath"
"reflect"
@@ -52,7 +51,11 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
})
require.NoError(t, err)
- ctx := context.Background()
+ // The privileged-change gate reads the caller's kernel identity from the
+ // context, which a real caller gets from the daemon's transport credentials.
+ // This test drives the handler directly, so it stands in for a root caller;
+ // without an identity the gate would (correctly) refuse the SSH fields.
+ ctx := privilegedTestCtx()
s := New(ctx, "console", "", false, false, false, false)
rosenpassEnabled := true
@@ -69,43 +72,41 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
disableFirewall := true
blockLANAccess := true
disableNotifications := true
- lazyConnectionEnabled := true
blockInbound := true
disableIPv6 := true
mtu := int64(1280)
sshJWTCacheTTL := int32(300)
req := &proto.SetConfigRequest{
- ProfileName: profName,
- Username: currUser.Username,
- ManagementUrl: "https://new-api.netbird.io:443",
- AdminURL: "https://new-admin.netbird.io",
- RosenpassEnabled: &rosenpassEnabled,
- RosenpassPermissive: &rosenpassPermissive,
- ServerSSHAllowed: &serverSSHAllowed,
- InterfaceName: &interfaceName,
- WireguardPort: &wireguardPort,
- OptionalPreSharedKey: &preSharedKey,
- DisableAutoConnect: &disableAutoConnect,
- NetworkMonitor: &networkMonitor,
- DisableClientRoutes: &disableClientRoutes,
- DisableServerRoutes: &disableServerRoutes,
- DisableDns: &disableDNS,
- DisableFirewall: &disableFirewall,
- BlockLanAccess: &blockLANAccess,
- DisableNotifications: &disableNotifications,
- LazyConnectionEnabled: &lazyConnectionEnabled,
- BlockInbound: &blockInbound,
- DisableIpv6: &disableIPv6,
- NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"},
- CleanNATExternalIPs: false,
- CustomDNSAddress: []byte("1.1.1.1:53"),
- ExtraIFaceBlacklist: []string{"eth1", "eth2"},
- DnsLabels: []string{"label1", "label2"},
- CleanDNSLabels: false,
- DnsRouteInterval: durationpb.New(2 * time.Minute),
- Mtu: &mtu,
- SshJWTCacheTTL: &sshJWTCacheTTL,
+ ProfileName: profName,
+ Username: currUser.Username,
+ ManagementUrl: "https://new-api.netbird.io:443",
+ AdminURL: "https://new-admin.netbird.io",
+ RosenpassEnabled: &rosenpassEnabled,
+ RosenpassPermissive: &rosenpassPermissive,
+ ServerSSHAllowed: &serverSSHAllowed,
+ InterfaceName: &interfaceName,
+ WireguardPort: &wireguardPort,
+ OptionalPreSharedKey: &preSharedKey,
+ DisableAutoConnect: &disableAutoConnect,
+ NetworkMonitor: &networkMonitor,
+ DisableClientRoutes: &disableClientRoutes,
+ DisableServerRoutes: &disableServerRoutes,
+ DisableDns: &disableDNS,
+ DisableFirewall: &disableFirewall,
+ BlockLanAccess: &blockLANAccess,
+ DisableNotifications: &disableNotifications,
+ BlockInbound: &blockInbound,
+ DisableIpv6: &disableIPv6,
+ NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"},
+ CleanNATExternalIPs: false,
+ CustomDNSAddress: []byte("1.1.1.1:53"),
+ ExtraIFaceBlacklist: []string{"eth1", "eth2"},
+ DnsLabels: []string{"label1", "label2"},
+ CleanDNSLabels: false,
+ DnsRouteInterval: durationpb.New(2 * time.Minute),
+ Mtu: &mtu,
+ SshJWTCacheTTL: &sshJWTCacheTTL,
}
_, err = s.SetConfig(ctx, req)
@@ -140,7 +141,6 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.Equal(t, blockLANAccess, cfg.BlockLANAccess)
require.NotNil(t, cfg.DisableNotifications)
require.Equal(t, disableNotifications, *cfg.DisableNotifications)
- require.Equal(t, lazyConnectionEnabled, cfg.LazyConnectionEnabled)
require.Equal(t, blockInbound, cfg.BlockInbound)
require.Equal(t, disableIPv6, cfg.DisableIPv6)
require.Equal(t, []string{"1.2.3.4", "5.6.7.8"}, cfg.NATExternalIPs)
@@ -164,13 +164,14 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
t.Helper()
metadataFields := map[string]bool{
- "state": true, // protobuf internal
- "sizeCache": true, // protobuf internal
- "unknownFields": true, // protobuf internal
- "Username": true, // metadata
- "ProfileName": true, // metadata
- "CleanNATExternalIPs": true, // control flag for clearing
- "CleanDNSLabels": true, // control flag for clearing
+ "state": true, // protobuf internal
+ "sizeCache": true, // protobuf internal
+ "unknownFields": true, // protobuf internal
+ "Username": true, // metadata
+ "ProfileName": true, // metadata
+ "CleanNATExternalIPs": true, // control flag for clearing
+ "CleanDNSLabels": true, // control flag for clearing
+ "LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied
}
expectedFields := map[string]bool{
@@ -190,7 +191,6 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"DisableFirewall": true,
"BlockLanAccess": true,
"DisableNotifications": true,
- "LazyConnectionEnabled": true,
"BlockInbound": true,
"DisableIpv6": true,
"NatExternalIPs": true,
@@ -252,7 +252,6 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
"block-lan-access": "BlockLanAccess",
"block-inbound": "BlockInbound",
"disable-ipv6": "DisableIpv6",
- "enable-lazy-connection": "LazyConnectionEnabled",
"external-ip-map": "NatExternalIPs",
"dns-resolver-address": "CustomDNSAddress",
"extra-iface-blacklist": "ExtraIFaceBlacklist",
@@ -269,7 +268,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
// SetConfigRequest fields that don't have CLI flags (settable only via UI or other means).
fieldsWithoutCLIFlags := map[string]bool{
- "DisableNotifications": true, // Only settable via UI
+ "DisableNotifications": true, // Only settable via UI
+ "LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN)
}
// Get all SetConfigRequest fields to verify our map is complete.
diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go
new file mode 100644
index 000000000..ca1b4c4ee
--- /dev/null
+++ b/client/server/ssh_gate.go
@@ -0,0 +1,282 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "runtime"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/genproto/googleapis/rpc/errdetails"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/daemonaddr"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/util"
+)
+
+// The daemon runs as root/LocalSystem, so a handful of config changes cross the
+// user-to-root boundary and are restricted to privileged callers:
+//
+// - Enabling SSH root login, or disabling SSH authentication, turns the
+// daemon's SSH server into a root (or unauthenticated) shell.
+// - Enabling the SSH server at all is what makes the above reachable, and a
+// profile the caller owns is not a privilege they hold.
+// - While the SSH server is enabled, repointing the profile at another
+// management identity hands SSH authorization decisions, including which
+// keys and users are accepted, to whoever controls that identity. Changing
+// the management URL and deregistering the peer are both ways to do that.
+//
+// Everything else stays unauthenticated, so this is not an authorization model:
+// it only refuses the changes that would let a local user become root. A caller
+// whose identity cannot be established is refused as well.
+
+// privilegedConfigChange is the subset of a config request that crosses the
+// user-to-root boundary. Fields are nil or empty when the request leaves them
+// untouched.
+type privilegedConfigChange struct {
+ managementURL string
+ serverSSHAllowed *bool
+ enableSSHRoot *bool
+ disableSSHAuth *bool
+}
+
+func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange {
+ return privilegedConfigChange{
+ managementURL: msg.GetManagementUrl(),
+ serverSSHAllowed: msg.ServerSSHAllowed,
+ enableSSHRoot: msg.EnableSSHRoot,
+ disableSSHAuth: msg.DisableSSHAuth,
+ }
+}
+
+func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange {
+ return privilegedConfigChange{
+ managementURL: msg.GetManagementUrl(),
+ serverSSHAllowed: msg.ServerSSHAllowed,
+ enableSSHRoot: msg.EnableSSHRoot,
+ disableSSHAuth: msg.DisableSSHAuth,
+ }
+}
+
+// requirePrivilegeForConfigChange refuses the privileged parts of a config
+// change when the caller is not root/administrator. stored is the profile's
+// current config, or nil when it has none yet.
+//
+// Each check compares against the stored value so that a request restating a
+// value it does not change is never refused: a UI that submits the whole
+// settings form must not start failing once an administrator has enabled SSH.
+func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager.Config, change privilegedConfigChange) error {
+ if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.EnableSSHRoot }), change.enableSSHRoot) {
+ return denyPrivileged(ctx, "enabling SSH root login", ipcauth.UpCommand("--enable-ssh-root"))
+ }
+
+ if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.DisableSSHAuth }), change.disableSSHAuth) {
+ return denyPrivileged(ctx, "disabling SSH authentication", ipcauth.UpCommand("--disable-ssh-auth"))
+ }
+
+ if enables(sshServerCurrentlyAllowed(stored), change.serverSSHAllowed) {
+ return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh"))
+ }
+
+ // Only guard the management binding while the SSH server is enabled: that is
+ // when the management identity decides who may open a shell here.
+ if !sshServerEnabled(stored) {
+ return nil
+ }
+
+ if change.managementURL != "" && !sameManagementURL(stored.ManagementURL, change.managementURL) {
+ return denyPrivileged(ctx,
+ "changing the management URL while the NetBird SSH server is enabled",
+ ipcauth.UpCommand("-m "+change.managementURL))
+ }
+
+ return nil
+}
+
+// requirePrivilegeForDeregistration refuses to deregister the peer from the
+// management server when the caller is not privileged and the profile has the
+// SSH server enabled. Deregistering frees the peer's key to be registered
+// against another management identity, which is the same handover the
+// management URL check refuses.
+//
+// Callers that treat deregistration as best-effort (profile removal) continue
+// without it; callers that were asked to deregister surface the error.
+func requirePrivilegeForDeregistration(ctx context.Context, cfg *profilemanager.Config) error {
+ if !sshServerEnabled(cfg) {
+ return nil
+ }
+
+ return denyPrivileged(ctx,
+ "deregistering this peer while the NetBird SSH server is enabled",
+ ipcauth.ElevatedCommand("netbird logout"))
+}
+
+// denyPrivileged returns nil when the caller is privileged, and otherwise a
+// PermissionDenied whose message names the action and the command that performs
+// it with the privileges it needs. The same summary and command ride along as an
+// ErrorInfo detail so the CLI and the UI can present them without parsing text.
+//
+// action reads as the subject of a sentence ("enabling SSH root login"), and
+// command is the equivalent command, already elevated for the platform.
+func denyPrivileged(ctx context.Context, action, command string) error {
+ id, ok := ipcauth.CallerIdentity(ctx)
+ if !ok {
+ log.Warnf("denying %s: the caller's identity cannot be verified on this control channel", action)
+ return privilegeError(unidentifiedSummary(action), reinstallCommand())
+ }
+
+ if ipcauth.IsPrivilegedCaller(id) {
+ log.Infof("allowing %s for privileged caller %s", action, id)
+ return nil
+ }
+
+ log.Warnf("denying %s for unprivileged caller %s", action, id)
+ actor, command := requiredActor(command)
+ return privilegeError(privilegeSummary(action, actor), command)
+}
+
+// requiredActor names who may perform the operation and adjusts the command to
+// match. A daemon that is not itself privileged delegates to its own identity, so
+// telling that host's user to become root is wrong twice over: root is not what the
+// daemon checks for, and a rootless container has neither root nor sudo.
+func requiredActor(command string) (string, string) {
+ self, delegates := ipcauth.SelfDelegatesTo()
+ if !delegates {
+ return ipcauth.PrivilegedActor(), command
+ }
+ return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "")
+}
+
+// privilegeError builds the PermissionDenied carrying summary and command.
+func privilegeError(summary, command string) error {
+ st := gstatus.New(codes.PermissionDenied, fmt.Sprintf("%s\n\n%s", summary, command))
+
+ detailed, err := st.WithDetails(&errdetails.ErrorInfo{
+ Reason: ipcauth.ErrorReasonPrivilegeRequired,
+ Domain: ipcauth.ErrorDomain,
+ Metadata: map[string]string{
+ ipcauth.ErrorMetaSummary: summary,
+ ipcauth.ErrorMetaCommand: command,
+ },
+ })
+ if err != nil {
+ log.Debugf("attach privilege error detail: %v", err)
+ return st.Err()
+ }
+ return detailed.Err()
+}
+
+// privilegeSummary states what is refused and what it needs, in one sentence
+// that reads the same in a dialog and in a terminal.
+func privilegeSummary(action, actor string) string {
+ return fmt.Sprintf("%s requires %s.", capitalize(action), actor)
+}
+
+// unidentifiedSummary covers a control channel that carries no caller identity.
+// Elevating does not help there, so it points at the daemon's socket instead.
+func unidentifiedSummary(action string) string {
+ return fmt.Sprintf("%s requires %s, and the daemon cannot verify who is calling over its current socket. "+
+ "Reinstall the service on a socket that carries the caller's identity.", capitalize(action), ipcauth.PrivilegedActor())
+}
+
+// reinstallCommand is the command that moves the daemon onto a socket whose
+// callers can be identified.
+func reinstallCommand() string {
+ if runtime.GOOS == "windows" {
+ return fmt.Sprintf("netbird service install --daemon-addr %s", daemonaddr.WindowsPipeAddr)
+ }
+ return "sudo netbird service install --daemon-addr unix:///var/run/netbird.sock"
+}
+
+func capitalize(s string) string {
+ if s == "" {
+ return s
+ }
+ return strings.ToUpper(s[:1]) + s[1:]
+}
+
+// enables reports whether requested turns a flag on that is currently off. A
+// request that restates the stored value, or turns the flag off, is not a
+// privileged change.
+func enables(stored, requested *bool) bool {
+ if requested == nil || !*requested {
+ return false
+ }
+ return stored == nil || !*stored
+}
+
+// storedFlag reads a flag from the stored config, tolerating a config that does
+// not exist yet.
+func storedFlag(cfg *profilemanager.Config, get func(*profilemanager.Config) *bool) *bool {
+ if cfg == nil {
+ return nil
+ }
+ return get(cfg)
+}
+
+// sshServerEnabled reports whether the profile currently runs the SSH server.
+//
+// A nil flag means ON, matching what the engine does with the same config
+// (util.ReturnBoolWithDefaultTrue in internal/connect.go, kept for configs written
+// before the flag existed). Reading it as OFF here would open the management-URL
+// and deregistration guards on exactly those legacy hosts, whose SSH server is
+// running. Configs loaded through profilemanager have already been materialised by
+// apply(), so this is the same answer by a route that does not depend on that.
+func sshServerEnabled(cfg *profilemanager.Config) bool {
+ if cfg == nil {
+ return false
+ }
+ return util.ReturnBoolWithDefaultTrue(cfg.ServerSSHAllowed)
+}
+
+// sshServerCurrentlyAllowed is the value an enable request is compared against. It
+// shares sshServerEnabled's nil-means-on default, so restating "on" for a legacy
+// config is correctly seen as no change.
+func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool {
+ enabled := sshServerEnabled(cfg)
+ if cfg == nil {
+ return nil
+ }
+ return &enabled
+}
+
+// sameManagementURL reports whether requested addresses the same management
+// server as stored, comparing scheme, host and effective port so that an
+// equivalent spelling ("https://api.netbird.io" for a stored
+// "https://api.netbird.io:443") is not treated as a change. It fails closed:
+// anything unparseable counts as a change and therefore needs privilege.
+func sameManagementURL(stored *url.URL, requested string) bool {
+ if stored == nil {
+ return false
+ }
+
+ // Normalise the requested URL through the config layer's own parser, so the
+ // comparison cannot drift from how the value would actually be stored.
+ parsed, err := profilemanager.ParseServiceURL("Management URL", requested)
+ if err != nil {
+ return false
+ }
+
+ return stored.Scheme == parsed.Scheme &&
+ stored.Hostname() == parsed.Hostname() &&
+ effectivePort(stored) == effectivePort(parsed)
+}
+
+func effectivePort(u *url.URL) string {
+ if port := u.Port(); port != "" {
+ return port
+ }
+ switch u.Scheme {
+ case "https":
+ return "443"
+ case "http":
+ return "80"
+ default:
+ return ""
+ }
+}
diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go
new file mode 100644
index 000000000..cbd345f16
--- /dev/null
+++ b/client/server/ssh_gate_test.go
@@ -0,0 +1,348 @@
+package server
+
+import (
+ "context"
+ "net/url"
+ "os"
+ "runtime"
+ "strings"
+ "testing"
+
+ "google.golang.org/genproto/googleapis/rpc/errdetails"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/peer"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+)
+
+// ctxWithIdentity builds a request context carrying the identity the transport
+// credentials would have attached.
+func ctxWithIdentity(id ipcauth.Identity) context.Context {
+ return peer.NewContext(context.Background(), &peer.Peer{
+ AuthInfo: ipcauth.AuthInfo{
+ CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
+ Identity: id,
+ },
+ })
+}
+
+// unprivUID is deliberately not this process's own uid. An unprivileged daemon
+// treats a caller sharing its identity as privileged (rootless containers), and
+// the test binary would otherwise stand in for both the daemon and the caller.
+// os.Geteuid returns -1 on Windows, where identities are SIDs instead and this is
+// unused.
+var unprivUID = uint32(os.Geteuid() + 1)
+
+// The fabricated identities have to be shaped like the platform's: a uid says
+// nothing on Windows, and a zero uid there would read as root and be privileged.
+func rootCtx() context.Context { return ctxWithIdentity(privilegedIdentity()) }
+func userCtx() context.Context { return ctxWithIdentity(unprivilegedIdentity()) }
+
+func privilegedIdentity() ipcauth.Identity {
+ if runtime.GOOS == "windows" {
+ // LocalSystem, which is what the Windows service account is.
+ return ipcauth.Identity{SID: "S-1-5-18"}
+ }
+ return ipcauth.Identity{UID: 0}
+}
+
+func unprivilegedIdentity() ipcauth.Identity {
+ if runtime.GOOS == "windows" {
+ // A plain user SID: no groups, so no BUILTIN\Administrators, and not
+ // elevated.
+ return ipcauth.Identity{SID: "S-1-5-21-1-2-3-1001"}
+ }
+ return ipcauth.Identity{UID: unprivUID, GID: unprivUID}
+}
+func noIdentityCtx() context.Context { return context.Background() }
+
+func boolPtr(v bool) *bool { return &v }
+
+func mustURL(t *testing.T, raw string) *url.URL {
+ t.Helper()
+ u, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("parse %q: %v", raw, err)
+ }
+ return u
+}
+
+func assertDenied(t *testing.T, err error) {
+ t.Helper()
+ if err == nil {
+ t.Fatal("expected the change to be refused, got nil")
+ }
+ st := gstatus.Convert(err)
+ if st.Code() != codes.PermissionDenied {
+ t.Fatalf("code = %v, want PermissionDenied", st.Code())
+ }
+ // The refusal must be machine-readable: the CLI and the UI render the
+ // summary and command from the detail rather than parsing the message.
+ var info *errdetails.ErrorInfo
+ for _, d := range st.Details() {
+ if got, ok := d.(*errdetails.ErrorInfo); ok {
+ info = got
+ }
+ }
+ if info == nil {
+ t.Fatal("refusal carries no ErrorInfo detail")
+ }
+ if info.GetReason() != ipcauth.ErrorReasonPrivilegeRequired || info.GetDomain() != ipcauth.ErrorDomain {
+ t.Fatalf("detail = %s/%s, want %s/%s", info.GetDomain(), info.GetReason(), ipcauth.ErrorDomain, ipcauth.ErrorReasonPrivilegeRequired)
+ }
+ if info.GetMetadata()[ipcauth.ErrorMetaSummary] == "" {
+ t.Error("detail carries no summary")
+ }
+ if info.GetMetadata()[ipcauth.ErrorMetaCommand] == "" {
+ t.Error("detail carries no command")
+ }
+}
+
+func assertAllowed(t *testing.T, err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatalf("expected the change to be allowed, got %v", err)
+ }
+}
+
+func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) {
+ tests := []struct {
+ name string
+ stored *profilemanager.Config
+ change privilegedConfigChange
+ privileged bool
+ wantDeny bool
+ }{
+ {
+ name: "enabling the ssh server unprivileged is refused",
+ stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
+ change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
+ wantDeny: true,
+ },
+ {
+ name: "enabling the ssh server as root is allowed",
+ stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
+ change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
+ privileged: true,
+ },
+ {
+ name: "restating an already enabled ssh server is not a change",
+ stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
+ change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
+ },
+ {
+ name: "turning the ssh server off is not guarded",
+ stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
+ change: privilegedConfigChange{serverSSHAllowed: boolPtr(false)},
+ },
+ {
+ name: "a profile with no config yet counts as off, so enabling is refused",
+ stored: nil,
+ change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
+ wantDeny: true,
+ },
+ {
+ name: "enabling ssh root login unprivileged is refused",
+ stored: &profilemanager.Config{EnableSSHRoot: boolPtr(false)},
+ change: privilegedConfigChange{enableSSHRoot: boolPtr(true)},
+ wantDeny: true,
+ },
+ {
+ name: "restating ssh root login is not a change",
+ stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)},
+ change: privilegedConfigChange{enableSSHRoot: boolPtr(true)},
+ },
+ {
+ name: "turning ssh root login off is not guarded",
+ stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)},
+ change: privilegedConfigChange{enableSSHRoot: boolPtr(false)},
+ },
+ {
+ name: "disabling ssh authentication unprivileged is refused",
+ stored: &profilemanager.Config{DisableSSHAuth: boolPtr(false)},
+ change: privilegedConfigChange{disableSSHAuth: boolPtr(true)},
+ wantDeny: true,
+ },
+ {
+ name: "re-enabling ssh authentication is not guarded",
+ stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)},
+ change: privilegedConfigChange{disableSSHAuth: boolPtr(false)},
+ },
+ {
+ name: "a request that touches none of the guarded fields is allowed",
+ stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
+ change: privilegedConfigChange{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := userCtx()
+ if tt.privileged {
+ ctx = rootCtx()
+ }
+ err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change)
+ if tt.wantDeny {
+ assertDenied(t, err)
+ return
+ }
+ assertAllowed(t, err)
+ })
+ }
+}
+
+func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) {
+ sshOn := func(raw string) *profilemanager.Config {
+ return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)}
+ }
+ sshOff := func(raw string) *profilemanager.Config {
+ return &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ManagementURL: mustURL(t, raw)}
+ }
+
+ tests := []struct {
+ name string
+ stored *profilemanager.Config
+ requested string
+ privileged bool
+ wantDeny bool
+ }{
+ {
+ name: "moving the binding while ssh is enabled is refused",
+ stored: sshOn("https://api.netbird.io:443"),
+ requested: "https://attacker.example.com:443",
+ wantDeny: true,
+ },
+ {
+ name: "moving the binding as root is allowed",
+ stored: sshOn("https://api.netbird.io:443"),
+ requested: "https://selfhosted.example.com:443",
+ privileged: true,
+ },
+ {
+ name: "the same url restated is not a change",
+ stored: sshOn("https://api.netbird.io:443"),
+ requested: "https://api.netbird.io:443",
+ },
+ {
+ name: "an equivalent spelling of the same url is not a change",
+ stored: sshOn("https://api.netbird.io:443"),
+ requested: "https://api.netbird.io",
+ },
+ {
+ name: "an equivalent spelling with an explicit http port is not a change",
+ stored: sshOn("http://mgmt.internal:80"),
+ requested: "http://mgmt.internal",
+ },
+ {
+ name: "a different port on the same host is a change",
+ stored: sshOn("https://api.netbird.io:443"),
+ requested: "https://api.netbird.io:8443",
+ wantDeny: true,
+ },
+ {
+ name: "a different scheme on the same host is a change",
+ stored: sshOn("https://mgmt.internal:443"),
+ requested: "http://mgmt.internal:443",
+ wantDeny: true,
+ },
+ {
+ name: "with ssh disabled the binding is not guarded at all",
+ stored: sshOff("https://api.netbird.io:443"),
+ requested: "https://attacker.example.com:443",
+ },
+ {
+ name: "an unparseable url fails closed",
+ stored: sshOn("https://api.netbird.io:443"),
+ requested: "ht tp://%zz",
+ wantDeny: true,
+ },
+ {
+ name: "an empty url leaves the binding alone",
+ stored: sshOn("https://api.netbird.io:443"),
+ requested: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := userCtx()
+ if tt.privileged {
+ ctx = rootCtx()
+ }
+ err := requirePrivilegeForConfigChange(ctx, tt.stored, privilegedConfigChange{managementURL: tt.requested})
+ if tt.wantDeny {
+ assertDenied(t, err)
+ return
+ }
+ assertAllowed(t, err)
+ })
+ }
+}
+
+// A caller the daemon cannot identify must be refused, not trusted: that is the
+// state on a TCP daemon socket, where no peer credentials exist.
+func TestRequirePrivilegeForConfigChange_UnidentifiedCallerIsRefused(t *testing.T) {
+ err := requirePrivilegeForConfigChange(noIdentityCtx(),
+ &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
+ privilegedConfigChange{serverSSHAllowed: boolPtr(true)})
+ assertDenied(t, err)
+
+ // The guidance must point at the socket rather than at sudo, since elevating
+ // would not help.
+ st := gstatus.Convert(err)
+ if !strings.Contains(st.Message(), "service install") {
+ t.Errorf("message %q does not tell the operator how to fix the socket", st.Message())
+ }
+}
+
+func TestRequirePrivilegeForDeregistration(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg *profilemanager.Config
+ privileged bool
+ wantDeny bool
+ }{
+ {
+ name: "deregistering while ssh is enabled is refused",
+ cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
+ wantDeny: true,
+ },
+ {
+ name: "deregistering while ssh is enabled is allowed for root",
+ cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
+ privileged: true,
+ },
+ {
+ name: "deregistering with ssh disabled is not guarded",
+ cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
+ },
+ {
+ name: "deregistering a profile with no config is not guarded",
+ cfg: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := userCtx()
+ if tt.privileged {
+ ctx = rootCtx()
+ }
+ err := requirePrivilegeForDeregistration(ctx, tt.cfg)
+ if tt.wantDeny {
+ assertDenied(t, err)
+ return
+ }
+ assertAllowed(t, err)
+ })
+ }
+}
+
+// privilegedTestCtx is the context a handler-level test should use when it is
+// standing in for a root/administrator caller. Tests that drive the handlers
+// directly have no transport credentials, and the privileged-change gate refuses
+// a caller it cannot identify.
+func privilegedTestCtx() context.Context { return rootCtx() }
diff --git a/client/server/state.go b/client/server/state.go
index f2d823465..a4e91468e 100644
--- a/client/server/state.go
+++ b/client/server/state.go
@@ -46,7 +46,7 @@ func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) (
if req.All {
// Reuse existing cleanup logic for all states
- if err := restoreResidualState(ctx, statePath); err != nil {
+ if err := RestoreResidualState(ctx, statePath); err != nil {
return nil, status.Errorf(codes.Internal, "failed to clean all states: %v", err)
}
@@ -113,9 +113,9 @@ func (s *Server) DeleteState(ctx context.Context, req *proto.DeleteStateRequest)
}, nil
}
-// restoreResidualState checks if the client was not shut down in a clean way and restores residual if required.
+// RestoreResidualState checks if the client was not shut down in a clean way and restores residual if required.
// Otherwise, we might not be able to connect to the management server to retrieve new config.
-func restoreResidualState(ctx context.Context, statePath string) error {
+func RestoreResidualState(ctx context.Context, statePath string) error {
if statePath == "" {
return nil
}
diff --git a/client/server/status_stream.go b/client/server/status_stream.go
new file mode 100644
index 000000000..c6ba547eb
--- /dev/null
+++ b/client/server/status_stream.go
@@ -0,0 +1,57 @@
+package server
+
+import (
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// SubscribeStatus pushes a fresh StatusResponse on every connection state
+// change. The first message is the current snapshot, so a re-subscribing
+// client doesn't need to also call Status. Subsequent messages fire when
+// the peer recorder reports any of: connected/disconnected/connecting,
+// management or signal flip, address change, or peers list change.
+//
+// The change channel coalesces bursts to a single tick. If the consumer
+// is slow the daemon drops extras (not blocks), and the next snapshot
+// the consumer pulls already reflects everything.
+func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
+ subID, ch := s.statusRecorder.SubscribeToStateChanges()
+ defer func() {
+ s.statusRecorder.UnsubscribeFromStateChanges(subID)
+ log.Debug("client unsubscribed from status updates")
+ }()
+
+ log.Debug("client subscribed to status updates")
+
+ if err := s.sendStatusSnapshot(req, stream); err != nil {
+ return err
+ }
+
+ for {
+ select {
+ case _, ok := <-ch:
+ if !ok {
+ return nil
+ }
+ if err := s.sendStatusSnapshot(req, stream); err != nil {
+ return err
+ }
+ case <-stream.Context().Done():
+ return nil
+ }
+ }
+}
+
+func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
+ resp, err := s.buildStatusResponse(stream.Context(), req)
+ if err != nil {
+ log.Warnf("build status snapshot for stream: %v", err)
+ return err
+ }
+ if err := stream.Send(resp); err != nil {
+ log.Warnf("send status snapshot to stream: %v", err)
+ return err
+ }
+ return nil
+}
diff --git a/client/ssh/client/client.go b/client/ssh/client/client.go
index ebf8eb794..31143a4f4 100644
--- a/client/ssh/client/client.go
+++ b/client/ssh/client/client.go
@@ -9,7 +9,6 @@ import (
"path/filepath"
"runtime"
"strconv"
- "strings"
"time"
log "github.com/sirupsen/logrus"
@@ -17,7 +16,6 @@ import (
"golang.org/x/crypto/ssh/knownhosts"
"golang.org/x/term"
"google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -32,7 +30,7 @@ const (
// DefaultDaemonAddr is the default address for the NetBird daemon
DefaultDaemonAddr = "unix:///var/run/netbird.sock"
// DefaultDaemonAddrWindows is the default address for the NetBird daemon on Windows
- DefaultDaemonAddrWindows = "tcp://127.0.0.1:41731"
+ DefaultDaemonAddrWindows = daemonaddr.WindowsPipeAddr
)
// Client wraps crypto/ssh Client for simplified SSH operations
@@ -268,7 +266,7 @@ func getDefaultDaemonAddr() string {
return addr
}
if runtime.GOOS == "windows" {
- return DefaultDaemonAddrWindows
+ return daemonaddr.ResolveDaemonAddr(DefaultDaemonAddrWindows)
}
return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr)
}
@@ -315,21 +313,23 @@ func Dial(ctx context.Context, addr, user string, opts DialOptions) (*Client, er
// dialSSH establishes an SSH connection without JWT authentication
func dialSSH(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*Client, error) {
+ if config.Timeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, config.Timeout)
+ defer cancel()
+ }
+
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
- clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
+ client, err := nbssh.Handshake(ctx, conn, addr, config)
if err != nil {
- if closeErr := conn.Close(); closeErr != nil {
- log.Debugf("connection close after handshake failure: %v", closeErr)
- }
- return nil, fmt.Errorf("ssh handshake: %w", err)
+ return nil, err
}
- client := ssh.NewClient(clientConn, chans, reqs)
return &Client{
client: client,
}, nil
@@ -410,12 +410,9 @@ func verifyHostKeyViaDaemon(hostname string, remote net.Addr, key ssh.PublicKey,
}
func connectToDaemon(daemonAddr string) (*grpc.ClientConn, error) {
- addr := strings.TrimPrefix(daemonAddr, "tcp://")
+ target, opts := daemonaddr.DialTarget(daemonAddr)
- conn, err := grpc.NewClient(
- addr,
- grpc.WithTransportCredentials(insecure.NewCredentials()),
- )
+ conn, err := grpc.NewClient(target, opts...)
if err != nil {
log.Debugf("failed to create gRPC client for NetBird daemon at %s: %v", daemonAddr, err)
return nil, fmt.Errorf("failed to connect to NetBird daemon: %w", err)
diff --git a/client/ssh/client/client_privileged_test.go b/client/ssh/client/client_privileged_test.go
new file mode 100644
index 000000000..12edbbc06
--- /dev/null
+++ b/client/ssh/client/client_privileged_test.go
@@ -0,0 +1,118 @@
+//go:build privileged
+
+package client
+
+import (
+ "context"
+ "errors"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ cryptossh "golang.org/x/crypto/ssh"
+
+ "github.com/netbirdio/netbird/client/ssh/testutil"
+)
+
+func TestSSHClient_CommandExecution(t *testing.T) {
+ if runtime.GOOS == "windows" && testutil.IsCI() {
+ t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues")
+ }
+
+ server, _, client := setupTestSSHServerAndClient(t)
+ defer func() {
+ err := server.Stop()
+ require.NoError(t, err)
+ }()
+ defer func() {
+ err := client.Close()
+ assert.NoError(t, err)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ t.Run("ExecuteCommand captures output", func(t *testing.T) {
+ output, err := client.ExecuteCommand(ctx, "echo hello")
+ assert.NoError(t, err)
+ assert.Contains(t, string(output), "hello")
+ })
+
+ t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) {
+ err := client.ExecuteCommandWithIO(ctx, "echo world")
+ assert.NoError(t, err)
+ })
+
+ t.Run("commands with flags work", func(t *testing.T) {
+ output, err := client.ExecuteCommand(ctx, "echo -n test_flag")
+ assert.NoError(t, err)
+ assert.Equal(t, "test_flag", strings.TrimSpace(string(output)))
+ })
+
+ t.Run("non-zero exit codes don't return errors", func(t *testing.T) {
+ var testCmd string
+ if runtime.GOOS == "windows" {
+ testCmd = "echo hello | Select-String notfound"
+ } else {
+ testCmd = "echo 'hello' | grep 'notfound'"
+ }
+ _, err := client.ExecuteCommand(ctx, testCmd)
+ assert.NoError(t, err)
+ })
+}
+
+func TestSSHClient_ContextCancellation(t *testing.T) {
+ server, serverAddr, _ := setupTestSSHServerAndClient(t)
+ defer func() {
+ err := server.Stop()
+ require.NoError(t, err)
+ }()
+
+ t.Run("connection with short timeout", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
+ defer cancel()
+
+ currentUser := testutil.GetTestUsername(t)
+ _, err := Dial(ctx, serverAddr, currentUser, DialOptions{
+ InsecureSkipVerify: true,
+ })
+ if err != nil {
+ // Check for actual timeout-related errors rather than string matching
+ assert.True(t,
+ errors.Is(err, context.DeadlineExceeded) ||
+ errors.Is(err, context.Canceled) ||
+ strings.Contains(err.Error(), "timeout"),
+ "Expected timeout-related error, got: %v", err)
+ }
+ })
+
+ t.Run("command execution cancellation", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ currentUser := testutil.GetTestUsername(t)
+ client, err := Dial(ctx, serverAddr, currentUser, DialOptions{
+ InsecureSkipVerify: true,
+ })
+ require.NoError(t, err)
+ defer func() {
+ if err := client.Close(); err != nil {
+ t.Logf("client close error: %v", err)
+ }
+ }()
+
+ cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cmdCancel()
+
+ err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10")
+ if err != nil {
+ var exitMissingErr *cryptossh.ExitMissingError
+ isValidCancellation := errors.Is(err, context.DeadlineExceeded) ||
+ errors.Is(err, context.Canceled) ||
+ errors.As(err, &exitMissingErr)
+ assert.True(t, isValidCancellation, "Should handle command cancellation properly")
+ }
+ })
+}
diff --git a/client/ssh/client/client_test.go b/client/ssh/client/client_test.go
index e38e02a86..191362940 100644
--- a/client/ssh/client/client_test.go
+++ b/client/ssh/client/client_test.go
@@ -15,7 +15,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- cryptossh "golang.org/x/crypto/ssh"
"github.com/netbirdio/netbird/client/ssh"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
@@ -78,53 +77,6 @@ func TestSSHClient_DialWithKey(t *testing.T) {
assert.NotNil(t, client.client)
}
-func TestSSHClient_CommandExecution(t *testing.T) {
- if runtime.GOOS == "windows" && testutil.IsCI() {
- t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues")
- }
-
- server, _, client := setupTestSSHServerAndClient(t)
- defer func() {
- err := server.Stop()
- require.NoError(t, err)
- }()
- defer func() {
- err := client.Close()
- assert.NoError(t, err)
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- defer cancel()
-
- t.Run("ExecuteCommand captures output", func(t *testing.T) {
- output, err := client.ExecuteCommand(ctx, "echo hello")
- assert.NoError(t, err)
- assert.Contains(t, string(output), "hello")
- })
-
- t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) {
- err := client.ExecuteCommandWithIO(ctx, "echo world")
- assert.NoError(t, err)
- })
-
- t.Run("commands with flags work", func(t *testing.T) {
- output, err := client.ExecuteCommand(ctx, "echo -n test_flag")
- assert.NoError(t, err)
- assert.Equal(t, "test_flag", strings.TrimSpace(string(output)))
- })
-
- t.Run("non-zero exit codes don't return errors", func(t *testing.T) {
- var testCmd string
- if runtime.GOOS == "windows" {
- testCmd = "echo hello | Select-String notfound"
- } else {
- testCmd = "echo 'hello' | grep 'notfound'"
- }
- _, err := client.ExecuteCommand(ctx, testCmd)
- assert.NoError(t, err)
- })
-}
-
func TestSSHClient_ConnectionHandling(t *testing.T) {
server, serverAddr, _ := setupTestSSHServerAndClient(t)
defer func() {
@@ -154,59 +106,6 @@ func TestSSHClient_ConnectionHandling(t *testing.T) {
}
}
-func TestSSHClient_ContextCancellation(t *testing.T) {
- server, serverAddr, _ := setupTestSSHServerAndClient(t)
- defer func() {
- err := server.Stop()
- require.NoError(t, err)
- }()
-
- t.Run("connection with short timeout", func(t *testing.T) {
- ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
- defer cancel()
-
- currentUser := testutil.GetTestUsername(t)
- _, err := Dial(ctx, serverAddr, currentUser, DialOptions{
- InsecureSkipVerify: true,
- })
- if err != nil {
- // Check for actual timeout-related errors rather than string matching
- assert.True(t,
- errors.Is(err, context.DeadlineExceeded) ||
- errors.Is(err, context.Canceled) ||
- strings.Contains(err.Error(), "timeout"),
- "Expected timeout-related error, got: %v", err)
- }
- })
-
- t.Run("command execution cancellation", func(t *testing.T) {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- currentUser := testutil.GetTestUsername(t)
- client, err := Dial(ctx, serverAddr, currentUser, DialOptions{
- InsecureSkipVerify: true,
- })
- require.NoError(t, err)
- defer func() {
- if err := client.Close(); err != nil {
- t.Logf("client close error: %v", err)
- }
- }()
-
- cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
- defer cmdCancel()
-
- err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10")
- if err != nil {
- var exitMissingErr *cryptossh.ExitMissingError
- isValidCancellation := errors.Is(err, context.DeadlineExceeded) ||
- errors.Is(err, context.Canceled) ||
- errors.As(err, &exitMissingErr)
- assert.True(t, isValidCancellation, "Should handle command cancellation properly")
- }
- })
-}
-
func TestSSHClient_NoAuthMode(t *testing.T) {
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
diff --git a/client/ssh/client/terminal_unix.go b/client/ssh/client/terminal_unix.go
index aaa3418f9..a963dc8be 100644
--- a/client/ssh/client/terminal_unix.go
+++ b/client/ssh/client/terminal_unix.go
@@ -12,6 +12,8 @@ import (
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
+
+ nbssh "github.com/netbirdio/netbird/client/ssh"
)
func (c *Client) setupTerminalMode(ctx context.Context, session *ssh.Session) error {
@@ -82,37 +84,7 @@ func (c *Client) setupTerminal(session *ssh.Session, fd int) error {
return fmt.Errorf("get terminal size: %w", err)
}
- modes := ssh.TerminalModes{
- ssh.ECHO: 1,
- ssh.TTY_OP_ISPEED: 14400,
- ssh.TTY_OP_OSPEED: 14400,
- // Ctrl+C
- ssh.VINTR: 3,
- // Ctrl+\
- ssh.VQUIT: 28,
- // Backspace
- ssh.VERASE: 127,
- // Ctrl+U
- ssh.VKILL: 21,
- // Ctrl+D
- ssh.VEOF: 4,
- ssh.VEOL: 0,
- ssh.VEOL2: 0,
- // Ctrl+Q
- ssh.VSTART: 17,
- // Ctrl+S
- ssh.VSTOP: 19,
- // Ctrl+Z
- ssh.VSUSP: 26,
- // Ctrl+O
- ssh.VDISCARD: 15,
- // Ctrl+R
- ssh.VREPRINT: 18,
- // Ctrl+W
- ssh.VWERASE: 23,
- // Ctrl+V
- ssh.VLNEXT: 22,
- }
+ modes := nbssh.DefaultTerminalModes
terminal := os.Getenv("TERM")
if terminal == "" {
diff --git a/client/ssh/client/terminal_windows.go b/client/ssh/client/terminal_windows.go
index 462438317..c6156fc26 100644
--- a/client/ssh/client/terminal_windows.go
+++ b/client/ssh/client/terminal_windows.go
@@ -10,6 +10,8 @@ import (
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
+
+ nbssh "github.com/netbirdio/netbird/client/ssh"
)
const (
@@ -80,28 +82,14 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro
w, h := c.getWindowsConsoleSize()
modes := ssh.TerminalModes{
- ssh.ECHO: 1,
- ssh.TTY_OP_ISPEED: 14400,
- ssh.TTY_OP_OSPEED: 14400,
- ssh.ICRNL: 1,
- ssh.OPOST: 1,
- ssh.ONLCR: 1,
- ssh.ISIG: 1,
- ssh.ICANON: 1,
- ssh.VINTR: 3, // Ctrl+C
- ssh.VQUIT: 28, // Ctrl+\
- ssh.VERASE: 127, // Backspace
- ssh.VKILL: 21, // Ctrl+U
- ssh.VEOF: 4, // Ctrl+D
- ssh.VEOL: 0,
- ssh.VEOL2: 0,
- ssh.VSTART: 17, // Ctrl+Q
- ssh.VSTOP: 19, // Ctrl+S
- ssh.VSUSP: 26, // Ctrl+Z
- ssh.VDISCARD: 15, // Ctrl+O
- ssh.VWERASE: 23, // Ctrl+W
- ssh.VLNEXT: 22, // Ctrl+V
- ssh.VREPRINT: 18, // Ctrl+R
+ ssh.ICRNL: 1,
+ ssh.OPOST: 1,
+ ssh.ONLCR: 1,
+ ssh.ISIG: 1,
+ ssh.ICANON: 1,
+ }
+ for mode, value := range nbssh.DefaultTerminalModes {
+ modes[mode] = value
}
if err := session.RequestPty("xterm-256color", h, w, modes); err != nil {
diff --git a/client/ssh/common.go b/client/ssh/common.go
index 92e647b7d..4ebf8842a 100644
--- a/client/ssh/common.go
+++ b/client/ssh/common.go
@@ -13,6 +13,7 @@ import (
"golang.org/x/crypto/ssh"
"github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/util"
)
const (
@@ -34,6 +35,19 @@ type HostKeyVerifier interface {
VerifySSHHostKey(peerAddress string, key []byte) error
}
+// PeerKeyLookup returns the stored SSH host key for a peer address.
+type PeerKeyLookup func(peerAddress string) ([]byte, bool)
+
+// VerifySSHHostKey implements HostKeyVerifier by looking up the stored key
+// and comparing it against the presented key.
+func (l PeerKeyLookup) VerifySSHHostKey(peerAddress string, presentedKey []byte) error {
+ storedKey, found := l(peerAddress)
+ if !found {
+ return ErrPeerNotFound
+ }
+ return VerifyHostKey(storedKey, presentedKey, peerAddress)
+}
+
// DaemonHostKeyVerifier implements HostKeyVerifier using the NetBird daemon
type DaemonHostKeyVerifier struct {
client proto.DaemonServiceClient
@@ -92,7 +106,8 @@ func printAuthInstructions(stderr io.Writer, authResponse *proto.RequestJWTAuthR
// RequestJWTToken requests or retrieves a JWT token for SSH authentication
func RequestJWTToken(ctx context.Context, client proto.DaemonServiceClient, stdout, stderr io.Writer, useCache bool, hint string, openBrowser func(string) error) (string, error) {
- req := &proto.RequestJWTAuthRequest{}
+ // the ssh client runs in the user's session, the daemon does not: tell it what we can see
+ req := &proto.RequestJWTAuthRequest{HasGraphicalSession: util.HasGraphicalSession()}
if hint != "" {
req.Hint = &hint
}
@@ -193,4 +208,3 @@ func buildAddressList(hostname string, remote net.Addr) []string {
}
return addresses
}
-
diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go
index 20695cb4d..e15330739 100644
--- a/client/ssh/config/manager.go
+++ b/client/ssh/config/manager.go
@@ -14,6 +14,7 @@ import (
log "github.com/sirupsen/logrus"
nbssh "github.com/netbirdio/netbird/client/ssh"
+ "github.com/netbirdio/netbird/shared/management/domain"
)
const (
@@ -218,11 +219,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string {
if peer.IPv6.IsValid() {
hostPatterns = append(hostPatterns, peer.IPv6.String())
}
- if peer.FQDN != "" {
+ // Peer FQDNs and hostnames originate from remote peers, so they must be
+ // validated as plain DNS names before being embedded in the ssh_config
+ // "Match host" pattern list. This prevents injection of arbitrary
+ // ssh_config directives via embedded quotes, whitespace, newlines, the
+ // comma pattern separator, or the "*"/"?" pattern metacharacters.
+ if domain.IsValidDomainNoWildcard(peer.FQDN) {
hostPatterns = append(hostPatterns, peer.FQDN)
+ } else if peer.FQDN != "" {
+ log.Warnf("skipping peer FQDN with invalid characters in SSH config: %q", peer.FQDN)
}
- if peer.Hostname != "" && peer.Hostname != peer.FQDN {
+ if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) {
hostPatterns = append(hostPatterns, peer.Hostname)
+ } else if peer.Hostname != "" && peer.Hostname != peer.FQDN {
+ log.Warnf("skipping peer hostname with invalid characters in SSH config: %q", peer.Hostname)
}
return hostPatterns
}
diff --git a/client/ssh/config/manager_test.go b/client/ssh/config/manager_test.go
index 8e6be40a3..f65d0ba6d 100644
--- a/client/ssh/config/manager_test.go
+++ b/client/ssh/config/manager_test.go
@@ -148,6 +148,45 @@ func TestManager_MatchHostFormat(t *testing.T) {
"should use Match host with comma-separated patterns")
}
+func TestManager_HostPatternInjection(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test")
+ require.NoError(t, err)
+ defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }()
+
+ manager := &Manager{
+ sshConfigDir: filepath.Join(tempDir, "ssh_config.d"),
+ sshConfigFile: "99-netbird.conf",
+ }
+
+ // A malicious peer FQDN/hostname attempts to break out of the Match host
+ // directive and inject arbitrary ssh_config (a ProxyCommand executing a
+ // command). It must be rejected, not written to the config.
+ peers := []PeerSSHInfo{
+ {
+ Hostname: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x",
+ IP: netip.MustParseAddr("100.125.1.1"),
+ FQDN: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x.nb.internal",
+ },
+ {Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"},
+ }
+
+ err = manager.SetupSSHClientConfig(peers)
+ require.NoError(t, err)
+
+ configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile)
+ content, err := os.ReadFile(configPath)
+ require.NoError(t, err)
+ configStr := string(content)
+
+ assert.NotContains(t, configStr, "ProxyCommand touch /tmp/pwned",
+ "injected directive must not appear in generated config")
+ assert.NotContains(t, configStr, "evil",
+ "malicious pattern must be dropped entirely")
+ // The valid peer must still be present, on a single Match host line.
+ assert.Contains(t, configStr, "Match host \"100.125.1.1,100.125.1.2,peer2.nb.internal,peer2\"",
+ "valid peers must survive, injected patterns dropped")
+}
+
func TestManager_ForcedSSHConfig(t *testing.T) {
// Set force environment variable
t.Setenv(EnvForceSSHConfig, "true")
diff --git a/client/ssh/handshake.go b/client/ssh/handshake.go
new file mode 100644
index 000000000..e78a806be
--- /dev/null
+++ b/client/ssh/handshake.go
@@ -0,0 +1,45 @@
+package ssh
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/crypto/ssh"
+)
+
+// Handshake runs the SSH client handshake on an already dialed conn and
+// returns the resulting client. Dialing bounds only the TCP establishment;
+// without a deadline on the socket a peer that accepts and then goes silent
+// blocks the handshake forever, so the context deadline is applied to conn
+// for the duration of the handshake. conn is closed on any error.
+func Handshake(ctx context.Context, conn net.Conn, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
+ if deadline, ok := ctx.Deadline(); ok {
+ if err := conn.SetDeadline(deadline); err != nil {
+ closeHandshake(conn, "conn after deadline error")
+ return nil, fmt.Errorf("set handshake deadline: %w", err)
+ }
+ }
+
+ sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
+ if err != nil {
+ closeHandshake(conn, "conn after handshake error")
+ return nil, fmt.Errorf("ssh handshake: %w", err)
+ }
+
+ if err := conn.SetDeadline(time.Time{}); err != nil {
+ closeHandshake(sshConn, "ssh conn after deadline clear error")
+ return nil, fmt.Errorf("clear handshake deadline: %w", err)
+ }
+
+ return ssh.NewClient(sshConn, chans, reqs), nil
+}
+
+func closeHandshake(c io.Closer, label string) {
+ if err := c.Close(); err != nil {
+ log.Debugf("ssh: close %s: %v", label, err)
+ }
+}
diff --git a/client/ssh/proxy/proxy.go b/client/ssh/proxy/proxy.go
index 73b50122c..070515b57 100644
--- a/client/ssh/proxy/proxy.go
+++ b/client/ssh/proxy/proxy.go
@@ -9,7 +9,6 @@ import (
"net"
"os"
"strconv"
- "strings"
"sync"
"time"
@@ -17,8 +16,8 @@ import (
log "github.com/sirupsen/logrus"
cryptossh "golang.org/x/crypto/ssh"
"google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
+ "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
nbssh "github.com/netbirdio/netbird/client/ssh"
@@ -55,8 +54,8 @@ type SSHProxy struct {
}
func New(daemonAddr, targetHost string, targetPort int, stderr io.Writer, browserOpener func(string) error) (*SSHProxy, error) {
- grpcAddr := strings.TrimPrefix(daemonAddr, "tcp://")
- grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ target, opts := daemonaddr.DialTarget(daemonAddr)
+ grpcConn, err := grpc.NewClient(target, opts...)
if err != nil {
return nil, fmt.Errorf("connect to daemon: %w", err)
}
@@ -611,13 +610,10 @@ func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string)
return nil, fmt.Errorf("connect to server: %w", err)
}
- clientConn, chans, reqs, err := cryptossh.NewClientConn(conn, addr, config)
- if err != nil {
- _ = conn.Close()
- return nil, fmt.Errorf("SSH handshake: %w", err)
- }
+ handshakeCtx, cancel := context.WithTimeout(ctx, sshHandshakeTimeout)
+ defer cancel()
- return cryptossh.NewClient(clientConn, chans, reqs), nil
+ return nbssh.Handshake(handshakeCtx, conn, addr, config)
}
func (p *SSHProxy) verifyHostKey(hostname string, remote net.Addr, key cryptossh.PublicKey) error {
diff --git a/client/ssh/proxy/proxy_privileged_test.go b/client/ssh/proxy/proxy_privileged_test.go
new file mode 100644
index 000000000..94495a3ae
--- /dev/null
+++ b/client/ssh/proxy/proxy_privileged_test.go
@@ -0,0 +1,423 @@
+//go:build privileged
+
+package proxy
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "math/big"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "runtime"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ cryptossh "golang.org/x/crypto/ssh"
+
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ sshauth "github.com/netbirdio/netbird/client/ssh/auth"
+ "github.com/netbirdio/netbird/client/ssh/server"
+ "github.com/netbirdio/netbird/client/ssh/testutil"
+ nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
+ sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
+)
+
+func (m *mockDaemon) setJWTToken(token string) {
+ m.impl.jwtToken = token
+}
+
+func TestSSHProxy_Connect(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ // TODO: Windows test times out - user switching and command execution tested on Linux
+ if runtime.GOOS == "windows" {
+ t.Skip("Skipping on Windows - covered by Linux tests")
+ }
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ audience = "test-audience"
+ )
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+ defer jwksServer.Close()
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+ hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
+ require.NoError(t, err)
+
+ serverConfig := &server.Config{
+ HostKeyPEM: hostKey,
+ JWT: &server.JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{audience},
+ KeysLocation: jwksURL,
+ },
+ }
+ sshServer := server.New(serverConfig)
+ sshServer.SetAllowRootLogin(true)
+
+ // Configure SSH authorization for the test user
+ testUsername := testutil.GetTestUsername(t)
+ testJWTUser := "test-username"
+ testUserHash, err := sshuserhash.HashUserID(testJWTUser)
+ require.NoError(t, err)
+
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ testUsername: {0}, // Index 0 in AuthorizedUsers
+ },
+ }
+ sshServer.UpdateSSHAuth(authConfig)
+
+ sshServerAddr := server.StartTestServer(t, sshServer)
+ defer func() { _ = sshServer.Stop() }()
+
+ mockDaemon := startMockDaemon(t)
+ defer mockDaemon.stop()
+
+ host, portStr, err := net.SplitHostPort(sshServerAddr)
+ require.NoError(t, err)
+ port, err := strconv.Atoi(portStr)
+ require.NoError(t, err)
+
+ mockDaemon.setHostKey(host, hostPubKey)
+
+ validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
+ mockDaemon.setJWTToken(validToken)
+
+ proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
+ require.NoError(t, err)
+
+ clientConn, proxyConn := net.Pipe()
+ defer func() { _ = clientConn.Close() }()
+
+ origStdin := os.Stdin
+ origStdout := os.Stdout
+ defer func() {
+ os.Stdin = origStdin
+ os.Stdout = origStdout
+ }()
+
+ stdinReader, stdinWriter, err := os.Pipe()
+ require.NoError(t, err)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ require.NoError(t, err)
+
+ os.Stdin = stdinReader
+ os.Stdout = stdoutWriter
+
+ go func() {
+ _, _ = io.Copy(stdinWriter, proxyConn)
+ }()
+ go func() {
+ _, _ = io.Copy(proxyConn, stdoutReader)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ connectErrCh := make(chan error, 1)
+ go func() {
+ connectErrCh <- proxyInstance.Connect(ctx)
+ }()
+
+ sshConfig := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{},
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 3 * time.Second,
+ }
+
+ sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
+ require.NoError(t, err, "Should connect to proxy server")
+ defer func() { _ = sshClientConn.Close() }()
+
+ sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
+
+ session, err := sshClient.NewSession()
+ require.NoError(t, err, "Should create session through full proxy to backend")
+
+ outputCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ output, err := session.Output("echo hello-from-proxy")
+ outputCh <- output
+ errCh <- err
+ }()
+
+ select {
+ case output := <-outputCh:
+ err := <-errCh
+ require.NoError(t, err, "Command should execute successfully through proxy")
+ assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
+ case <-time.After(3 * time.Second):
+ t.Fatal("Command execution timed out")
+ }
+
+ _ = session.Close()
+ _ = sshClient.Close()
+ _ = clientConn.Close()
+ cancel()
+}
+
+// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
+// when forwarding commands to the backend. This is critical for tools like
+// Ansible that send commands such as:
+//
+// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
+//
+// The single quotes must be preserved so the backend shell receives the
+// subshell expression as a single argument to -c.
+func TestSSHProxy_CommandQuoting(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ sshClient, cleanup := setupProxySSHClient(t)
+ defer cleanup()
+
+ // These commands simulate what the SSH protocol delivers as exec payloads.
+ // When a user types: ssh host '/bin/sh -c "( echo hello )"'
+ // the local shell strips the outer single quotes, and the SSH exec request
+ // contains the raw string: /bin/sh -c "( echo hello )"
+ //
+ // The proxy must forward this string verbatim. Using session.Command()
+ // (shlex.Split + strings.Join) strips the inner double quotes, breaking
+ // the command on the backend.
+ tests := []struct {
+ name string
+ command string
+ expect string
+ }{
+ {
+ name: "subshell_in_double_quotes",
+ command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
+ expect: "from-subshell\nouter\n",
+ },
+ {
+ name: "printf_with_special_chars",
+ command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
+ expect: "hello world\n",
+ },
+ {
+ name: "nested_command_substitution",
+ command: `/bin/sh -c "echo $(echo nested)"`,
+ expect: "nested\n",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ session, err := sshClient.NewSession()
+ require.NoError(t, err)
+ defer func() { _ = session.Close() }()
+
+ var stderrBuf bytes.Buffer
+ session.Stderr = &stderrBuf
+
+ outputCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ output, err := session.Output(tc.command)
+ outputCh <- output
+ errCh <- err
+ }()
+
+ select {
+ case output := <-outputCh:
+ err := <-errCh
+ if stderrBuf.Len() > 0 {
+ t.Logf("stderr: %s", stderrBuf.String())
+ }
+ require.NoError(t, err, "command should succeed: %s", tc.command)
+ assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
+ case <-time.After(5 * time.Second):
+ t.Fatalf("command timed out: %s", tc.command)
+ }
+ })
+ }
+}
+
+// setupProxySSHClient creates a full proxy test environment and returns
+// an SSH client connected through the proxy to a backend NetBird SSH server.
+func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
+ t.Helper()
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ audience = "test-audience"
+ )
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+ hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
+ require.NoError(t, err)
+
+ serverConfig := &server.Config{
+ HostKeyPEM: hostKey,
+ JWT: &server.JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{audience},
+ KeysLocation: jwksURL,
+ },
+ }
+ sshServer := server.New(serverConfig)
+ sshServer.SetAllowRootLogin(true)
+
+ testUsername := testutil.GetTestUsername(t)
+ testJWTUser := "test-username"
+ testUserHash, err := sshuserhash.HashUserID(testJWTUser)
+ require.NoError(t, err)
+
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ testUsername: {0},
+ },
+ }
+ sshServer.UpdateSSHAuth(authConfig)
+
+ sshServerAddr := server.StartTestServer(t, sshServer)
+
+ mockDaemon := startMockDaemon(t)
+
+ host, portStr, err := net.SplitHostPort(sshServerAddr)
+ require.NoError(t, err)
+ port, err := strconv.Atoi(portStr)
+ require.NoError(t, err)
+
+ mockDaemon.setHostKey(host, hostPubKey)
+
+ validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
+ mockDaemon.setJWTToken(validToken)
+
+ proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
+ require.NoError(t, err)
+
+ origStdin := os.Stdin
+ origStdout := os.Stdout
+
+ stdinReader, stdinWriter, err := os.Pipe()
+ require.NoError(t, err)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ require.NoError(t, err)
+
+ os.Stdin = stdinReader
+ os.Stdout = stdoutWriter
+
+ clientConn, proxyConn := net.Pipe()
+
+ go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
+ go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
+ go func() {
+ _ = proxyInstance.Connect(ctx)
+ }()
+
+ sshConfig := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{},
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 5 * time.Second,
+ }
+
+ sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
+ require.NoError(t, err)
+
+ client := cryptossh.NewClient(sshClientConn, chans, reqs)
+
+ cleanupFn := func() {
+ _ = client.Close()
+ _ = clientConn.Close()
+ cancel()
+ os.Stdin = origStdin
+ os.Stdout = origStdout
+ _ = sshServer.Stop()
+ mockDaemon.stop()
+ jwksServer.Close()
+ }
+
+ return client, cleanupFn
+}
+
+func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
+ t.Helper()
+ privateKey, jwksJSON := generateTestJWKS(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(jwksJSON); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }))
+
+ return server, privateKey, server.URL
+}
+
+func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
+ t.Helper()
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ publicKey := &privateKey.PublicKey
+ n := publicKey.N.Bytes()
+ e := publicKey.E
+
+ jwk := nbjwt.JSONWebKey{
+ Kty: "RSA",
+ Kid: "test-key-id",
+ Use: "sig",
+ N: base64.RawURLEncoding.EncodeToString(n),
+ E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
+ }
+
+ jwks := nbjwt.Jwks{
+ Keys: []nbjwt.JSONWebKey{jwk},
+ }
+
+ jwksJSON, err := json.Marshal(jwks)
+ require.NoError(t, err)
+
+ return privateKey, jwksJSON
+}
+
+func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
+ t.Helper()
+ claims := jwt.MapClaims{
+ "iss": issuer,
+ "aud": audience,
+ "sub": user,
+ "exp": time.Now().Add(time.Hour).Unix(),
+ "iat": time.Now().Unix(),
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+ token.Header["kid"] = "test-key-id"
+
+ tokenString, err := token.SignedString(privateKey)
+ require.NoError(t, err)
+
+ return tokenString
+}
diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go
index b33d5f8f4..2795c786b 100644
--- a/client/ssh/proxy/proxy_test.go
+++ b/client/ssh/proxy/proxy_test.go
@@ -1,25 +1,12 @@
package proxy
import (
- "bytes"
"context"
- "crypto/rand"
- "crypto/rsa"
- "encoding/base64"
- "encoding/json"
"fmt"
- "io"
- "math/big"
"net"
- "net/http"
- "net/http/httptest"
"os"
- "runtime"
- "strconv"
"testing"
- "time"
- "github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
cryptossh "golang.org/x/crypto/ssh"
@@ -28,11 +15,7 @@ import (
"github.com/netbirdio/netbird/client/proto"
nbssh "github.com/netbirdio/netbird/client/ssh"
- sshauth "github.com/netbirdio/netbird/client/ssh/auth"
- "github.com/netbirdio/netbird/client/ssh/server"
"github.com/netbirdio/netbird/client/ssh/testutil"
- nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
- sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
)
func TestMain(m *testing.M) {
@@ -106,331 +89,6 @@ func TestSSHProxy_verifyHostKey(t *testing.T) {
})
}
-func TestSSHProxy_Connect(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping integration test in short mode")
- }
-
- // TODO: Windows test times out - user switching and command execution tested on Linux
- if runtime.GOOS == "windows" {
- t.Skip("Skipping on Windows - covered by Linux tests")
- }
-
- const (
- issuer = "https://test-issuer.example.com"
- audience = "test-audience"
- )
-
- jwksServer, privateKey, jwksURL := setupJWKSServer(t)
- defer jwksServer.Close()
-
- hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- require.NoError(t, err)
- hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
- require.NoError(t, err)
-
- serverConfig := &server.Config{
- HostKeyPEM: hostKey,
- JWT: &server.JWTConfig{
- Issuer: issuer,
- Audiences: []string{audience},
- KeysLocation: jwksURL,
- },
- }
- sshServer := server.New(serverConfig)
- sshServer.SetAllowRootLogin(true)
-
- // Configure SSH authorization for the test user
- testUsername := testutil.GetTestUsername(t)
- testJWTUser := "test-username"
- testUserHash, err := sshuserhash.HashUserID(testJWTUser)
- require.NoError(t, err)
-
- authConfig := &sshauth.Config{
- UserIDClaim: sshauth.DefaultUserIDClaim,
- AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
- MachineUsers: map[string][]uint32{
- testUsername: {0}, // Index 0 in AuthorizedUsers
- },
- }
- sshServer.UpdateSSHAuth(authConfig)
-
- sshServerAddr := server.StartTestServer(t, sshServer)
- defer func() { _ = sshServer.Stop() }()
-
- mockDaemon := startMockDaemon(t)
- defer mockDaemon.stop()
-
- host, portStr, err := net.SplitHostPort(sshServerAddr)
- require.NoError(t, err)
- port, err := strconv.Atoi(portStr)
- require.NoError(t, err)
-
- mockDaemon.setHostKey(host, hostPubKey)
-
- validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
- mockDaemon.setJWTToken(validToken)
-
- proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
- require.NoError(t, err)
-
- clientConn, proxyConn := net.Pipe()
- defer func() { _ = clientConn.Close() }()
-
- origStdin := os.Stdin
- origStdout := os.Stdout
- defer func() {
- os.Stdin = origStdin
- os.Stdout = origStdout
- }()
-
- stdinReader, stdinWriter, err := os.Pipe()
- require.NoError(t, err)
- stdoutReader, stdoutWriter, err := os.Pipe()
- require.NoError(t, err)
-
- os.Stdin = stdinReader
- os.Stdout = stdoutWriter
-
- go func() {
- _, _ = io.Copy(stdinWriter, proxyConn)
- }()
- go func() {
- _, _ = io.Copy(proxyConn, stdoutReader)
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- connectErrCh := make(chan error, 1)
- go func() {
- connectErrCh <- proxyInstance.Connect(ctx)
- }()
-
- sshConfig := &cryptossh.ClientConfig{
- User: testutil.GetTestUsername(t),
- Auth: []cryptossh.AuthMethod{},
- HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
- Timeout: 3 * time.Second,
- }
-
- sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
- require.NoError(t, err, "Should connect to proxy server")
- defer func() { _ = sshClientConn.Close() }()
-
- sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
-
- session, err := sshClient.NewSession()
- require.NoError(t, err, "Should create session through full proxy to backend")
-
- outputCh := make(chan []byte, 1)
- errCh := make(chan error, 1)
- go func() {
- output, err := session.Output("echo hello-from-proxy")
- outputCh <- output
- errCh <- err
- }()
-
- select {
- case output := <-outputCh:
- err := <-errCh
- require.NoError(t, err, "Command should execute successfully through proxy")
- assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
- case <-time.After(3 * time.Second):
- t.Fatal("Command execution timed out")
- }
-
- _ = session.Close()
- _ = sshClient.Close()
- _ = clientConn.Close()
- cancel()
-}
-
-// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
-// when forwarding commands to the backend. This is critical for tools like
-// Ansible that send commands such as:
-//
-// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
-//
-// The single quotes must be preserved so the backend shell receives the
-// subshell expression as a single argument to -c.
-func TestSSHProxy_CommandQuoting(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping integration test in short mode")
- }
-
- sshClient, cleanup := setupProxySSHClient(t)
- defer cleanup()
-
- // These commands simulate what the SSH protocol delivers as exec payloads.
- // When a user types: ssh host '/bin/sh -c "( echo hello )"'
- // the local shell strips the outer single quotes, and the SSH exec request
- // contains the raw string: /bin/sh -c "( echo hello )"
- //
- // The proxy must forward this string verbatim. Using session.Command()
- // (shlex.Split + strings.Join) strips the inner double quotes, breaking
- // the command on the backend.
- tests := []struct {
- name string
- command string
- expect string
- }{
- {
- name: "subshell_in_double_quotes",
- command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
- expect: "from-subshell\nouter\n",
- },
- {
- name: "printf_with_special_chars",
- command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
- expect: "hello world\n",
- },
- {
- name: "nested_command_substitution",
- command: `/bin/sh -c "echo $(echo nested)"`,
- expect: "nested\n",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- session, err := sshClient.NewSession()
- require.NoError(t, err)
- defer func() { _ = session.Close() }()
-
- var stderrBuf bytes.Buffer
- session.Stderr = &stderrBuf
-
- outputCh := make(chan []byte, 1)
- errCh := make(chan error, 1)
- go func() {
- output, err := session.Output(tc.command)
- outputCh <- output
- errCh <- err
- }()
-
- select {
- case output := <-outputCh:
- err := <-errCh
- if stderrBuf.Len() > 0 {
- t.Logf("stderr: %s", stderrBuf.String())
- }
- require.NoError(t, err, "command should succeed: %s", tc.command)
- assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
- case <-time.After(5 * time.Second):
- t.Fatalf("command timed out: %s", tc.command)
- }
- })
- }
-}
-
-// setupProxySSHClient creates a full proxy test environment and returns
-// an SSH client connected through the proxy to a backend NetBird SSH server.
-func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
- t.Helper()
-
- const (
- issuer = "https://test-issuer.example.com"
- audience = "test-audience"
- )
-
- jwksServer, privateKey, jwksURL := setupJWKSServer(t)
-
- hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- require.NoError(t, err)
- hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
- require.NoError(t, err)
-
- serverConfig := &server.Config{
- HostKeyPEM: hostKey,
- JWT: &server.JWTConfig{
- Issuer: issuer,
- Audiences: []string{audience},
- KeysLocation: jwksURL,
- },
- }
- sshServer := server.New(serverConfig)
- sshServer.SetAllowRootLogin(true)
-
- testUsername := testutil.GetTestUsername(t)
- testJWTUser := "test-username"
- testUserHash, err := sshuserhash.HashUserID(testJWTUser)
- require.NoError(t, err)
-
- authConfig := &sshauth.Config{
- UserIDClaim: sshauth.DefaultUserIDClaim,
- AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
- MachineUsers: map[string][]uint32{
- testUsername: {0},
- },
- }
- sshServer.UpdateSSHAuth(authConfig)
-
- sshServerAddr := server.StartTestServer(t, sshServer)
-
- mockDaemon := startMockDaemon(t)
-
- host, portStr, err := net.SplitHostPort(sshServerAddr)
- require.NoError(t, err)
- port, err := strconv.Atoi(portStr)
- require.NoError(t, err)
-
- mockDaemon.setHostKey(host, hostPubKey)
-
- validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
- mockDaemon.setJWTToken(validToken)
-
- proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
- require.NoError(t, err)
-
- origStdin := os.Stdin
- origStdout := os.Stdout
-
- stdinReader, stdinWriter, err := os.Pipe()
- require.NoError(t, err)
- stdoutReader, stdoutWriter, err := os.Pipe()
- require.NoError(t, err)
-
- os.Stdin = stdinReader
- os.Stdout = stdoutWriter
-
- clientConn, proxyConn := net.Pipe()
-
- go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
- go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-
- go func() {
- _ = proxyInstance.Connect(ctx)
- }()
-
- sshConfig := &cryptossh.ClientConfig{
- User: testutil.GetTestUsername(t),
- Auth: []cryptossh.AuthMethod{},
- HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
- Timeout: 5 * time.Second,
- }
-
- sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
- require.NoError(t, err)
-
- client := cryptossh.NewClient(sshClientConn, chans, reqs)
-
- cleanupFn := func() {
- _ = client.Close()
- _ = clientConn.Close()
- cancel()
- os.Stdin = origStdin
- os.Stdout = origStdout
- _ = sshServer.Stop()
- mockDaemon.stop()
- jwksServer.Close()
- }
-
- return client, cleanupFn
-}
-
type mockDaemonServer struct {
proto.UnimplementedDaemonServiceServer
hostKeys map[string][]byte
@@ -492,10 +150,6 @@ func (m *mockDaemon) setHostKey(addr string, pubKey []byte) {
m.impl.hostKeys[addr] = pubKey
}
-func (m *mockDaemon) setJWTToken(token string) {
- m.impl.jwtToken = token
-}
-
func (m *mockDaemon) stop() {
if m.server != nil {
m.server.Stop()
@@ -508,63 +162,3 @@ func mustParsePublicKey(t *testing.T, pubKeyBytes []byte) cryptossh.PublicKey {
require.NoError(t, err)
return pubKey
}
-
-func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
- t.Helper()
- privateKey, jwksJSON := generateTestJWKS(t)
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- if _, err := w.Write(jwksJSON); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
- }))
-
- return server, privateKey, server.URL
-}
-
-func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
- t.Helper()
- privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
- require.NoError(t, err)
-
- publicKey := &privateKey.PublicKey
- n := publicKey.N.Bytes()
- e := publicKey.E
-
- jwk := nbjwt.JSONWebKey{
- Kty: "RSA",
- Kid: "test-key-id",
- Use: "sig",
- N: base64.RawURLEncoding.EncodeToString(n),
- E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
- }
-
- jwks := nbjwt.Jwks{
- Keys: []nbjwt.JSONWebKey{jwk},
- }
-
- jwksJSON, err := json.Marshal(jwks)
- require.NoError(t, err)
-
- return privateKey, jwksJSON
-}
-
-func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
- t.Helper()
- claims := jwt.MapClaims{
- "iss": issuer,
- "aud": audience,
- "sub": user,
- "exp": time.Now().Add(time.Hour).Unix(),
- "iat": time.Now().Unix(),
- }
-
- token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
- token.Header["kid"] = "test-key-id"
-
- tokenString, err := token.SignedString(privateKey)
- require.NoError(t, err)
-
- return tokenString
-}
diff --git a/client/ssh/server/command_execution.go b/client/ssh/server/command_execution.go
index b0a85fe4b..c8b3240d0 100644
--- a/client/ssh/server/command_execution.go
+++ b/client/ssh/server/command_execution.go
@@ -75,8 +75,8 @@ func (s *Server) createCommand(logger *log.Entry, privilegeResult PrivilegeCheck
}
// Try su first for system integration (PAM/audit) when privileged
- cmd, err := s.createSuCommand(logger, session, localUser, hasPty)
- if err != nil || privilegeResult.UsedFallback {
+ cmd, err := s.createSuCommand(logger, session, localUser, hasPty) //nolint:staticcheck
+ if err != nil || privilegeResult.UsedFallback { //nolint:staticcheck // always errors on platforms without su
logger.Debugf("su command failed, falling back to executor: %v", err)
cmd, cleanup, err := s.createExecutorCommand(logger, session, localUser, hasPty)
if err != nil {
diff --git a/client/ssh/server/command_execution_windows.go b/client/ssh/server/command_execution_windows.go
index e1ba777f6..feb8daa26 100644
--- a/client/ssh/server/command_execution_windows.go
+++ b/client/ssh/server/command_execution_windows.go
@@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi
// prepareCommandEnv prepares environment variables for command execution on Windows
func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string {
- username, domain := s.parseUsername(localUser.Username)
+ username, domain := parseUsername(localUser.Username)
userEnv, err := s.getUserEnvironment(logger, username, domain)
if err != nil {
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
@@ -383,7 +383,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
return false
}
- username, domain := s.parseUsername(localUser.Username)
+ username, domain := parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
req := PtyExecutionRequest{
diff --git a/client/ssh/server/executor_unix_privileged_test.go b/client/ssh/server/executor_unix_privileged_test.go
new file mode 100644
index 000000000..f1b0805d9
--- /dev/null
+++ b/client/ssh/server/executor_unix_privileged_test.go
@@ -0,0 +1,66 @@
+//go:build unix && privileged
+
+package server
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
+ pd := NewPrivilegeDropper()
+
+ config := ExecutorConfig{
+ UID: 1000,
+ GID: 1000,
+ Groups: []uint32{1000, 1001},
+ WorkingDir: "/home/testuser",
+ Shell: "/bin/bash",
+ Command: "ls -la",
+ }
+
+ cmd, err := pd.CreateExecutorCommand(context.Background(), config)
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+
+ // Verify the command is calling netbird ssh exec
+ assert.Contains(t, cmd.Args, "ssh")
+ assert.Contains(t, cmd.Args, "exec")
+ assert.Contains(t, cmd.Args, "--uid")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "--gid")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "--groups")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "1001")
+ assert.Contains(t, cmd.Args, "--working-dir")
+ assert.Contains(t, cmd.Args, "/home/testuser")
+ assert.Contains(t, cmd.Args, "--shell")
+ assert.Contains(t, cmd.Args, "/bin/bash")
+ assert.Contains(t, cmd.Args, "--cmd")
+ assert.Contains(t, cmd.Args, "ls -la")
+}
+
+func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
+ pd := NewPrivilegeDropper()
+
+ config := ExecutorConfig{
+ UID: 1000,
+ GID: 1000,
+ Groups: []uint32{1000},
+ WorkingDir: "/home/testuser",
+ Shell: "/bin/bash",
+ Command: "",
+ }
+
+ cmd, err := pd.CreateExecutorCommand(context.Background(), config)
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+
+ // Verify no command mode (command is empty so no --cmd flag)
+ assert.NotContains(t, cmd.Args, "--cmd")
+ assert.NotContains(t, cmd.Args, "--interactive")
+}
diff --git a/client/ssh/server/executor_unix_test.go b/client/ssh/server/executor_unix_test.go
index 0c5108f57..171e78b83 100644
--- a/client/ssh/server/executor_unix_test.go
+++ b/client/ssh/server/executor_unix_test.go
@@ -73,61 +73,6 @@ func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) {
}
}
-func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
- pd := NewPrivilegeDropper()
-
- config := ExecutorConfig{
- UID: 1000,
- GID: 1000,
- Groups: []uint32{1000, 1001},
- WorkingDir: "/home/testuser",
- Shell: "/bin/bash",
- Command: "ls -la",
- }
-
- cmd, err := pd.CreateExecutorCommand(context.Background(), config)
- require.NoError(t, err)
- require.NotNil(t, cmd)
-
- // Verify the command is calling netbird ssh exec
- assert.Contains(t, cmd.Args, "ssh")
- assert.Contains(t, cmd.Args, "exec")
- assert.Contains(t, cmd.Args, "--uid")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "--gid")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "--groups")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "1001")
- assert.Contains(t, cmd.Args, "--working-dir")
- assert.Contains(t, cmd.Args, "/home/testuser")
- assert.Contains(t, cmd.Args, "--shell")
- assert.Contains(t, cmd.Args, "/bin/bash")
- assert.Contains(t, cmd.Args, "--cmd")
- assert.Contains(t, cmd.Args, "ls -la")
-}
-
-func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
- pd := NewPrivilegeDropper()
-
- config := ExecutorConfig{
- UID: 1000,
- GID: 1000,
- Groups: []uint32{1000},
- WorkingDir: "/home/testuser",
- Shell: "/bin/bash",
- Command: "",
- }
-
- cmd, err := pd.CreateExecutorCommand(context.Background(), config)
- require.NoError(t, err)
- require.NotNil(t, cmd)
-
- // Verify no command mode (command is empty so no --cmd flag)
- assert.NotContains(t, cmd.Args, "--cmd")
- assert.NotContains(t, cmd.Args, "--interactive")
-}
-
// TestPrivilegeDropper_ActualPrivilegeDrop tests actual privilege dropping
// This test requires root privileges and will be skipped if not running as root
func TestPrivilegeDropper_ActualPrivilegeDrop(t *testing.T) {
diff --git a/client/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go
index 18edb2fdf..a3a9641f8 100644
--- a/client/ssh/server/getent_unix.go
+++ b/client/ssh/server/getent_unix.go
@@ -69,7 +69,8 @@ func parseGetentPasswd(output string) (*user.User, string, error) {
// validateGetentInput checks that the input is safe to pass to getent or id.
// Allows POSIX usernames, numeric UIDs, and common NSS extensions
-// (@ for Kerberos, $ for Samba, + for NIS compat).
+// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is
+// rejected so the input can never be parsed as a command-line flag.
func validateGetentInput(input string) bool {
maxLen := 32
if runtime.GOOS == "linux" {
@@ -80,6 +81,10 @@ func validateGetentInput(input string) bool {
return false
}
+ if input[0] == '-' {
+ return false
+ }
+
for _, r := range input {
if isAllowedGetentChar(r) {
continue
diff --git a/client/ssh/server/getent_unix_test.go b/client/ssh/server/getent_unix_test.go
index e44563b79..a73214e17 100644
--- a/client/ssh/server/getent_unix_test.go
+++ b/client/ssh/server/getent_unix_test.go
@@ -157,6 +157,9 @@ func TestValidateGetentInput(t *testing.T) {
{"numeric UID", "1001", true},
{"dots and underscores", "alice.bob_test", true},
{"hyphen", "alice-bob", true},
+ {"leading hyphen rejected", "-i", false},
+ {"leading double hyphen rejected", "--no-idn", false},
+ {"lone hyphen rejected", "-", false},
{"kerberos principal", "user@REALM", true},
{"samba machine account", "MACHINE$", true},
{"NIS compat", "+user", true},
diff --git a/client/ssh/server/port_forwarding.go b/client/ssh/server/port_forwarding.go
index a47fdb48a..81d3b9173 100644
--- a/client/ssh/server/port_forwarding.go
+++ b/client/ssh/server/port_forwarding.go
@@ -133,7 +133,12 @@ func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, resu
return nil
}
- if result.User != nil && isPrivilegedUsername(result.User.Username) {
+ // Only uid 0 may bind below the threshold, which is the kernel's own rule and
+ // is asked directly rather than through isPrivilegedOrUnknown: that helper
+ // reports an account it cannot evaluate as privileged, which is safe for a
+ // refusal and unsafe for a grant such as this one. Windows has returned
+ // above, so Uid here is a Unix uid and never a SID.
+ if result.User != nil && result.User.Uid == "0" {
return nil
}
diff --git a/client/ssh/server/privileges_other.go b/client/ssh/server/privileges_other.go
new file mode 100644
index 000000000..89440dea8
--- /dev/null
+++ b/client/ssh/server/privileges_other.go
@@ -0,0 +1,16 @@
+//go:build !windows
+
+package server
+
+// isProcessElevated is only meaningful on Windows; other platforms use the
+// effective UID check in isCurrentProcessPrivileged.
+func isProcessElevated() bool {
+ return false
+}
+
+// isWindowsAccountPrivilegedOrUnknown is only reachable on Windows. Report
+// privileged on other platforms so a caller refusing privileged accounts fails
+// closed.
+func isWindowsAccountPrivilegedOrUnknown(string) bool {
+ return true
+}
diff --git a/client/ssh/server/privileges_windows.go b/client/ssh/server/privileges_windows.go
new file mode 100644
index 000000000..41ea00fd7
--- /dev/null
+++ b/client/ssh/server/privileges_windows.go
@@ -0,0 +1,228 @@
+//go:build windows
+
+package server
+
+import (
+ "fmt"
+ "strings"
+ "unsafe"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows"
+)
+
+var (
+ netapi32 = windows.NewLazySystemDLL("netapi32.dll")
+ procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups")
+)
+
+const (
+ // lgIncludeIndirect makes NetUserGetLocalGroups also return local groups
+ // the user belongs to through a global group.
+ lgIncludeIndirect = 0x1
+ maxPreferredLength = 0xFFFFFFFF
+)
+
+// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0.
+type localGroupUsersInfo0 struct {
+ name *uint16
+}
+
+// isProcessElevated reports whether the current process token is elevated
+// (TokenElevation): true for elevated administrators, the built-in
+// Administrator, administrators with UAC disabled, and SYSTEM; false for
+// standard users and administrators running with a UAC-filtered token.
+func isProcessElevated() bool {
+ return windows.GetCurrentProcessToken().IsElevated()
+}
+
+// isWindowsAccountPrivilegedOrUnknown reports whether the account is privileged
+// on this machine: a well-known service account, a built-in Administrator
+// (RID 500), or a member of the local Administrators group, directly or through
+// nested groups.
+//
+// An account whose privilege cannot be determined counts as privileged, which
+// is why the name says "or unknown". That is fail-closed for a caller that
+// refuses privileged accounts, and fail-open for a caller that grants something
+// to them, so only the former may use this.
+func isWindowsAccountPrivilegedOrUnknown(username string) bool {
+ sid, _, _, err := windows.LookupSID("", username)
+ if err != nil {
+ log.Warnf("privilege check: SID lookup for %q failed, treating as privileged: %v", username, err)
+ return true
+ }
+
+ if isPrivilegedUserSID(sid) {
+ return true
+ }
+
+ member, err := isLocalAdminsMember(username)
+ if err != nil {
+ log.Warnf("privilege check: cannot determine Administrators membership for %q, treating as privileged: %v", username, err)
+ return true
+ }
+ return member
+}
+
+// isPrivilegedUserSID reports whether the SID itself identifies a privileged
+// principal, without consulting group membership.
+func isPrivilegedUserSID(sid *windows.SID) bool {
+ wellKnown := []windows.WELL_KNOWN_SID_TYPE{
+ windows.WinLocalSystemSid,
+ windows.WinLocalServiceSid,
+ windows.WinNetworkServiceSid,
+ windows.WinBuiltinAdministratorsSid,
+ }
+ for _, sidType := range wellKnown {
+ if sid.IsWellKnown(sidType) {
+ return true
+ }
+ }
+ return isBuiltinAdministratorSID(sid)
+}
+
+// isBuiltinAdministratorSID reports whether the SID is a machine or domain
+// built-in Administrator account (S-1-5-21-...-500). RID 500 is reserved for
+// that account; it can be renamed but cannot be removed from the
+// Administrators group.
+func isBuiltinAdministratorSID(sid *windows.SID) bool {
+ if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
+ return false
+ }
+ count := sid.SubAuthorityCount()
+ if count < 2 || sid.SubAuthority(0) != 21 {
+ return false
+ }
+ return sid.SubAuthority(uint32(count-1)) == 500
+}
+
+// isLocalAdminsMember reports whether the account is a member of the local
+// Administrators group.
+//
+// Local accounts are checked against the local SAM, which is authoritative for
+// them and, unlike a token, cannot under-report: UAC filters the tokens of
+// local administrators, and a filtered token carries Administrators as
+// deny-only, which a membership check on the token would read as "not a
+// member". Domain accounts are exempt from that filtering, so for them an S4U
+// token is preferred because its group list is LSA's transitive expansion and
+// therefore covers nested and universal groups plus the machine's own local
+// groups. NetUserGetLocalGroups expands only one global-group hop but needs no
+// logon, so it serves as the fallback when no token can be obtained.
+func isLocalAdminsMember(username string) (bool, error) {
+ adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
+ if err != nil {
+ return false, fmt.Errorf("create Administrators SID: %w", err)
+ }
+
+ account, domain := parseUsername(username)
+ if NewPrivilegeDropper().isLocalUser(domain) {
+ return localGroupsContainSID(account, adminSid)
+ }
+
+ member, s4uErr := s4uTokenIsMember(account, domain, adminSid)
+ if s4uErr == nil {
+ return member, nil
+ }
+ log.Debugf("privilege check: S4U membership check for %q failed, falling back to local group enumeration: %v", username, s4uErr)
+
+ member, err = localGroupsContainSID(buildUserCpn(account, domain), adminSid)
+ if err != nil {
+ return false, fmt.Errorf("S4U check: %w; local group enumeration: %w", s4uErr, err)
+ }
+ return member, nil
+}
+
+// s4uTokenIsMember obtains an S4U token for the account and checks whether the
+// given SID is enabled in it.
+func s4uTokenIsMember(account, domain string, sid *windows.SID) (bool, error) {
+ token, err := generateS4UUserToken(log.NewEntry(log.StandardLogger()), account, domain)
+ if err != nil {
+ return false, err
+ }
+ defer func() {
+ if err := windows.CloseHandle(token); err != nil {
+ log.Debugf("close S4U token: %v", err)
+ }
+ }()
+ return windows.Token(token).IsMember(sid)
+}
+
+// localGroupsContainSID reports whether the wanted group is among the local
+// groups the account belongs to, directly or through a global group.
+//
+// The wanted SID is resolved to its group name once and compared against the
+// enumerated names. Well-known SIDs resolve from a static table, so that lookup
+// needs no domain controller, and it keeps the comparison correct for a renamed
+// or localized group because both sides then carry the new name. Resolving each
+// enumerated name back to a SID instead would add a lookup per group that can
+// block until it times out while a domain controller is unreachable, and cannot
+// change the outcome: the names enumerated here are local groups of this
+// machine, whose names are unique, so a name match identifies the group.
+//
+// A failure to resolve the wanted SID is returned rather than reported as
+// "not a member", so a privilege check built on this fails closed.
+func localGroupsContainSID(username string, want *windows.SID) (bool, error) {
+ wantName, _, _, err := want.LookupAccount("")
+ if err != nil {
+ return false, fmt.Errorf("resolve group SID %s to a name: %w", want, err)
+ }
+
+ groups, err := netUserGetLocalGroups(username)
+ if err != nil {
+ return false, err
+ }
+
+ for _, group := range groups {
+ if strings.EqualFold(group, wantName) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// netUserGetLocalGroups returns the names of the local groups the account is a
+// member of, including indirect membership through global groups.
+func netUserGetLocalGroups(username string) ([]string, error) {
+ name16, err := windows.UTF16PtrFromString(username)
+ if err != nil {
+ return nil, fmt.Errorf("convert username: %w", err)
+ }
+
+ var buf *byte
+ var entriesRead, totalEntries uint32
+ status, _, _ := procNetUserGetLocalGroups.Call(
+ 0, // local server
+ uintptr(unsafe.Pointer(name16)),
+ 0, // level 0: LOCALGROUP_USERS_INFO_0
+ lgIncludeIndirect,
+ uintptr(unsafe.Pointer(&buf)),
+ maxPreferredLength,
+ uintptr(unsafe.Pointer(&entriesRead)),
+ uintptr(unsafe.Pointer(&totalEntries)),
+ )
+ if status != 0 {
+ return nil, fmt.Errorf("NetUserGetLocalGroups for %q: status %d", username, status)
+ }
+ if buf == nil {
+ return nil, nil
+ }
+ defer func() {
+ if err := windows.NetApiBufferFree(buf); err != nil {
+ log.Debugf("free NetApi buffer: %v", err)
+ }
+ }()
+
+ // MAX_PREFERRED_LENGTH makes the API allocate as much as it needs, so a
+ // short read is not expected. Report it rather than silently returning a
+ // subset of the account's groups.
+ if entriesRead != totalEntries {
+ return nil, fmt.Errorf("NetUserGetLocalGroups for %q returned %d of %d groups", username, entriesRead, totalEntries)
+ }
+
+ entries := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buf)), entriesRead)
+ groups := make([]string, 0, entriesRead)
+ for _, entry := range entries {
+ groups = append(groups, windows.UTF16PtrToString(entry.name))
+ }
+ return groups, nil
+}
diff --git a/client/ssh/server/privileges_windows_test.go b/client/ssh/server/privileges_windows_test.go
new file mode 100644
index 000000000..983fccdf7
--- /dev/null
+++ b/client/ssh/server/privileges_windows_test.go
@@ -0,0 +1,293 @@
+//go:build windows
+
+package server
+
+import (
+ "os/user"
+ "testing"
+ "unsafe"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/sys/windows"
+)
+
+// filterNormalAccount limits NetUserEnum to normal user accounts.
+const filterNormalAccount = 0x2
+
+// TOKEN_ELEVATION_TYPE values.
+const (
+ tokenElevationTypeDefault = 1
+ tokenElevationTypeFull = 2
+ tokenElevationTypeLimited = 3
+)
+
+// tokenElevationType reads TokenElevationType from a token.
+func tokenElevationType(token windows.Token) (uint32, error) {
+ var elevationType, returnedLen uint32
+ err := windows.GetTokenInformation(token, windows.TokenElevationType,
+ (*byte)(unsafe.Pointer(&elevationType)), uint32(unsafe.Sizeof(elevationType)), &returnedLen)
+ if err != nil {
+ return 0, err
+ }
+ return elevationType, nil
+}
+
+// userInfo0 mirrors USER_INFO_0.
+type userInfo0 struct {
+ name *uint16
+}
+
+func mustParseSID(t *testing.T, s string) *windows.SID {
+ t.Helper()
+ sid, err := windows.StringToSid(s)
+ require.NoError(t, err, "parse SID %s", s)
+ return sid
+}
+
+// localAccountNames returns the names of the local user accounts.
+func localAccountNames(t *testing.T) []string {
+ t.Helper()
+
+ var buf *byte
+ var entriesRead, totalEntries, resume uint32
+ err := windows.NetUserEnum(nil, 0, filterNormalAccount, &buf, maxPreferredLength,
+ &entriesRead, &totalEntries, &resume)
+ require.NoError(t, err, "enumerate local users")
+ t.Cleanup(func() {
+ require.NoError(t, windows.NetApiBufferFree(buf), "free NetApi buffer")
+ })
+
+ entries := unsafe.Slice((*userInfo0)(unsafe.Pointer(buf)), entriesRead)
+ names := make([]string, 0, entriesRead)
+ for _, entry := range entries {
+ names = append(names, windows.UTF16PtrToString(entry.name))
+ }
+ return names
+}
+
+// localAccountNameByRID returns the name of the local account carrying the
+// given RID. Accounts such as Administrator and Guest can be renamed and are
+// localized, so tests must not name them literally.
+func localAccountNameByRID(t *testing.T, rid uint32) string {
+ t.Helper()
+
+ for _, name := range localAccountNames(t) {
+ sid, _, _, err := windows.LookupSID("", name)
+ if err != nil {
+ continue
+ }
+ if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
+ continue
+ }
+ count := sid.SubAuthorityCount()
+ if count < 2 || sid.SubAuthority(0) != 21 {
+ continue
+ }
+ if sid.SubAuthority(uint32(count-1)) == rid {
+ return name
+ }
+ }
+
+ t.Fatalf("no local account with RID %d", rid)
+ return ""
+}
+
+// wellKnownAccountName resolves a well-known SID to the qualified account name
+// the local system uses for it, which is localized.
+func wellKnownAccountName(t *testing.T, sidType windows.WELL_KNOWN_SID_TYPE) string {
+ t.Helper()
+
+ sid, err := windows.CreateWellKnownSid(sidType)
+ require.NoError(t, err, "create well-known SID")
+ name, domain, _, err := sid.LookupAccount("")
+ require.NoError(t, err, "resolve %s to an account name", sid)
+ if domain == "" {
+ return name
+ }
+ return domain + `\` + name
+}
+
+func TestIsBuiltinAdministratorSID(t *testing.T) {
+ tests := []struct {
+ name string
+ sid string
+ want bool
+ }{
+ {"machine_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
+ {"domain_administrator", "S-1-5-21-3390233681-4087452608-412898826-500", true},
+ {"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
+ {"guest_account", "S-1-5-21-1111111111-2222222222-3333333333-501", false},
+ {"domain_admins_group", "S-1-5-21-1111111111-2222222222-3333333333-512", false},
+ {"system", "S-1-5-18", false},
+ {"administrators_group", "S-1-5-32-544", false},
+ {"non_nt_authority", "S-1-1-0", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := isBuiltinAdministratorSID(mustParseSID(t, tt.sid))
+ assert.Equal(t, tt.want, result, "RID 500 detection for %s", tt.sid)
+ })
+ }
+}
+
+func TestIsPrivilegedUserSID(t *testing.T) {
+ tests := []struct {
+ name string
+ sid string
+ want bool
+ }{
+ {"local_system", "S-1-5-18", true},
+ {"local_service", "S-1-5-19", true},
+ {"network_service", "S-1-5-20", true},
+ {"administrators_group", "S-1-5-32-544", true},
+ {"builtin_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
+ {"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
+ {"users_group", "S-1-5-32-545", false},
+ {"everyone", "S-1-1-0", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := isPrivilegedUserSID(mustParseSID(t, tt.sid))
+ assert.Equal(t, tt.want, result, "SID privilege classification for %s", tt.sid)
+ })
+ }
+}
+
+func TestIsWindowsAccountPrivilegedOrUnknown(t *testing.T) {
+ tests := []struct {
+ name string
+ username string
+ want bool
+ }{
+ {"system", wellKnownAccountName(t, windows.WinLocalSystemSid), true},
+ {"local_service", wellKnownAccountName(t, windows.WinLocalServiceSid), true},
+ {"network_service", wellKnownAccountName(t, windows.WinNetworkServiceSid), true},
+ {"administrators_group", wellKnownAccountName(t, windows.WinBuiltinAdministratorsSid), true},
+ // The built-in Administrator (RID 500) and Guest (RID 501) accounts
+ // exist on every Windows installation, though they may be disabled.
+ {"builtin_administrator", localAccountNameByRID(t, 500), true},
+ {"guest", localAccountNameByRID(t, 501), false},
+ // Unresolvable accounts fail closed.
+ {"nonexistent_user", "netbird-no-such-user", true},
+ {"empty_username", "", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := isWindowsAccountPrivilegedOrUnknown(tt.username)
+ assert.Equal(t, tt.want, result, "account privilege classification for %q", tt.username)
+ })
+ }
+}
+
+func TestIsProcessElevated(t *testing.T) {
+ elevated := isProcessElevated()
+
+ // TokenElevationType is a second, independent view of the same token:
+ // Full means elevated and Limited means a filtered administrator, while
+ // Default covers both a standard user and an administrator with no linked
+ // token (UAC off, the built-in Administrator, SYSTEM), so it implies nothing.
+ elevationType, err := tokenElevationType(windows.GetCurrentProcessToken())
+ require.NoError(t, err, "read token elevation type")
+
+ adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
+ require.NoError(t, err, "create Administrators SID")
+
+ // Token(0) makes CheckTokenMembership evaluate the caller's own token. It
+ // counts only enabled SIDs, so a filtered administrator reports false here.
+ member, err := windows.Token(0).IsMember(adminSid)
+ require.NoError(t, err, "check own Administrators membership")
+
+ t.Logf("elevated=%v elevationType=%d memberOfAdministrators=%v", elevated, elevationType, member)
+
+ switch elevationType {
+ case tokenElevationTypeFull:
+ assert.True(t, elevated, "a token of elevation type Full must report elevated")
+ case tokenElevationTypeLimited:
+ assert.False(t, elevated, "a filtered administrator token must not report elevated")
+ }
+
+ // Administrators enabled in the token means the token wields administrative
+ // rights, which is what elevation reports.
+ if member {
+ assert.True(t, elevated, "token with enabled Administrators membership must report elevated")
+ }
+}
+
+// TestS4UMembershipAgreesWithLocalGroups exercises the S4U token path used
+// for domain accounts. S4U logons need the TCB privilege, so the test runs
+// only as SYSTEM (which is how CI executes the suite). For local accounts the
+// token's Administrators membership must agree with the SAM enumeration.
+func TestS4UMembershipAgreesWithLocalGroups(t *testing.T) {
+ system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
+ require.NoError(t, err, "create SYSTEM SID")
+ current, err := user.Current()
+ require.NoError(t, err, "get current user")
+ if current.Uid != system.String() {
+ t.Skipf("S4U logon requires SYSTEM (running as %s)", current.Username)
+ }
+
+ adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
+ require.NoError(t, err, "create Administrators SID")
+
+ checked := 0
+ for _, name := range localAccountNames(t) {
+ viaToken, err := s4uTokenIsMember(name, ".", adminSid)
+ if err != nil {
+ // Disabled or logon-restricted accounts cannot get an S4U logon.
+ t.Logf("skipping %s: %v", name, err)
+ continue
+ }
+ viaSAM, err := localGroupsContainSID(name, adminSid)
+ require.NoError(t, err, "enumerate local groups for %s", name)
+
+ assert.Equal(t, viaSAM, viaToken, "S4U token and SAM enumeration must agree on Administrators membership for %s", name)
+ checked++
+ }
+ // Ineligible accounts are skipped, so without this the test could report
+ // success while comparing nothing at all.
+ require.Positive(t, checked, "no local account completed an S4U logon, so nothing was compared")
+ t.Logf("checked %d local accounts via S4U", checked)
+}
+
+// TestLocalGroupsContainSID_Administrator checks the positive case against the
+// built-in Administrator, a member of Administrators on every installation.
+func TestLocalGroupsContainSID_Administrator(t *testing.T) {
+ adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
+ require.NoError(t, err, "create Administrators SID")
+
+ administrator := localAccountNameByRID(t, 500)
+ member, err := localGroupsContainSID(administrator, adminSid)
+ require.NoError(t, err, "enumerate local groups for %s", administrator)
+ assert.True(t, member, "%s is a member of the Administrators group", administrator)
+}
+
+// TestLocalGroupsContainSID_UnresolvableGroupFailsClosed covers a wanted SID
+// that resolves to no group: the error must surface rather than being reported
+// as "not a member", so the privilege check treats the account as privileged.
+func TestLocalGroupsContainSID_UnresolvableGroupFailsClosed(t *testing.T) {
+ unknown := mustParseSID(t, "S-1-5-21-1111111111-2222222222-3333333333-4444")
+
+ _, err := localGroupsContainSID(localAccountNameByRID(t, 500), unknown)
+ require.Error(t, err, "must report an error when the wanted group cannot be identified")
+}
+
+func TestLocalGroupsContainSID_Guest(t *testing.T) {
+ guestsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid)
+ require.NoError(t, err, "create Guests SID")
+ adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
+ require.NoError(t, err, "create Administrators SID")
+
+ guest := localAccountNameByRID(t, 501)
+
+ inGuests, err := localGroupsContainSID(guest, guestsSid)
+ require.NoError(t, err, "enumerate local groups for %s", guest)
+ assert.True(t, inGuests, "%s is a member of the Guests group", guest)
+
+ inAdmins, err := localGroupsContainSID(guest, adminsSid)
+ require.NoError(t, err, "enumerate local groups for %s", guest)
+ assert.False(t, inAdmins, "%s is not a member of the Administrators group", guest)
+}
diff --git a/client/ssh/server/server_config_test.go b/client/ssh/server/server_config_test.go
index f70e29963..983bd7a43 100644
--- a/client/ssh/server/server_config_test.go
+++ b/client/ssh/server/server_config_test.go
@@ -239,6 +239,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType string
port uint32
username string
+ uid string
expectError bool
errorMsg string
skipOnWindows bool
@@ -248,6 +249,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 80,
username: "testuser",
+ uid: "1000",
expectError: true,
errorMsg: "cannot bind to privileged port",
skipOnWindows: true,
@@ -257,6 +259,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "tcpip-forward",
port: 443,
username: "testuser",
+ uid: "1000",
expectError: true,
errorMsg: "cannot bind to privileged port",
skipOnWindows: true,
@@ -266,6 +269,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 8080,
username: "testuser",
+ uid: "1000",
expectError: false,
},
{
@@ -273,6 +277,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 0,
username: "testuser",
+ uid: "1000",
expectError: false,
},
{
@@ -280,13 +285,35 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 22,
username: "root",
+ uid: "0",
expectError: false,
},
+ {
+ // Only uid 0 is privileged, whatever the account is called.
+ name: "uid 0 under another name may bind a privileged port",
+ forwardType: "remote",
+ port: 22,
+ username: "toor",
+ uid: "0",
+ expectError: false,
+ skipOnWindows: true,
+ },
+ {
+ name: "account named root without uid 0 may not",
+ forwardType: "remote",
+ port: 22,
+ username: "root",
+ uid: "1000",
+ expectError: true,
+ errorMsg: "cannot bind to privileged port",
+ skipOnWindows: true,
+ },
{
name: "local forward privileged port allowed for non-root",
forwardType: "local",
port: 80,
username: "testuser",
+ uid: "1000",
expectError: false,
},
}
@@ -299,7 +326,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
result := PrivilegeCheckResult{
Allowed: true,
- User: &user.User{Username: tt.username},
+ User: &user.User{Username: tt.username, Uid: tt.uid},
}
err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result)
@@ -420,6 +447,13 @@ func TestServer_PortConflictHandling(t *testing.T) {
func TestServer_IsPrivilegedUser(t *testing.T) {
+ // Windows classification depends on account SIDs and group membership, and
+ // the accounts involved carry localized, renameable names. It is covered by
+ // TestIsWindowsAccountPrivileged, which resolves them from well-known SIDs.
+ if runtime.GOOS == "windows" {
+ t.Skip("covered by TestIsWindowsAccountPrivileged")
+ }
+
tests := []struct {
username string
expected bool
@@ -440,44 +474,16 @@ func TestServer_IsPrivilegedUser(t *testing.T) {
expected: false,
description: "empty username should not be privileged",
},
- }
-
- // Add Windows-specific tests
- if runtime.GOOS == "windows" {
- tests = append(tests, []struct {
- username string
- expected bool
- description string
- }{
- {
- username: "Administrator",
- expected: true,
- description: "Administrator should be considered privileged on Windows",
- },
- {
- username: "administrator",
- expected: true,
- description: "administrator should be considered privileged on Windows (case insensitive)",
- },
- }...)
- } else {
- // On non-Windows systems, Administrator should not be privileged
- tests = append(tests, []struct {
- username string
- expected bool
- description string
- }{
- {
- username: "Administrator",
- expected: false,
- description: "Administrator should not be privileged on non-Windows systems",
- },
- }...)
+ {
+ username: "Administrator",
+ expected: false,
+ description: "Administrator should not be privileged on non-Windows systems",
+ },
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- result := isPrivilegedUsername(tt.username)
+ result := isPrivilegedOrUnknown(tt.username)
assert.Equal(t, tt.expected, result, tt.description)
})
}
diff --git a/client/ssh/server/sftp_windows.go b/client/ssh/server/sftp_windows.go
index dc532b9e7..25cd17298 100644
--- a/client/ssh/server/sftp_windows.go
+++ b/client/ssh/server/sftp_windows.go
@@ -17,7 +17,7 @@ import (
// createSftpCommand creates a Windows SFTP command with user switching.
// The caller must close the returned token handle after starting the process.
func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, windows.Token, error) {
- username, domain := s.parseUsername(targetUser.Username)
+ username, domain := parseUsername(targetUser.Username)
netbirdPath, err := os.Executable()
if err != nil {
diff --git a/client/ssh/server/test.go b/client/ssh/server/test.go
index 454d3afa3..e2be0551c 100644
--- a/client/ssh/server/test.go
+++ b/client/ssh/server/test.go
@@ -1,3 +1,11 @@
+// This file is intentionally named test.go (not test_test.go) so the exported
+// StartTestServer helper is visible to the ssh/proxy and ssh/client external
+// test packages, not just this package's own tests. The //go:build !js tag
+// keeps its "testing" import — and the whole testing/flag/regexp transitive
+// chain it drags in — out of the wasm client, which links ssh/server through
+// the engine but never runs Go tests under GOOS=js.
+//go:build !js
+
package server
import (
diff --git a/client/ssh/server/user_utils.go b/client/ssh/server/user_utils.go
index bc2aa2d7d..6c8142b30 100644
--- a/client/ssh/server/user_utils.go
+++ b/client/ssh/server/user_utils.go
@@ -16,11 +16,6 @@ var (
ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges")
)
-// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
-func isPlatformUnix() bool {
- return getCurrentOS() != "windows"
-}
-
// Dependency injection variables for testing - allows mocking dynamic runtime checks
var (
getCurrentUser = currentUserWithGetent
@@ -29,6 +24,9 @@ var (
getIsProcessPrivileged = isCurrentProcessPrivileged
getEuid = os.Geteuid
+
+ getProcessElevated = isProcessElevated
+ getWindowsAccountPrivilegedOrUnknown = isWindowsAccountPrivilegedOrUnknown
)
const (
@@ -65,6 +63,13 @@ type PrivilegeCheckResult struct {
RequiresUserSwitching bool
}
+// privilegeCheckContext holds all context needed for privilege checking
+type privilegeCheckContext struct {
+ currentUser *user.User
+ currentUserPrivileged bool
+ allowRoot bool
+}
+
// CheckPrivileges performs comprehensive privilege checking for all SSH features.
// This is the single source of truth for privilege decisions across the SSH server.
func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult {
@@ -75,7 +80,7 @@ func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult
// Handle empty username case - but still check root access controls
if req.RequestedUsername == "" {
- if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot {
+ if isPrivilegedOrUnknown(context.currentUser.Username) && !context.allowRoot {
return PrivilegeCheckResult{
Allowed: false,
Error: &PrivilegedUserError{Username: context.currentUser.Username},
@@ -135,7 +140,7 @@ func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheck
needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser)
- if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot {
+ if isPrivilegedOrUnknown(resolvedUser.Username) && !ctx.allowRoot {
return PrivilegeCheckResult{
Allowed: false,
Error: &PrivilegedUserError{Username: resolvedUser.Username},
@@ -175,6 +180,42 @@ func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, err
return u, nil
}
+// SetAllowRootLogin configures root login access
+func (s *Server) SetAllowRootLogin(allow bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.allowRootLogin = allow
+}
+
+// userNameLookup performs user lookup with root login permission check
+func (s *Server) userNameLookup(username string) (*user.User, error) {
+ result, err := s.userPrivilegeCheck(username)
+ if err != nil {
+ return nil, err
+ }
+ return result.User, nil
+}
+
+// userPrivilegeCheck performs user lookup with full privilege check result
+func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
+ result := s.CheckPrivileges(PrivilegeCheckRequest{
+ RequestedUsername: username,
+ FeatureSupportsUserSwitch: true,
+ FeatureName: FeatureSSHLogin,
+ })
+
+ if !result.Allowed {
+ return result, result.Error
+ }
+
+ return result, nil
+}
+
+// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
+func isPlatformUnix() bool {
+ return getCurrentOS() != "windows"
+}
+
// isSameResolvedUser compares two resolved user identities
func isSameResolvedUser(user1, user2 *user.User) bool {
if user1 == nil || user2 == nil {
@@ -183,13 +224,6 @@ func isSameResolvedUser(user1, user2 *user.User) bool {
return user1.Uid == user2.Uid
}
-// privilegeCheckContext holds all context needed for privilege checking
-type privilegeCheckContext struct {
- currentUser *user.User
- currentUserPrivileged bool
- allowRoot bool
-}
-
// isSameUser checks if two usernames refer to the same user
// SECURITY: This function must be conservative - it should only return true
// when we're certain both usernames refer to the exact same user identity
@@ -253,159 +287,30 @@ func isWindowsSameUser(requestedUsername, currentUsername string) bool {
return strings.EqualFold(reqDomain, curDomain)
}
-// SetAllowRootLogin configures root login access
-func (s *Server) SetAllowRootLogin(allow bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.allowRootLogin = allow
-}
-
-// userNameLookup performs user lookup with root login permission check
-func (s *Server) userNameLookup(username string) (*user.User, error) {
- result := s.CheckPrivileges(PrivilegeCheckRequest{
- RequestedUsername: username,
- FeatureSupportsUserSwitch: true,
- FeatureName: FeatureSSHLogin,
- })
-
- if !result.Allowed {
- return nil, result.Error
- }
-
- return result.User, nil
-}
-
-// userPrivilegeCheck performs user lookup with full privilege check result
-func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
- result := s.CheckPrivileges(PrivilegeCheckRequest{
- RequestedUsername: username,
- FeatureSupportsUserSwitch: true,
- FeatureName: FeatureSSHLogin,
- })
-
- if !result.Allowed {
- return result, result.Error
- }
-
- return result, nil
-}
-
-// isPrivilegedUsername checks if the given username represents a privileged user across platforms.
-// On Unix: root
-// On Windows: Administrator, SYSTEM (case-insensitive)
-// Handles domain-qualified usernames like "DOMAIN\Administrator" or "user@domain.com"
-func isPrivilegedUsername(username string) bool {
+// isPrivilegedOrUnknown reports whether the given username represents a
+// privileged user, or on Windows an account whose privilege could not be
+// determined.
+// On Unix: root.
+// On Windows: well-known service accounts, built-in Administrator accounts,
+// and members of the local Administrators group; handles domain-qualified
+// usernames like "DOMAIN\user" or "user@domain.com". An account that cannot be
+// resolved or evaluated is reported as privileged.
+//
+// Use this to refuse privileged accounts, never to grant them anything: the
+// undetermined case is safe for a refusal and unsafe for a grant.
+func isPrivilegedOrUnknown(username string) bool {
if getCurrentOS() != "windows" {
return username == "root"
}
-
- bareUsername := username
- // Handle Windows domain format: DOMAIN\username
- if idx := strings.LastIndex(username, `\`); idx != -1 {
- bareUsername = username[idx+1:]
- }
- // Handle email-style format: username@domain.com
- if idx := strings.Index(bareUsername, "@"); idx != -1 {
- bareUsername = bareUsername[:idx]
- }
-
- return isWindowsPrivilegedUser(bareUsername)
-}
-
-// isWindowsPrivilegedUser checks if a bare username (domain already stripped) represents a Windows privileged account
-func isWindowsPrivilegedUser(bareUsername string) bool {
- // common privileged usernames (case insensitive)
- privilegedNames := []string{
- "administrator",
- "admin",
- "root",
- "system",
- "localsystem",
- "networkservice",
- "localservice",
- }
-
- usernameLower := strings.ToLower(bareUsername)
- for _, privilegedName := range privilegedNames {
- if usernameLower == privilegedName {
- return true
- }
- }
-
- // computer accounts (ending with $) are not privileged by themselves
- // They only gain privileges through group membership or specific SIDs
-
- if targetUser, err := lookupUser(bareUsername); err == nil {
- return isWindowsPrivilegedSID(targetUser.Uid)
- }
-
- return false
-}
-
-// isWindowsPrivilegedSID checks if a Windows SID represents a privileged account
-func isWindowsPrivilegedSID(sid string) bool {
- privilegedSIDs := []string{
- "S-1-5-18", // Local System (SYSTEM)
- "S-1-5-19", // Local Service (NT AUTHORITY\LOCAL SERVICE)
- "S-1-5-20", // Network Service (NT AUTHORITY\NETWORK SERVICE)
- "S-1-5-32-544", // Administrators group (BUILTIN\Administrators)
- "S-1-5-500", // Built-in Administrator account (local machine RID 500)
- }
-
- for _, privilegedSID := range privilegedSIDs {
- if sid == privilegedSID {
- return true
- }
- }
-
- // Check for domain administrator accounts (RID 500 in any domain)
- // Format: S-1-5-21-domain-domain-domain-500
- // This is reliable as RID 500 is reserved for the domain Administrator account
- if strings.HasPrefix(sid, "S-1-5-21-") && strings.HasSuffix(sid, "-500") {
- return true
- }
-
- // Check for other well-known privileged RIDs in domain contexts
- // RID 512 = Domain Admins group, RID 516 = Domain Controllers group
- if strings.HasPrefix(sid, "S-1-5-21-") {
- if strings.HasSuffix(sid, "-512") || // Domain Admins group
- strings.HasSuffix(sid, "-516") || // Domain Controllers group
- strings.HasSuffix(sid, "-519") { // Enterprise Admins group
- return true
- }
- }
-
- return false
+ return getWindowsAccountPrivilegedOrUnknown(username)
}
// isCurrentProcessPrivileged checks if the current process is running with elevated privileges.
// On Unix systems, this means running as root (UID 0).
-// On Windows, this means running as Administrator or SYSTEM.
+// On Windows, this means the process token is elevated (administrators, SYSTEM).
func isCurrentProcessPrivileged() bool {
if getCurrentOS() == "windows" {
- return isWindowsElevated()
+ return getProcessElevated()
}
return getEuid() == 0
}
-
-// isWindowsElevated checks if the current process is running with elevated privileges on Windows
-func isWindowsElevated() bool {
- currentUser, err := getCurrentUser()
- if err != nil {
- log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err)
- return false
- }
-
- if isWindowsPrivilegedSID(currentUser.Uid) {
- log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid)
- return true
- }
-
- if isPrivilegedUsername(currentUser.Username) {
- log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username)
- return true
- }
-
- log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid)
- return false
-}
diff --git a/client/ssh/server/user_utils_test.go b/client/ssh/server/user_utils_test.go
index 637dc10d0..2fa9b68ee 100644
--- a/client/ssh/server/user_utils_test.go
+++ b/client/ssh/server/user_utils_test.go
@@ -4,6 +4,7 @@ import (
"errors"
"os/user"
"runtime"
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -27,8 +28,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
originalLookupUser := lookupUser
originalGetCurrentOS := getCurrentOS
originalGetEuid := getEuid
-
- // Reset caches to ensure clean test state
+ originalGetProcessElevated := getProcessElevated
+ originalGetWindowsAccountPrivilegedOrUnknown := getWindowsAccountPrivilegedOrUnknown
// Set test values - inject platform dependencies
getCurrentUser = func() (*user.User, error) {
@@ -53,16 +54,31 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
return euid
}
- // Mock privilege detection based on the test user
- getIsProcessPrivileged = func() bool {
+ // Simulate the Windows token elevation check based on the fixture user:
+ // the built-in Administrator (RID 500) and SYSTEM run elevated.
+ getProcessElevated = func() bool {
if currentUser == nil {
return false
}
- // Check both username and SID for Windows systems
- if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) {
+ return currentUser.Uid == "S-1-5-18" || strings.HasSuffix(currentUser.Uid, "-500")
+ }
+
+ // Simulate the Windows account classifier for the fixture accounts.
+ // "root" does not exist on Windows; the real classifier fails closed on
+ // unresolvable accounts, so it counts as privileged here too.
+ getWindowsAccountPrivilegedOrUnknown = func(username string) bool {
+ bare := username
+ if idx := strings.LastIndex(bare, `\`); idx != -1 {
+ bare = bare[idx+1:]
+ }
+ if idx := strings.Index(bare, "@"); idx != -1 {
+ bare = bare[:idx]
+ }
+ switch strings.ToLower(bare) {
+ case "administrator", "system", "root":
return true
}
- return isPrivilegedUsername(currentUser.Username)
+ return false
}
// Return cleanup function
@@ -71,10 +87,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
lookupUser = originalLookupUser
getCurrentOS = originalGetCurrentOS
getEuid = originalGetEuid
-
- getIsProcessPrivileged = isCurrentProcessPrivileged
-
- // Reset caches after test
+ getProcessElevated = originalGetProcessElevated
+ getWindowsAccountPrivilegedOrUnknown = originalGetWindowsAccountPrivilegedOrUnknown
}
}
@@ -421,6 +435,9 @@ func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) {
}
func TestPrivilegedUsernameDetection(t *testing.T) {
+ // Windows classification is syscall-backed (SID resolution, group
+ // membership) and is covered by privileges_windows_test.go; here only the
+ // Unix logic and the platform dispatch are exercised.
tests := []struct {
name string
username string
@@ -432,25 +449,9 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
{"unix_regular_user", "alice", "linux", false},
{"unix_root_capital", "Root", "linux", false}, // Case-sensitive
- // Windows tests
+ // Windows dispatch to the (mocked) account classifier
{"windows_administrator", "Administrator", "windows", true},
- {"windows_system", "SYSTEM", "windows", true},
- {"windows_admin", "admin", "windows", true},
- {"windows_admin_lowercase", "administrator", "windows", true}, // Case-insensitive
- {"windows_domain_admin", "DOMAIN\\Administrator", "windows", true},
- {"windows_email_admin", "admin@domain.com", "windows", true},
{"windows_regular_user", "alice", "windows", false},
- {"windows_domain_user", "DOMAIN\\alice", "windows", false},
- {"windows_localsystem", "localsystem", "windows", true},
- {"windows_networkservice", "networkservice", "windows", true},
- {"windows_localservice", "localservice", "windows", true},
-
- // Computer accounts (these depend on current user context in real implementation)
- {"windows_computer_account", "WIN2K19-C2$", "windows", false}, // Computer account by itself not privileged
- {"windows_domain_computer", "DOMAIN\\COMPUTER$", "windows", false}, // Domain computer account
-
- // Cross-platform
- {"root_on_windows", "root", "windows", true}, // Root should be privileged everywhere
}
for _, tt := range tests {
@@ -459,50 +460,8 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil)
defer cleanup()
- result := isPrivilegedUsername(tt.username)
- assert.Equal(t, tt.privileged, result)
- })
- }
-}
-
-func TestWindowsPrivilegedSIDDetection(t *testing.T) {
- tests := []struct {
- name string
- sid string
- privileged bool
- description string
- }{
- // Well-known system accounts
- {"system_account", "S-1-5-18", true, "Local System (SYSTEM)"},
- {"local_service", "S-1-5-19", true, "Local Service"},
- {"network_service", "S-1-5-20", true, "Network Service"},
- {"administrators_group", "S-1-5-32-544", true, "Administrators group"},
- {"builtin_administrator", "S-1-5-500", true, "Built-in Administrator"},
-
- // Domain accounts
- {"domain_administrator", "S-1-5-21-1234567890-1234567890-1234567890-500", true, "Domain Administrator (RID 500)"},
- {"domain_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-512", true, "Domain Admins group"},
- {"domain_controllers_group", "S-1-5-21-1234567890-1234567890-1234567890-516", true, "Domain Controllers group"},
- {"enterprise_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-519", true, "Enterprise Admins group"},
-
- // Regular users
- {"regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1001", false, "Regular domain user"},
- {"another_regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1234", false, "Another regular user"},
- {"local_user", "S-1-5-21-1234567890-1234567890-1234567890-1000", false, "Local regular user"},
-
- // Groups that are not privileged
- {"domain_users", "S-1-5-21-1234567890-1234567890-1234567890-513", false, "Domain Users group"},
- {"power_users", "S-1-5-32-547", false, "Power Users group"},
-
- // Invalid SIDs
- {"malformed_sid", "S-1-5-invalid", false, "Malformed SID"},
- {"empty_sid", "", false, "Empty SID"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := isWindowsPrivilegedSID(tt.sid)
- assert.Equal(t, tt.privileged, result, "Failed for %s: %s", tt.description, tt.sid)
+ result := isPrivilegedOrUnknown(tt.username)
+ assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform)
})
}
}
diff --git a/client/ssh/server/userswitching_windows.go b/client/ssh/server/userswitching_windows.go
index 260e1301e..9e8cd5b30 100644
--- a/client/ssh/server/userswitching_windows.go
+++ b/client/ssh/server/userswitching_windows.go
@@ -91,7 +91,7 @@ func validateUsernameFormat(username string) error {
func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) {
logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty)
- username, _ := s.parseUsername(localUser.Username)
+ username, _ := parseUsername(localUser.Username)
if err := validateUsername(username); err != nil {
return nil, nil, fmt.Errorf("invalid username %q: %w", username, err)
}
@@ -102,7 +102,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
// createUserSwitchCommand creates a command with Windows user switching.
// Returns the command and a cleanup function that must be called after starting the process.
func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
- username, domain := s.parseUsername(localUser.Username)
+ username, domain := parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
@@ -138,7 +138,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session,
}
// parseUsername extracts username and domain from a Windows username
-func (s *Server) parseUsername(fullUsername string) (username, domain string) {
+func parseUsername(fullUsername string) (username, domain string) {
// Handle DOMAIN\username format
if idx := strings.LastIndex(fullUsername, `\`); idx != -1 {
domain = fullUsername[:idx]
diff --git a/client/ssh/session.go b/client/ssh/session.go
new file mode 100644
index 000000000..999b6f251
--- /dev/null
+++ b/client/ssh/session.go
@@ -0,0 +1,84 @@
+package ssh
+
+import (
+ "fmt"
+ "io"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/crypto/ssh"
+)
+
+// DefaultTerminalModes are the PTY modes used by the interactive terminal clients.
+var DefaultTerminalModes = ssh.TerminalModes{
+ ssh.ECHO: 1,
+ ssh.TTY_OP_ISPEED: 14400,
+ ssh.TTY_OP_OSPEED: 14400,
+ ssh.VINTR: 3, // Ctrl+C
+ ssh.VQUIT: 28, // Ctrl+\
+ ssh.VERASE: 127, // Backspace
+ ssh.VKILL: 21, // Ctrl+U
+ ssh.VEOF: 4, // Ctrl+D
+ ssh.VEOL: 0,
+ ssh.VEOL2: 0,
+ ssh.VSTART: 17, // Ctrl+Q
+ ssh.VSTOP: 19, // Ctrl+S
+ ssh.VSUSP: 26, // Ctrl+Z
+ ssh.VDISCARD: 15, // Ctrl+O
+ ssh.VREPRINT: 18, // Ctrl+R
+ ssh.VWERASE: 23, // Ctrl+W
+ ssh.VLNEXT: 22, // Ctrl+V
+}
+
+// PTYSession is an interactive shell session with a PTY and its I/O pipes.
+type PTYSession struct {
+ Session *ssh.Session
+ Stdin io.WriteCloser
+ Stdout io.Reader
+ Stderr io.Reader
+}
+
+// StartPTYSession opens a session on the client, requests an xterm-256color PTY
+// with the default terminal modes, wires up the I/O pipes and starts a shell.
+// The session is closed on any error.
+func StartPTYSession(client *ssh.Client, cols, rows int) (*PTYSession, error) {
+ session, err := client.NewSession()
+ if err != nil {
+ return nil, fmt.Errorf("new session: %w", err)
+ }
+
+ pty, err := setupPTYSession(session, cols, rows)
+ if err != nil {
+ if closeErr := session.Close(); closeErr != nil {
+ log.Debugf("ssh: session close after setup error: %v", closeErr)
+ }
+ return nil, err
+ }
+ return pty, nil
+}
+
+// setupPTYSession requests the PTY, opens the pipes and starts the shell on an
+// already created session.
+func setupPTYSession(session *ssh.Session, cols, rows int) (*PTYSession, error) {
+ if err := session.RequestPty("xterm-256color", rows, cols, DefaultTerminalModes); err != nil {
+ return nil, fmt.Errorf("request pty: %w", err)
+ }
+
+ stdin, err := session.StdinPipe()
+ if err != nil {
+ return nil, fmt.Errorf("stdin pipe: %w", err)
+ }
+ stdout, err := session.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("stdout pipe: %w", err)
+ }
+ stderr, err := session.StderrPipe()
+ if err != nil {
+ return nil, fmt.Errorf("stderr pipe: %w", err)
+ }
+
+ if err := session.Shell(); err != nil {
+ return nil, fmt.Errorf("start shell: %w", err)
+ }
+
+ return &PTYSession{Session: session, Stdin: stdin, Stdout: stdout, Stderr: stderr}, nil
+}
diff --git a/client/status/status.go b/client/status/status.go
index 5b815aaa3..1c204cdb1 100644
--- a/client/status/status.go
+++ b/client/status/status.go
@@ -46,7 +46,10 @@ func ParseDaemonStatus(s string) DaemonStatus {
// ConvertOptions holds parameters for ConvertToStatusOutputOverview.
type ConvertOptions struct {
- Anonymize bool
+ Anonymize bool
+ // AnonymizeLevel selects how much the anonymizer redacts. Only
+ // meaningful when Anonymize is set.
+ AnonymizeLevel anonymize.Level
DaemonVersion string
DaemonStatus DaemonStatus
StatusFilter string
@@ -55,6 +58,10 @@ type ConvertOptions struct {
IPsFilter map[string]struct{}
ConnectionTypeFilter string
ProfileName string
+ // SessionExpiresAt is the absolute UTC instant at which the peer's SSO
+ // session expires. Zero when the peer is not SSO-tracked or login
+ // expiration is disabled. Sourced from StatusResponse.SessionExpiresAt.
+ SessionExpiresAt time.Time
}
type PeerStateDetailOutput struct {
@@ -155,6 +162,11 @@ type OutputOverview struct {
LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"`
ProfileName string `json:"profileName" yaml:"profileName"`
SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"`
+ // SessionExpiresAt is the absolute UTC instant at which the peer's SSO
+ // session expires. nil when the peer is not SSO-tracked or login
+ // expiration is disabled. Pointer (rather than zero-value time.Time) so
+ // JSON / YAML omit the field entirely with `,omitempty`.
+ SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty" yaml:"sessionExpiresAt,omitempty"`
}
// ConvertToStatusOutputOverview converts protobuf status to the output overview.
@@ -201,9 +213,14 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
ProfileName: opts.ProfileName,
SSHServerState: sshServerOverview,
}
+ if !opts.SessionExpiresAt.IsZero() {
+ t := opts.SessionExpiresAt
+ overview.SessionExpiresAt = &t
+ }
if opts.Anonymize {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
+ anonymizer.SetLevel(opts.AnonymizeLevel)
anonymizeOverview(anonymizer, &overview)
}
@@ -547,6 +564,15 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
+ var sessionExpiryString string
+ if o.SessionExpiresAt != nil && !o.SessionExpiresAt.IsZero() {
+ sessionExpiryString = fmt.Sprintf(
+ "Session expires: %s (in %s)\n",
+ o.SessionExpiresAt.Format(time.RFC3339),
+ FormatRemainingDuration(time.Until(*o.SessionExpiresAt)),
+ )
+ }
+
var forwardingRulesString string
if o.NumberOfForwardingRules > 0 {
forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules)
@@ -593,6 +619,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"SSH Server: %s\n"+
"Networks: %s\n"+
"%s"+
+ "%s"+
"Peers count: %s\n",
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
daemonVersion,
@@ -612,6 +639,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
sshServerStatus,
networks,
forwardingRulesString,
+ sessionExpiryString,
peersCountString,
)
return summary
@@ -722,6 +750,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
}
+ pbFullStatus.Events = fullStatus.Events
+
return &pbFullStatus
}
@@ -950,6 +980,7 @@ func timeAgo(t time.Time) string {
func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) {
peer.FQDN = a.AnonymizeDomain(peer.FQDN)
+ peer.PubKey = a.AnonymizeWGKey(peer.PubKey)
if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil {
peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port)
}
@@ -981,6 +1012,7 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL)
overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error)
+ overview.PubKey = a.AnonymizeWGKey(overview.PubKey)
overview.IP = a.AnonymizeIPString(overview.IP)
overview.IPv6 = a.AnonymizeIPString(overview.IPv6)
for i, detail := range overview.Relays.Details {
@@ -1025,3 +1057,57 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command)
}
}
+
+// FormatRemainingDuration renders a time.Duration for the "Session expires"
+// line. Examples: "2h 15m", "47m 12s", "8s", "expired 3m ago".
+//
+// Granularity drops to seconds only under a minute, otherwise minutes are
+// the smallest unit shown — sub-minute precision is noise for a deadline
+// that's hours or days out.
+func FormatRemainingDuration(d time.Duration) string {
+ if d <= 0 {
+ return "expired " + HumaniseDuration(-d) + " ago"
+ }
+ return HumaniseDuration(d)
+}
+
+// HumaniseDuration renders a positive duration in compact form (e.g.
+// "2h 15m", "47m", "8s"). Exposed alongside FormatRemainingDuration so
+// callers that don't need the "expired … ago" wording can format
+// positive durations directly.
+func HumaniseDuration(d time.Duration) string {
+ if d < time.Minute {
+ s := int(d.Round(time.Second).Seconds())
+ if s < 1 {
+ s = 1
+ }
+ return fmt.Sprintf("%ds", s)
+ }
+
+ const (
+ day = 24 * time.Hour
+ hour = time.Hour
+ minute = time.Minute
+ )
+
+ days := int64(d / day)
+ d -= time.Duration(days) * day
+ hours := int64(d / hour)
+ d -= time.Duration(hours) * hour
+ minutes := int64(d / minute)
+
+ switch {
+ case days > 0:
+ if hours == 0 {
+ return fmt.Sprintf("%dd", days)
+ }
+ return fmt.Sprintf("%dd %dh", days, hours)
+ case hours > 0:
+ if minutes == 0 {
+ return fmt.Sprintf("%dh", hours)
+ }
+ return fmt.Sprintf("%dh %dm", hours, minutes)
+ default:
+ return fmt.Sprintf("%dm", minutes)
+ }
+}
diff --git a/client/status/status_test.go b/client/status/status_test.go
index 44fc30baf..2babd9342 100644
--- a/client/status/status_test.go
+++ b/client/status/status_test.go
@@ -648,6 +648,53 @@ func TestTimeAgo(t *testing.T) {
}
}
+func TestHumaniseDuration(t *testing.T) {
+ cases := []struct {
+ in time.Duration
+ want string
+ }{
+ {0, "1s"},
+ {500 * time.Millisecond, "1s"},
+ {8 * time.Second, "8s"},
+ {59 * time.Second, "59s"},
+ {time.Minute, "1m"},
+ {47*time.Minute + 12*time.Second, "47m"},
+ {time.Hour, "1h"},
+ {2*time.Hour + 15*time.Minute, "2h 15m"},
+ {2 * time.Hour, "2h"},
+ {24 * time.Hour, "1d"},
+ {2*24*time.Hour + 3*time.Hour, "2d 3h"},
+ }
+ for _, tc := range cases {
+ got := HumaniseDuration(tc.in)
+ assert.Equal(t, tc.want, got, "input %s", tc.in)
+ }
+}
+
+func TestFormatRemainingDuration_Expired(t *testing.T) {
+ assert.Equal(t, "expired 3m ago", FormatRemainingDuration(-3*time.Minute))
+ assert.Equal(t, "expired 1s ago", FormatRemainingDuration(-500*time.Millisecond))
+}
+
+func TestSessionExpiresLineRendered(t *testing.T) {
+ in := overview // copy of the package-level fixture
+ deadline := time.Now().Add(2*time.Hour + 30*time.Minute).UTC()
+ in.SessionExpiresAt = &deadline
+
+ out := in.GeneralSummary(false, false, false, false)
+ assert.Contains(t, out, "Session expires: ")
+ assert.Contains(t, out, deadline.Format(time.RFC3339))
+ // 2h 30m drifts to "2h 29m" within 60s — match the family prefix.
+ assert.Contains(t, out, "(in 2h ")
+}
+
+func TestSessionExpiresLineOmittedWhenNil(t *testing.T) {
+ in := overview
+ in.SessionExpiresAt = nil
+ out := in.GeneralSummary(false, false, false, false)
+ assert.NotContains(t, out, "Session expires")
+}
+
func TestMapRelaysTransport(t *testing.T) {
out := mapRelays([]*proto.RelayState{
{URI: "rels://relay.example:443", Available: true, Transport: "quic"},
diff --git a/client/system/info.go b/client/system/info.go
index 477d5162b..daeabca13 100644
--- a/client/system/info.go
+++ b/client/system/info.go
@@ -2,8 +2,11 @@ package system
import (
"context"
+ "errors"
"net/netip"
+ "slices"
"strings"
+ "time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/metadata"
@@ -71,20 +74,20 @@ type Info struct {
BlockInbound bool
DisableIPv6 bool
- LazyConnectionEnabled bool
-
EnableSSHRoot bool
EnableSSHSFTP bool
EnableSSHLocalPortForwarding bool
EnableSSHRemotePortForwarding bool
DisableSSHAuth bool
+
+ SyncMessageVersion *int
}
func (i *Info) SetFlags(
rosenpassEnabled, rosenpassPermissive bool,
serverSSHAllowed *bool,
disableClientRoutes, disableServerRoutes,
- disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool,
+ disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int,
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
disableSSHAuth *bool,
) {
@@ -102,7 +105,7 @@ func (i *Info) SetFlags(
i.BlockInbound = blockInbound
i.DisableIPv6 = disableIPv6
- i.LazyConnectionEnabled = lazyConnectionEnabled
+ i.SyncMessageVersion = syncMessageVersion
if enableSSHRoot != nil {
i.EnableSSHRoot = *enableSSHRoot
@@ -121,6 +124,23 @@ func (i *Info) SetFlags(
}
}
+// removeAddresses drops network addresses whose IP matches any of the given
+// addresses, regardless of prefix length. Used to exclude the NetBird overlay
+// address, which otherwise churns the meta as the interface comes and goes.
+func (i *Info) removeAddresses(ips ...netip.Addr) {
+ if len(ips) == 0 {
+ return
+ }
+ filtered := i.NetworkAddresses[:0]
+ for _, addr := range i.NetworkAddresses {
+ if slices.Contains(ips, addr.NetIP.Addr()) {
+ continue
+ }
+ filtered = append(filtered, addr)
+ }
+ i.NetworkAddresses = filtered
+}
+
// extractUserAgent extracts Netbird's agent (client) name and version from the outgoing context
func extractUserAgent(ctx context.Context) string {
md, hasMeta := metadata.FromOutgoingContext(ctx)
@@ -147,14 +167,16 @@ func extractDeviceName(ctx context.Context, defaultName string) string {
}
// GetInfoWithChecks retrieves and parses the system information with applied checks.
-func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, error) {
+// excludeIPs are dropped from the reported network addresses (e.g. our own
+// WireGuard overlay address, which otherwise churns the peer meta).
+func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, error) {
log.Debugf("gathering system information with checks: %d", len(checks))
processCheckPaths := make([]string, 0)
for _, check := range checks {
processCheckPaths = append(processCheckPaths, check.GetFiles()...)
}
- files, err := checkFileAndProcess(processCheckPaths)
+ files, err := checkFileAndProcess(ctx, processCheckPaths)
if err != nil {
return nil, err
}
@@ -162,7 +184,48 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, erro
info := GetInfo(ctx)
info.Files = files
+ info.removeAddresses(excludeIPs...)
log.Debugf("all system information gathered successfully")
return info, nil
}
+
+// GetInfoWithChecksTimeout is GetInfoWithChecks bounded by timeout. Posture-check gathering
+// runs uncancellable system calls (process enumeration, os.Stat), so calling it inline can
+// block the caller for as long as such a call hangs. It runs in a goroutine instead: if it
+// does not return within timeout the caller gets (nil, false) and should proceed with
+// degraded behavior rather than block. On a gathering error it falls back to base GetInfo.
+//
+// The buffered channel lets the abandoned goroutine finish and exit once its blocking call
+// returns, so it does not leak beyond the duration of that call.
+func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) {
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ infoCh := make(chan *Info, 1)
+ go func() {
+ info, err := GetInfoWithChecks(ctx, checks, excludeIPs...)
+ if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ log.Warnf("failed to get system info with checks: %v", err)
+ info = GetInfo(ctx)
+ info.removeAddresses(excludeIPs...)
+ }
+ infoCh <- info
+ }()
+
+ select {
+ case info := <-infoCh:
+ return info, true
+ case <-ctx.Done():
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ log.Warnf("gathering system info with checks timed out after %s", timeout)
+ } else {
+ // Parent context canceled (e.g. shutdown), not a timeout.
+ log.Warnf("gathering system info with checks canceled: %v", ctx.Err())
+ }
+ return nil, false
+ }
+}
diff --git a/client/system/info_android.go b/client/system/info_android.go
index 794ff15ed..d4f479386 100644
--- a/client/system/info_android.go
+++ b/client/system/info_android.go
@@ -30,6 +30,11 @@ func GetInfo(ctx context.Context) *Info {
kernelVersion = osInfo[2]
}
+ addrs, err := networkAddresses()
+ if err != nil {
+ log.Warnf("discover network addresses: %s", err)
+ }
+
gio := &Info{
GoOS: runtime.GOOS,
Kernel: kernel,
@@ -41,6 +46,7 @@ func GetInfo(ctx context.Context) *Info {
NetbirdVersion: version.NetbirdVersion(),
UIVersion: extractUIVersion(ctx),
KernelVersion: kernelVersion,
+ NetworkAddresses: addrs,
SystemSerialNumber: serial(),
SystemProductName: productModel(),
SystemManufacturer: productManufacturer(),
@@ -50,7 +56,7 @@ func GetInfo(ctx context.Context) *Info {
}
// checkFileAndProcess checks if the file path exists and if a process is running at that path.
-func checkFileAndProcess(paths []string) ([]File, error) {
+func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) {
return []File{}, nil
}
diff --git a/client/system/info_darwin.go b/client/system/info_darwin.go
index 4a31920ec..e7bf367f6 100644
--- a/client/system/info_darwin.go
+++ b/client/system/info_darwin.go
@@ -32,7 +32,7 @@ func GetInfo(ctx context.Context) *Info {
sysName := string(bytes.Split(utsname.Sysname[:], []byte{0})[0])
machine := string(bytes.Split(utsname.Machine[:], []byte{0})[0])
release := string(bytes.Split(utsname.Release[:], []byte{0})[0])
- swVersion, err := exec.Command("sw_vers", "-productVersion").Output()
+ swVersion, err := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output()
if err != nil {
log.Warnf("got an error while retrieving macOS version with sw_vers, error: %s. Using darwin version instead.\n", err)
swVersion = []byte(release)
diff --git a/client/system/info_ios.go b/client/system/info_ios.go
index ad42b1edf..1b0c084b3 100644
--- a/client/system/info_ios.go
+++ b/client/system/info_ios.go
@@ -105,7 +105,7 @@ func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool {
}
// checkFileAndProcess checks if the file path exists and if a process is running at that path.
-func checkFileAndProcess(paths []string) ([]File, error) {
+func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) {
return []File{}, nil
}
diff --git a/client/system/info_js.go b/client/system/info_js.go
index 994d439a7..3323fb542 100644
--- a/client/system/info_js.go
+++ b/client/system/info_js.go
@@ -15,7 +15,7 @@ func UpdateStaticInfoAsync() {
}
// GetInfo retrieves system information for WASM environment
-func GetInfo(_ context.Context) *Info {
+func GetInfo(ctx context.Context) *Info {
info := &Info{
GoOS: runtime.GOOS,
Kernel: runtime.GOARCH,
@@ -30,6 +30,13 @@ func GetInfo(_ context.Context) *Info {
collectBrowserInfo(info)
collectLocationInfo(info)
collectSystemInfo(info)
+
+ // A caller-provided device name wins, as on the other platforms. A peer
+ // registered over an API keeps reporting the name it was registered with,
+ // so its meta does not change on the first sync.
+ if name := extractDeviceName(ctx, info.Hostname); name != "" {
+ info.Hostname = name
+ }
return info
}
@@ -103,7 +110,7 @@ func collectLocationInfo(info *Info) {
}
}
-func checkFileAndProcess(_ []string) ([]File, error) {
+func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) {
return []File{}, nil
}
diff --git a/client/system/info_js_test.go b/client/system/info_js_test.go
new file mode 100644
index 000000000..e2a33ada0
--- /dev/null
+++ b/client/system/info_js_test.go
@@ -0,0 +1,27 @@
+//go:build js
+
+package system
+
+import (
+ "context"
+ "testing"
+)
+
+// TestGetInfoHonorsDeviceName covers a caller-provided device name reaching the
+// reported hostname, so a peer registered over an API keeps reporting the name
+// it was registered with instead of renaming itself on its first sync.
+func TestGetInfoHonorsDeviceName(t *testing.T) {
+ ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "session-name")
+ if got := GetInfo(ctx).Hostname; got != "session-name" {
+ t.Errorf("hostname should carry the caller's device name, got %q", got)
+ }
+}
+
+// TestGetInfoWithoutDeviceNameKeepsFallback covers the embed layer's habit of
+// always setting the context value: an empty name must not blank the hostname.
+func TestGetInfoWithoutDeviceNameKeepsFallback(t *testing.T) {
+ ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "")
+ if got := GetInfo(ctx).Hostname; got == "" {
+ t.Error("an empty device name must not blank the hostname")
+ }
+}
diff --git a/client/system/info_test.go b/client/system/info_test.go
index 27821f3c5..a7fa02197 100644
--- a/client/system/info_test.go
+++ b/client/system/info_test.go
@@ -2,7 +2,9 @@ package system
import (
"context"
+ "net/netip"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/metadata"
@@ -34,6 +36,20 @@ func Test_CustomHostname(t *testing.T) {
assert.Equal(t, want, got.Hostname)
}
+func TestGetInfoWithChecksTimeout_Success(t *testing.T) {
+ info, ok := GetInfoWithChecksTimeout(context.Background(), 30*time.Second, nil)
+ assert.True(t, ok, "expected gathering to complete within the timeout")
+ assert.NotNil(t, info)
+}
+
+func TestGetInfoWithChecksTimeout_Timeout(t *testing.T) {
+ // A 1ns budget expires before the (real) system-info gathering can finish, so the
+ // caller must get (nil, false) instead of blocking on the in-flight goroutine.
+ info, ok := GetInfoWithChecksTimeout(context.Background(), time.Nanosecond, nil)
+ assert.False(t, ok, "expected timeout to be reported")
+ assert.Nil(t, info)
+}
+
func Test_NetAddresses(t *testing.T) {
addr, err := networkAddresses()
if err != nil {
@@ -43,3 +59,42 @@ func Test_NetAddresses(t *testing.T) {
t.Errorf("no network addresses found")
}
}
+
+func TestInfo_RemoveAddresses(t *testing.T) {
+ addr := func(cidr string) NetworkAddress {
+ return NetworkAddress{NetIP: netip.MustParsePrefix(cidr)}
+ }
+
+ info := &Info{
+ NetworkAddresses: []NetworkAddress{
+ addr("192.168.1.7/24"),
+ addr("100.76.70.97/32"), // overlay v4 (host mask /32)
+ addr("2001:818:c51b:4800:845:a65d:ae6f:623f/64"), // real global v6
+ addr("fd00:1234::1/64"), // overlay v6
+ },
+ }
+
+ // Overlay addresses as the engine knows them, with a different mask (/16, /64).
+ info.removeAddresses(
+ netip.MustParseAddr("100.76.70.97"),
+ netip.MustParseAddr("fd00:1234::1"),
+ )
+
+ want := []string{"192.168.1.7/24", "2001:818:c51b:4800:845:a65d:ae6f:623f/64"}
+ if len(info.NetworkAddresses) != len(want) {
+ t.Fatalf("got %d addresses, want %d: %v", len(info.NetworkAddresses), len(want), info.NetworkAddresses)
+ }
+ for i, w := range want {
+ if got := info.NetworkAddresses[i].NetIP.String(); got != w {
+ t.Errorf("address[%d] = %s, want %s", i, got, w)
+ }
+ }
+}
+
+func TestInfo_RemoveAddresses_NoOp(t *testing.T) {
+ info := &Info{NetworkAddresses: []NetworkAddress{{NetIP: netip.MustParsePrefix("10.0.0.1/24")}}}
+ info.removeAddresses()
+ if len(info.NetworkAddresses) != 1 {
+ t.Errorf("expected no change with empty input, got %v", info.NetworkAddresses)
+ }
+}
diff --git a/client/system/network_addr.go b/client/system/network_addr.go
index 5423cf8ad..505a6f0ea 100644
--- a/client/system/network_addr.go
+++ b/client/system/network_addr.go
@@ -1,4 +1,4 @@
-//go:build !ios
+//go:build !ios && !android
package system
@@ -46,7 +46,9 @@ func toNetworkAddress(address net.Addr, mac string) (NetworkAddress, bool) {
if !ok {
return NetworkAddress{}, false
}
- if ipNet.IP.IsLoopback() {
+ // Skip link-local and multicast: they carry no routable peer info and the
+ // IPv6 link-local of a flapping NIC churns the meta on every up/down.
+ if ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() || ipNet.IP.IsMulticast() {
return NetworkAddress{}, false
}
prefix, err := netip.ParsePrefix(ipNet.String())
diff --git a/client/system/network_addr_android.go b/client/system/network_addr_android.go
new file mode 100644
index 000000000..99a71e105
--- /dev/null
+++ b/client/system/network_addr_android.go
@@ -0,0 +1,89 @@
+package system
+
+import (
+ "net/netip"
+ "strings"
+)
+
+var iFaceDiscover IFaceDiscover
+
+type IFaceDiscover interface {
+ IFaces() (string, error)
+}
+
+// SetIFaceDiscover configures the Android interface discovery provider.
+func SetIFaceDiscover(discover IFaceDiscover) {
+ iFaceDiscover = discover
+}
+
+func networkAddresses() ([]NetworkAddress, error) {
+ if iFaceDiscover == nil {
+ return nil, nil
+ }
+ ifaces, err := iFaceDiscover.IFaces()
+ if err != nil {
+ return nil, err
+ }
+
+ var netAddresses []NetworkAddress
+ for _, line := range strings.Split(ifaces, "\n") {
+ addresses, ok := interfaceAddresses(line)
+ if !ok {
+ continue
+ }
+ for _, address := range addresses {
+ netAddr, ok := toNetworkAddress(address)
+ if !ok {
+ continue
+ }
+ if isDuplicated(netAddresses, netAddr) {
+ continue
+ }
+ netAddresses = append(netAddresses, netAddr)
+ }
+ }
+ return netAddresses, nil
+}
+
+func interfaceAddresses(line string) ([]string, bool) {
+ parts := strings.Split(line, "|")
+ if len(parts) != 2 {
+ return nil, false
+ }
+ flags := strings.Fields(parts[0])
+ if len(flags) != 8 {
+ return nil, false
+ }
+ up, loopback := flags[3], flags[5]
+ if up != "true" || loopback == "true" {
+ return nil, false
+ }
+ return strings.Fields(parts[1]), true
+}
+
+func toNetworkAddress(address string) (NetworkAddress, bool) {
+ prefix, err := netip.ParsePrefix(address)
+ if err != nil {
+ return NetworkAddress{}, false
+ }
+ if prefix.Addr().Is4In6() {
+ if prefix.Bits() < 96 {
+ return NetworkAddress{}, false
+ }
+ prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
+ }
+ ip := prefix.Addr()
+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() {
+ return NetworkAddress{}, false
+ }
+ return NetworkAddress{NetIP: prefix}, true
+}
+
+func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool {
+ for _, duplicated := range addresses {
+ if duplicated.NetIP == addr.NetIP {
+ return true
+ }
+ }
+ return false
+}
diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go
new file mode 100644
index 000000000..b0be40f0a
--- /dev/null
+++ b/client/system/network_addr_test.go
@@ -0,0 +1,45 @@
+//go:build !ios && !android
+
+package system
+
+import (
+ "net"
+ "testing"
+)
+
+func mustIPNet(t *testing.T, cidr string) *net.IPNet {
+ t.Helper()
+ ip, ipNet, err := net.ParseCIDR(cidr)
+ if err != nil {
+ t.Fatalf("parse %q: %v", cidr, err)
+ }
+ ipNet.IP = ip
+ return ipNet
+}
+
+func TestToNetworkAddress_Filtering(t *testing.T) {
+ const mac = "c8:4b:d6:b6:04:ac"
+
+ tests := []struct {
+ name string
+ cidr string
+ want bool
+ }{
+ {"ipv4 global", "10.65.16.181/23", true},
+ {"ipv6 global", "2620:52:0:4110:102d:6a98:ee75:8b92/64", true},
+ {"ipv4 loopback", "127.0.0.1/8", false},
+ {"ipv6 loopback", "::1/128", false},
+ {"ipv6 link-local", "fe80::871:4c25:23d7:2529/64", false},
+ {"ipv4 link-local", "169.254.1.2/16", false},
+ {"ipv6 multicast", "ff02::1/128", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, got := toNetworkAddress(mustIPNet(t, tt.cidr), mac)
+ if got != tt.want {
+ t.Errorf("toNetworkAddress(%s) ok = %v, want %v", tt.cidr, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/client/system/process.go b/client/system/process.go
index 87e21eb9d..fefa7d913 100644
--- a/client/system/process.go
+++ b/client/system/process.go
@@ -3,24 +3,30 @@
package system
import (
+ "context"
"os"
"slices"
- "github.com/shirou/gopsutil/v3/process"
+ "github.com/shirou/gopsutil/v4/process"
)
-// getRunningProcesses returns a list of running process paths.
-func getRunningProcesses() ([]string, error) {
- processIDs, err := process.Pids()
+// getRunningProcesses returns a list of running process paths. The context bounds the work:
+// the per-PID loop bails as soon as ctx is done, and the gopsutil calls honor it where they
+// can, so a stuck enumeration cannot run unbounded.
+func getRunningProcesses(ctx context.Context) ([]string, error) {
+ processIDs, err := process.PidsWithContext(ctx)
if err != nil {
return nil, err
}
processMap := make(map[string]bool)
for _, pID := range processIDs {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
p := &process.Process{Pid: pID}
- path, _ := p.Exe()
+ path, _ := p.ExeWithContext(ctx)
if path != "" {
processMap[path] = false
}
@@ -35,18 +41,21 @@ func getRunningProcesses() ([]string, error) {
}
// checkFileAndProcess checks if the file path exists and if a process is running at that path.
-func checkFileAndProcess(paths []string) ([]File, error) {
+func checkFileAndProcess(ctx context.Context, paths []string) ([]File, error) {
files := make([]File, len(paths))
if len(paths) == 0 {
return files, nil
}
- runningProcesses, err := getRunningProcesses()
+ runningProcesses, err := getRunningProcesses(ctx)
if err != nil {
return nil, err
}
for i, path := range paths {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
file := File{Path: path}
_, err := os.Stat(path)
diff --git a/client/system/process_test.go b/client/system/process_test.go
index 505808a9e..de1cfc1db 100644
--- a/client/system/process_test.go
+++ b/client/system/process_test.go
@@ -1,15 +1,18 @@
+//go:build windows || (linux && !android) || (darwin && !ios) || freebsd
+
package system
import (
+ "context"
"testing"
- "github.com/shirou/gopsutil/v3/process"
+ "github.com/shirou/gopsutil/v4/process"
)
func Benchmark_getRunningProcesses(b *testing.B) {
b.Run("getRunningProcesses new", func(b *testing.B) {
for i := 0; i < b.N; i++ {
- ps, err := getRunningProcesses()
+ ps, err := getRunningProcesses(context.Background())
if err != nil {
b.Fatalf("unexpected error: %v", err)
}
@@ -29,12 +32,38 @@ func Benchmark_getRunningProcesses(b *testing.B) {
}
}
})
- s, _ := getRunningProcesses()
+ s, _ := getRunningProcesses(context.Background())
b.Logf("getRunningProcesses returned %d processes", len(s))
s, _ = getRunningProcessesOld()
b.Logf("getRunningProcessesOld returned %d processes", len(s))
}
+func TestCheckFileAndProcess_ContextCanceled(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ // With a canceled context and non-empty paths the gathering must bail with an error
+ // instead of running the (potentially blocking) process scan / stat loop.
+ if _, err := checkFileAndProcess(ctx, []string{"/does/not/exist"}); err == nil {
+ t.Fatal("expected error on canceled context, got nil")
+ }
+}
+
+func TestCheckFileAndProcess_EmptyPaths(t *testing.T) {
+ // No check paths means no work to do: it must return immediately with no error,
+ // even on a canceled context (nothing to scan or stat).
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ files, err := checkFileAndProcess(ctx, nil)
+ if err != nil {
+ t.Fatalf("unexpected error for empty paths: %v", err)
+ }
+ if len(files) != 0 {
+ t.Fatalf("expected no files, got %d", len(files))
+ }
+}
+
func getRunningProcessesOld() ([]string, error) {
processes, err := process.Processes()
if err != nil {
diff --git a/client/test/json-socket-docker.sh b/client/test/json-socket-docker.sh
new file mode 100755
index 000000000..a878f13d6
--- /dev/null
+++ b/client/test/json-socket-docker.sh
@@ -0,0 +1,223 @@
+#!/usr/bin/env bash
+set -eEuo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: client/test/json-socket-docker.sh [tcp|unix|both]
+
+Builds the NetBird client Docker image from the local source tree, starts
+`netbird service run` in a container with --enable-json-socket, and verifies
+that the HTTP/JSON daemon gateway responds to Status requests.
+
+Modes:
+ tcp Validate tcp://0.0.0.0:8080 via a published localhost port (default)
+ unix Validate unix:///sock/netbird-http.sock via a bind-mounted socket dir
+ both Run both validations
+
+Environment:
+ CONTAINER_RUNTIME docker or podman. Auto-detected if unset.
+ IMAGE Image tag to build. Default: netbird-json-socket-test:local
+ TARGETARCH Go/Docker target arch. Default: `go env GOARCH`
+ PLATFORM Docker platform. Default: linux/$TARGETARCH
+ WAIT_TIMEOUT Seconds to wait for the JSON socket. Default: 30
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+ usage
+ exit 0
+fi
+
+MODE="${1:-tcp}"
+case "${MODE}" in
+ tcp|unix|both) ;;
+ *)
+ usage >&2
+ echo "invalid mode: ${MODE}" >&2
+ exit 2
+ ;;
+esac
+
+RUNTIME="${CONTAINER_RUNTIME:-}"
+if [[ -z "${RUNTIME}" ]]; then
+ if command -v docker >/dev/null 2>&1; then
+ RUNTIME=docker
+ elif command -v podman >/dev/null 2>&1; then
+ RUNTIME=podman
+ else
+ echo "docker or podman is required" >&2
+ exit 127
+ fi
+fi
+if ! command -v "${RUNTIME}" >/dev/null 2>&1; then
+ echo "container runtime not found: ${RUNTIME}" >&2
+ exit 127
+fi
+
+if ! command -v curl >/dev/null 2>&1; then
+ echo "curl is required" >&2
+ exit 127
+fi
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+IMAGE="${IMAGE:-netbird-json-socket-test:local}"
+TARGETARCH="${TARGETARCH:-$(go env GOARCH)}"
+PLATFORM="${PLATFORM:-linux/${TARGETARCH}}"
+WAIT_TIMEOUT="${WAIT_TIMEOUT:-30}"
+TMP_DIR="$(mktemp -d)"
+CONTAINERS=()
+
+cleanup() {
+ local status=$?
+ for container in "${CONTAINERS[@]:-}"; do
+ "${RUNTIME}" rm -f "${container}" >/dev/null 2>&1 || true
+ done
+ rm -rf "${TMP_DIR}"
+ exit "${status}"
+}
+trap cleanup EXIT
+
+build_image() {
+ echo "==> Building Linux ${TARGETARCH} netbird binary"
+ mkdir -p "${TMP_DIR}/context/client"
+ cp "${ROOT_DIR}/client/Dockerfile" "${TMP_DIR}/context/Dockerfile"
+ cp "${ROOT_DIR}/client/netbird-entrypoint.sh" "${TMP_DIR}/context/client/netbird-entrypoint.sh"
+
+ (cd "${ROOT_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" go build -o "${TMP_DIR}/context/netbird" ./client)
+
+ echo "==> Building ${IMAGE} for ${PLATFORM}"
+ "${RUNTIME}" build \
+ --platform "${PLATFORM}" \
+ --build-arg NETBIRD_BINARY=netbird \
+ -t "${IMAGE}" \
+ -f "${TMP_DIR}/context/Dockerfile" \
+ "${TMP_DIR}/context"
+}
+
+pick_port() {
+ python3 - <<'PY'
+import socket
+sock = socket.socket()
+sock.bind(("127.0.0.1", 0))
+print(sock.getsockname()[1])
+sock.close()
+PY
+}
+
+assert_status_json() {
+ local response_file="$1"
+ if command -v python3 >/dev/null 2>&1; then
+ python3 - "${response_file}" <<'PY'
+import json
+import sys
+with open(sys.argv[1], encoding="utf-8") as fh:
+ data = json.load(fh)
+if not data.get("status"):
+ raise SystemExit("missing non-empty status field")
+if "daemonVersion" not in data:
+ raise SystemExit("missing daemonVersion field")
+print(f"status={data['status']} daemonVersion={data['daemonVersion']}")
+PY
+ else
+ grep -q '"status"' "${response_file}"
+ grep -q '"daemonVersion"' "${response_file}"
+ cat "${response_file}"
+ fi
+}
+
+container_logs() {
+ local container="$1"
+ echo "---- ${container} logs ----" >&2
+ "${RUNTIME}" logs "${container}" >&2 || true
+ echo "--------------------------" >&2
+}
+
+wait_for_http_status() {
+ local container="$1"
+ local response="${TMP_DIR}/${container}.json"
+ local curl_err="${TMP_DIR}/${container}.curl.err"
+ shift
+ local deadline=$((SECONDS + WAIT_TIMEOUT))
+
+ while (( SECONDS < deadline )); do
+ if curl -fsS "$@" \
+ -X POST \
+ -H 'Content-Type: application/json' \
+ -d '{}' \
+ -o "${response}" \
+ 2>"${curl_err}"; then
+ assert_status_json "${response}"
+ return 0
+ fi
+
+ if ! "${RUNTIME}" ps --format '{{.Names}}' | grep -Fxq "${container}"; then
+ echo "container exited before JSON socket became ready" >&2
+ container_logs "${container}"
+ return 1
+ fi
+ sleep 1
+ done
+
+ echo "timed out waiting for JSON socket after ${WAIT_TIMEOUT}s" >&2
+ cat "${curl_err}" >&2 || true
+ container_logs "${container}"
+ return 1
+}
+
+run_netbird_container() {
+ local container="$1"
+ local json_socket="$2"
+ shift 2
+
+ CONTAINERS+=("${container}")
+ "${RUNTIME}" run --rm -d \
+ --name "${container}" \
+ -e NB_STATE_DIR=/tmp/netbird-state \
+ --entrypoint /usr/local/bin/netbird \
+ "$@" \
+ "${IMAGE}" \
+ --log-file console \
+ --daemon-addr unix:///tmp/netbird.sock \
+ service run \
+ --enable-json-socket \
+ --json-socket "${json_socket}" >/dev/null
+}
+
+run_tcp_test() {
+ local port container
+ port="$(pick_port)"
+ container="nb-json-socket-tcp-$RANDOM-$RANDOM"
+
+ echo "==> Validating TCP JSON socket on 127.0.0.1:${port}"
+ run_netbird_container "${container}" "tcp://0.0.0.0:8080" -p "127.0.0.1:${port}:8080"
+ wait_for_http_status "${container}" "http://127.0.0.1:${port}/daemon.DaemonService/Status"
+}
+
+run_unix_test() {
+ local sock_dir sock_path container
+ sock_dir="${TMP_DIR}/sock"
+ sock_path="${sock_dir}/netbird-http.sock"
+ container="nb-json-socket-unix-$RANDOM-$RANDOM"
+ mkdir -p "${sock_dir}"
+
+ echo "==> Validating Unix JSON socket at ${sock_path}"
+ run_netbird_container "${container}" "unix:///sock/netbird-http.sock" -v "${sock_dir}:/sock"
+ wait_for_http_status "${container}" --unix-socket "${sock_path}" "http://unix/daemon.DaemonService/Status"
+}
+
+build_image
+
+case "${MODE}" in
+ tcp)
+ run_tcp_test
+ ;;
+ unix)
+ run_unix_test
+ ;;
+ both)
+ run_tcp_test
+ run_unix_test
+ ;;
+esac
+
+echo "==> Docker JSON socket validation passed (${MODE})"
diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go
new file mode 100644
index 000000000..d1945894d
--- /dev/null
+++ b/client/testutil/privileged/runner_test.go
@@ -0,0 +1,196 @@
+//go:build privileged && (linux || darwin)
+
+// Package privileged provides a self-hosting harness that runs the repo's
+// privileged-tagged test suite inside a --privileged --cap-add=NET_ADMIN
+// container, so developers can exercise the root/system-mutating tests on a
+// non-root host with a single `go test` invocation.
+package privileged
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/moby/moby/api/types/container"
+ "github.com/ory/dockertest/v4"
+)
+
+// containerImage / containerTag match the image used by the CI privileged job
+// (.github/workflows/golang-test-linux.yml, test_client_on_docker).
+const (
+ containerImage = "golang"
+ containerTag = "1.25-alpine"
+)
+
+const (
+ containerWorkdir = "/app"
+ containerGoCache = "/root/.cache/go-build"
+ containerGoModCache = "/go/pkg/mod"
+)
+
+// alpinePackages are the build/runtime deps the privileged tests need, mirroring
+// the CI container setup.
+const alpinePackages = "ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base"
+
+// privilegedTestPackages is the package list the suite runs, excluding the
+// server-side trees and UI/upload helpers, matching the CI Docker job's filter.
+const privilegedTestPackages = `go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server`
+
+// testWriter forwards container output to the test log line by line.
+type testWriter struct{ t *testing.T }
+
+func (w testWriter) Write(p []byte) (int, error) {
+ for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
+ w.t.Log(line)
+ }
+ return len(p), nil
+}
+
+// TestRunPrivilegedSuiteInDocker spins up a privileged container, mounts the repo,
+// and runs `go test -tags 'devcert privileged'` inside it. When already running
+// inside that container (DOCKER_CI=true) it returns immediately so the real
+// privileged tests in the suite execute in place instead of recursing.
+func TestRunPrivilegedSuiteInDocker(t *testing.T) {
+ if os.Getenv("DOCKER_CI") == "true" {
+ t.Skip("inside privileged container, skipping container spawn; privileged tests run in place")
+ }
+
+ repoRoot, err := findRepoRoot()
+ if err != nil {
+ t.Fatalf("locate repo root: %v", err)
+ }
+ goCache, goModCache := hostGoCaches(t)
+
+ // dockertest reads DOCKER_HOST; point it at the active context's socket when
+ // the default one is absent (macOS Docker Desktop, Colima, OrbStack).
+ if host := dockerHost(); host != "" {
+ t.Setenv("DOCKER_HOST", host)
+ }
+
+ // NewPoolT registers container cleanup via t.Cleanup automatically.
+ pool := dockertest.NewPoolT(t, "", dockertest.WithMaxWait(30*time.Minute))
+
+ // Keep the container alive so the suite runs via Exec, which yields a clean
+ // exit code (the v4 Resource API exposes no container wait/exit-code).
+ resource := pool.RunT(t, containerImage,
+ dockertest.WithTag(containerTag),
+ dockertest.WithWorkingDir(containerWorkdir),
+ dockertest.WithMounts([]string{
+ repoRoot + ":" + containerWorkdir,
+ goCache + ":" + containerGoCache,
+ goModCache + ":" + containerGoModCache,
+ }),
+ dockertest.WithEnv([]string{
+ "CGO_ENABLED=1",
+ "CI=true",
+ "DOCKER_CI=true",
+ "CONTAINER=true",
+ "GOCACHE=" + containerGoCache,
+ "GOMODCACHE=" + containerGoModCache,
+ }),
+ dockertest.WithCmd([]string{"sleep", "infinity"}),
+ dockertest.WithHostConfig(func(hc *container.HostConfig) {
+ hc.Privileged = true
+ hc.CapAdd = []string{"NET_ADMIN"}
+ }),
+ dockertest.WithoutReuse(),
+ )
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+ defer cancel()
+
+ result, err := resource.Exec(ctx, []string{"sh", "-c", buildTestScript()})
+ if err != nil {
+ t.Fatalf("run privileged suite in container: %v", err)
+ }
+
+ w := testWriter{t}
+ _, _ = w.Write([]byte(result.StdOut))
+ _, _ = w.Write([]byte(result.StdErr))
+
+ if result.ExitCode != 0 {
+ t.Fatalf("privileged test suite failed in container (exit code %d)", result.ExitCode)
+ }
+}
+
+// findRepoRoot walks up from the test's working directory to the module root.
+func findRepoRoot() (string, error) {
+ dir, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ for {
+ if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil {
+ return dir, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ return "", fmt.Errorf("go.mod not found above %s", dir)
+ }
+ dir = parent
+ }
+}
+
+// dockerHost returns a DOCKER_HOST override when the default socket is missing.
+// An empty result means the caller should leave DOCKER_HOST untouched (it is
+// already set, or the default unix socket exists). When neither is present
+// (common on macOS Docker Desktop, Colima and OrbStack, which use a per-user
+// socket), it resolves the active docker context's endpoint.
+func dockerHost() string {
+ if os.Getenv("DOCKER_HOST") != "" {
+ return ""
+ }
+ if _, err := os.Stat("/var/run/docker.sock"); err == nil {
+ return ""
+ }
+
+ out, err := exec.Command("docker", "context", "inspect", "-f", "{{.Endpoints.docker.Host}}").Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// hostGoCaches resolves the host GOCACHE/GOMODCACHE so the container reuses the
+// existing build/module cache for speed.
+func hostGoCaches(t *testing.T) (string, string) {
+ t.Helper()
+ return goEnv(t, "GOCACHE"), goEnv(t, "GOMODCACHE")
+}
+
+func goEnv(t *testing.T, key string) string {
+ t.Helper()
+ var out bytes.Buffer
+ cmd := exec.Command("go", "env", key)
+ cmd.Stdout = &out
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("go env %s: %v", key, err)
+ }
+ return strings.TrimSpace(out.String())
+}
+
+// buildTestScript builds the in-container command. PRIV_PKGS overrides the package
+// list (default: the full filtered set); PRIV_RUN adds a -run test-name filter.
+// Both empty reproduces the full privileged suite.
+func buildTestScript() string {
+ pkgs := privilegedTestPackages + " | xargs"
+ if p := os.Getenv("PRIV_PKGS"); p != "" {
+ pkgs = "echo " + p + " | xargs"
+ }
+
+ runFilter := ""
+ if r := os.Getenv("PRIV_RUN"); r != "" {
+ runFilter = "-run '" + r + "' "
+ }
+
+ return fmt.Sprintf(
+ "apk update >/dev/null && apk add --no-cache %s >/dev/null && %s go test -buildvcs=false -tags 'devcert privileged' %s-v -timeout 20m -p 1",
+ alpinePackages, pkgs, runFilter,
+ )
+}
diff --git a/client/ui/.gitignore b/client/ui/.gitignore
new file mode 100644
index 000000000..9f233d8b6
--- /dev/null
+++ b/client/ui/.gitignore
@@ -0,0 +1,8 @@
+.task
+bin
+frontend/dist
+frontend/node_modules
+frontend/bindings
+frontend/.vite
+build/linux/appimage/build
+build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
diff --git a/client/ui/Netbird.icns b/client/ui/Netbird.icns
deleted file mode 100644
index 20af72825..000000000
Binary files a/client/ui/Netbird.icns and /dev/null differ
diff --git a/client/ui/Taskfile.yml b/client/ui/Taskfile.yml
new file mode 100644
index 000000000..2d0af9018
--- /dev/null
+++ b/client/ui/Taskfile.yml
@@ -0,0 +1,58 @@
+version: '3'
+
+includes:
+ common: ./build/Taskfile.yml
+ windows: ./build/windows/Taskfile.yml
+ darwin: ./build/darwin/Taskfile.yml
+ linux: ./build/linux/Taskfile.yml
+
+vars:
+ APP_NAME: "netbird-ui"
+ BIN_DIR: "bin"
+ VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
+
+tasks:
+ build:
+ summary: Builds the application
+ cmds:
+ - task: "{{OS}}:build"
+
+ package:
+ summary: Packages a production build of the application
+ cmds:
+ - task: "{{OS}}:package"
+
+ run:
+ summary: Runs the application
+ cmds:
+ - task: "{{OS}}:run"
+
+ dev:
+ summary: Runs the application in development mode
+ cmds:
+ - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
+
+ setup:docker:
+ summary: Builds Docker image for cross-compilation (~800MB download)
+ cmds:
+ - task: common:setup:docker
+
+ build:server:
+ summary: Builds the application in server mode (no GUI, HTTP server only)
+ cmds:
+ - task: common:build:server
+
+ run:server:
+ summary: Runs the application in server mode
+ cmds:
+ - task: common:run:server
+
+ build:docker:
+ summary: Builds a Docker image for server mode deployment
+ cmds:
+ - task: common:build:docker
+
+ run:docker:
+ summary: Builds and runs the Docker image
+ cmds:
+ - task: common:run:docker
diff --git a/client/ui/assets/connected.png b/client/ui/assets/connected.png
deleted file mode 100644
index 7dd2ab01a..000000000
Binary files a/client/ui/assets/connected.png and /dev/null differ
diff --git a/client/ui/assets/disconnected.png b/client/ui/assets/disconnected.png
deleted file mode 100644
index 421632b52..000000000
Binary files a/client/ui/assets/disconnected.png and /dev/null differ
diff --git a/client/ui/assets/netbird-disconnected.ico b/client/ui/assets/netbird-disconnected.ico
deleted file mode 100644
index 812e9d283..000000000
Binary files a/client/ui/assets/netbird-disconnected.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-disconnected.png b/client/ui/assets/netbird-disconnected.png
deleted file mode 100644
index 79d4775ea..000000000
Binary files a/client/ui/assets/netbird-disconnected.png and /dev/null differ
diff --git a/client/ui/assets/netbird-menu-16.png b/client/ui/assets/netbird-menu-16.png
new file mode 100644
index 000000000..d5dcab446
Binary files /dev/null and b/client/ui/assets/netbird-menu-16.png differ
diff --git a/client/ui/assets/netbird-menu-24.png b/client/ui/assets/netbird-menu-24.png
new file mode 100644
index 000000000..087c1c2ae
Binary files /dev/null and b/client/ui/assets/netbird-menu-24.png differ
diff --git a/client/ui/assets/netbird-menu-about-18.png b/client/ui/assets/netbird-menu-about-18.png
new file mode 100644
index 000000000..bb12c6367
Binary files /dev/null and b/client/ui/assets/netbird-menu-about-18.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connected-16.png b/client/ui/assets/netbird-menu-dot-connected-16.png
new file mode 100644
index 000000000..3a7fa31a4
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected-16.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connected-22.png b/client/ui/assets/netbird-menu-dot-connected-22.png
new file mode 100644
index 000000000..78b068748
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected-22.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connected.png b/client/ui/assets/netbird-menu-dot-connected.png
new file mode 100644
index 000000000..fc8ce4d85
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connecting-16.png b/client/ui/assets/netbird-menu-dot-connecting-16.png
new file mode 100644
index 000000000..f874706b5
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting-16.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connecting-22.png b/client/ui/assets/netbird-menu-dot-connecting-22.png
new file mode 100644
index 000000000..d8e5970f5
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting-22.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connecting.png b/client/ui/assets/netbird-menu-dot-connecting.png
new file mode 100644
index 000000000..3f8bc29d8
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting.png differ
diff --git a/client/ui/assets/netbird-menu-dot-error-16.png b/client/ui/assets/netbird-menu-dot-error-16.png
new file mode 100644
index 000000000..cdc6254da
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error-16.png differ
diff --git a/client/ui/assets/netbird-menu-dot-error-22.png b/client/ui/assets/netbird-menu-dot-error-22.png
new file mode 100644
index 000000000..d9bd013d6
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error-22.png differ
diff --git a/client/ui/assets/netbird-menu-dot-error.png b/client/ui/assets/netbird-menu-dot-error.png
new file mode 100644
index 000000000..ce5d0e8ef
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error.png differ
diff --git a/client/ui/assets/netbird-menu-dot-idle-16.png b/client/ui/assets/netbird-menu-dot-idle-16.png
new file mode 100644
index 000000000..354b5b860
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle-16.png differ
diff --git a/client/ui/assets/netbird-menu-dot-idle-22.png b/client/ui/assets/netbird-menu-dot-idle-22.png
new file mode 100644
index 000000000..675cf1ffe
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle-22.png differ
diff --git a/client/ui/assets/netbird-menu-dot-idle.png b/client/ui/assets/netbird-menu-dot-idle.png
new file mode 100644
index 000000000..79e7bbbf8
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle.png differ
diff --git a/client/ui/assets/netbird-menu-dot-offline-16.png b/client/ui/assets/netbird-menu-dot-offline-16.png
new file mode 100644
index 000000000..f9aa5c3e9
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline-16.png differ
diff --git a/client/ui/assets/netbird-menu-dot-offline-22.png b/client/ui/assets/netbird-menu-dot-offline-22.png
new file mode 100644
index 000000000..5202c8baa
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline-22.png differ
diff --git a/client/ui/assets/netbird-menu-dot-offline.png b/client/ui/assets/netbird-menu-dot-offline.png
new file mode 100644
index 000000000..7aec5d01d
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline.png differ
diff --git a/client/ui/assets/netbird-systemtray-connected-dark.ico b/client/ui/assets/netbird-systemtray-connected-dark.ico
deleted file mode 100644
index 0db8a0862..000000000
Binary files a/client/ui/assets/netbird-systemtray-connected-dark.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-connected-macos.png b/client/ui/assets/netbird-systemtray-connected-macos.png
index ead210250..d29a7ade8 100644
Binary files a/client/ui/assets/netbird-systemtray-connected-macos.png and b/client/ui/assets/netbird-systemtray-connected-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-connected-mono-dark.png
new file mode 100644
index 000000000..1f7d40121
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-connected-mono.png b/client/ui/assets/netbird-systemtray-connected-mono.png
new file mode 100644
index 000000000..8a8710746
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-connected.ico b/client/ui/assets/netbird-systemtray-connected.ico
deleted file mode 100644
index c16bec3f5..000000000
Binary files a/client/ui/assets/netbird-systemtray-connected.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-connecting-dark.ico b/client/ui/assets/netbird-systemtray-connecting-dark.ico
deleted file mode 100644
index 615d40f07..000000000
Binary files a/client/ui/assets/netbird-systemtray-connecting-dark.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-connecting-macos.png b/client/ui/assets/netbird-systemtray-connecting-macos.png
index 0fe7fa0db..306c6ddf5 100644
Binary files a/client/ui/assets/netbird-systemtray-connecting-macos.png and b/client/ui/assets/netbird-systemtray-connecting-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-connecting-mono-dark.png b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png
new file mode 100644
index 000000000..f208cb6bf
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-connecting-mono.png b/client/ui/assets/netbird-systemtray-connecting-mono.png
new file mode 100644
index 000000000..e254321fd
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-connecting.ico b/client/ui/assets/netbird-systemtray-connecting.ico
deleted file mode 100644
index 4e4c3a9b1..000000000
Binary files a/client/ui/assets/netbird-systemtray-connecting.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-disconnected-macos.png b/client/ui/assets/netbird-systemtray-disconnected-macos.png
index 36b9a488f..48cfa7c60 100644
Binary files a/client/ui/assets/netbird-systemtray-disconnected-macos.png and b/client/ui/assets/netbird-systemtray-disconnected-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png
new file mode 100644
index 000000000..035e71ba7
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono.png b/client/ui/assets/netbird-systemtray-disconnected-mono.png
new file mode 100644
index 000000000..d68c4dc5b
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-disconnected.ico b/client/ui/assets/netbird-systemtray-disconnected.ico
deleted file mode 100644
index dcb9f4bf8..000000000
Binary files a/client/ui/assets/netbird-systemtray-disconnected.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-error-dark.ico b/client/ui/assets/netbird-systemtray-error-dark.ico
deleted file mode 100644
index 083816188..000000000
Binary files a/client/ui/assets/netbird-systemtray-error-dark.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-error-macos.png b/client/ui/assets/netbird-systemtray-error-macos.png
index 9a9998bcf..580fe647c 100644
Binary files a/client/ui/assets/netbird-systemtray-error-macos.png and b/client/ui/assets/netbird-systemtray-error-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-error-mono-dark.png b/client/ui/assets/netbird-systemtray-error-mono-dark.png
new file mode 100644
index 000000000..6bcdacd44
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-error-mono.png b/client/ui/assets/netbird-systemtray-error-mono.png
new file mode 100644
index 000000000..164d65a4f
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-error.ico b/client/ui/assets/netbird-systemtray-error.ico
deleted file mode 100644
index 1abc45c2a..000000000
Binary files a/client/ui/assets/netbird-systemtray-error.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-needs-login-macos.png b/client/ui/assets/netbird-systemtray-needs-login-macos.png
new file mode 100644
index 000000000..580fe647c
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png
new file mode 100644
index 000000000..6bcdacd44
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono.png b/client/ui/assets/netbird-systemtray-needs-login-mono.png
new file mode 100644
index 000000000..164d65a4f
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-needs-login.png b/client/ui/assets/netbird-systemtray-needs-login.png
new file mode 100644
index 000000000..722342989
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-connected-dark.ico b/client/ui/assets/netbird-systemtray-update-connected-dark.ico
deleted file mode 100644
index b11bb5492..000000000
Binary files a/client/ui/assets/netbird-systemtray-update-connected-dark.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-update-connected-macos.png b/client/ui/assets/netbird-systemtray-update-connected-macos.png
index 8a6b2f2db..8b7b9f131 100644
Binary files a/client/ui/assets/netbird-systemtray-update-connected-macos.png and b/client/ui/assets/netbird-systemtray-update-connected-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png
new file mode 100644
index 000000000..284efa880
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono.png b/client/ui/assets/netbird-systemtray-update-connected-mono.png
new file mode 100644
index 000000000..ed9ceb8a2
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-connected.ico b/client/ui/assets/netbird-systemtray-update-connected.ico
deleted file mode 100644
index d3ce2f0f3..000000000
Binary files a/client/ui/assets/netbird-systemtray-update-connected.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico b/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico
deleted file mode 100644
index 123237f66..000000000
Binary files a/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-macos.png b/client/ui/assets/netbird-systemtray-update-disconnected-macos.png
index 8b190034e..b6afa3937 100644
Binary files a/client/ui/assets/netbird-systemtray-update-disconnected-macos.png and b/client/ui/assets/netbird-systemtray-update-disconnected-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png
new file mode 100644
index 000000000..eb0c4bcf5
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png
new file mode 100644
index 000000000..519ea014a
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-disconnected.ico b/client/ui/assets/netbird-systemtray-update-disconnected.ico
deleted file mode 100644
index 968dc4105..000000000
Binary files a/client/ui/assets/netbird-systemtray-update-disconnected.ico and /dev/null differ
diff --git a/client/ui/assets/netbird.ico b/client/ui/assets/netbird.ico
deleted file mode 100644
index 2bab8a503..000000000
Binary files a/client/ui/assets/netbird.ico and /dev/null differ
diff --git a/client/ui/assets/svg/needs-login.svg b/client/ui/assets/svg/needs-login.svg
new file mode 100644
index 000000000..5c01b48d4
--- /dev/null
+++ b/client/ui/assets/svg/needs-login.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/client/ui/assets/svg/netbird-menu.svg b/client/ui/assets/svg/netbird-menu.svg
new file mode 100644
index 000000000..bd4e9d65d
--- /dev/null
+++ b/client/ui/assets/svg/netbird-menu.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/client/ui/authsession/service.go b/client/ui/authsession/service.go
new file mode 100644
index 000000000..9c094c2a1
--- /dev/null
+++ b/client/ui/authsession/service.go
@@ -0,0 +1,129 @@
+//go:build !android && !ios && !freebsd && !js
+
+package authsession
+
+import (
+ "context"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+type ExtendStartParams struct {
+ // Hint is the OIDC login_hint, typically the user's email.
+ Hint string `json:"hint"`
+}
+
+type ExtendStartResult struct {
+ VerificationURI string `json:"verificationUri"`
+ VerificationURIComplete string `json:"verificationUriComplete"`
+ UserCode string `json:"userCode"`
+ DeviceCode string `json:"deviceCode"`
+ ExpiresIn int64 `json:"expiresIn"`
+}
+
+type ExtendWaitParams struct {
+ DeviceCode string `json:"deviceCode"`
+ UserCode string `json:"userCode"`
+}
+
+// ExtendResult: ExpiresAt is nil when the peer is ineligible for extension.
+// Preempted means a newer WaitExtend took over the IdP poll — a no-op, not a failure.
+type ExtendResult struct {
+ ExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
+ Preempted bool `json:"preempted,omitempty"`
+}
+
+// DaemonConn duplicates services.DaemonConn to avoid an import cycle.
+type DaemonConn interface {
+ Client() (proto.DaemonServiceClient, error)
+}
+
+// Session bundles the session-auth daemon RPCs the UI drives.
+type Session struct {
+ conn DaemonConn
+}
+
+func NewSession(conn DaemonConn) *Session {
+ return &Session{conn: conn}
+}
+
+// RequestExtend starts the SSO session-extension flow on the daemon.
+func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return ExtendStartResult{}, err
+ }
+
+ // a request from the UI implies a graphical session, which the daemon cannot detect itself
+ req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: true}
+ hint := p.Hint
+ if hint == "" {
+ pm := profilemanager.NewProfileManager()
+ if active, perr := pm.GetActiveProfile(); perr != nil {
+ log.Debugf("failed to get active profile for login hint: %v", perr)
+ } else if state, serr := pm.GetProfileState(active.ID); serr != nil {
+ log.Debugf("failed to get profile state for login hint: %v", serr)
+ } else {
+ hint = state.Email
+ }
+ }
+ if hint != "" {
+ req.Hint = &hint
+ }
+
+ resp, err := cli.RequestExtendAuthSession(ctx, req)
+ if err != nil {
+ return ExtendStartResult{}, err
+ }
+
+ return ExtendStartResult{
+ VerificationURI: resp.GetVerificationURI(),
+ VerificationURIComplete: resp.GetVerificationURIComplete(),
+ UserCode: resp.GetUserCode(),
+ DeviceCode: resp.GetDeviceCode(),
+ ExpiresIn: resp.GetExpiresIn(),
+ }, nil
+}
+
+// WaitExtend blocks until the user completes the SSO flow started by RequestExtend.
+func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return ExtendResult{}, err
+ }
+
+ resp, err := cli.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{
+ DeviceCode: p.DeviceCode,
+ UserCode: p.UserCode,
+ })
+ if err != nil {
+ if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Canceled {
+ return ExtendResult{Preempted: true}, nil
+ }
+ return ExtendResult{}, err
+ }
+
+ out := ExtendResult{}
+ if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
+ t := ts.AsTime().UTC()
+ out.ExpiresAt = &t
+ }
+ return out, nil
+}
+
+// DismissWarning suppresses the daemon's T-FinalWarningLead fallback dialog for
+// the current deadline. Best-effort: a stale call is silently swallowed daemon-side.
+func (s *Session) DismissWarning(ctx context.Context) error {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return err
+ }
+ _, err = cli.DismissSessionWarning(ctx, &proto.DismissSessionWarningRequest{})
+ return err
+}
diff --git a/client/ui/authsession/warning.go b/client/ui/authsession/warning.go
new file mode 100644
index 000000000..91ae7f101
--- /dev/null
+++ b/client/ui/authsession/warning.go
@@ -0,0 +1,64 @@
+//go:build !android && !ios && !freebsd && !js
+
+// Package authsession holds the UI-side domain logic for the SSO
+// session-extend feature. The Wails facades in client/ui/services/session*.go
+// are thin adapters over these types.
+package authsession
+
+import (
+ "time"
+
+ "github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
+)
+
+// Re-exported from sessionwatch so UI-side consumers don't import the
+// daemon-internal package directly.
+const (
+ MetaWarning = sessionwatch.MetaSessionWarning
+ MetaFinal = sessionwatch.MetaSessionFinal
+ MetaExpiresAt = sessionwatch.MetaSessionExpiresAt
+ MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes
+ MetaDeadlineRejected = sessionwatch.MetaSessionDeadlineRejected
+)
+
+// Warning is the typed payload emitted on the session-warning Wails events.
+type Warning struct {
+ // Absolute UTC deadline; best-effort, stays zero when metadata is
+ // missing or malformed (e.g. an older daemon) and the UI falls back
+ // to the Status snapshot.
+ ExpiresAt time.Time `json:"sessionExpiresAt"`
+ // Configured lead time, so the UI need not hardcode the constant.
+ LeadMinutes int `json:"leadMinutes"`
+ // True on the final-warning fallback event.
+ Final bool `json:"final"`
+}
+
+// WarningFromMetadata parses SystemEvent metadata into a Warning, or returns
+// (nil, false) when the event is not a session-warning. A field that fails to
+// parse stays zero; the event is still surfaced.
+func WarningFromMetadata(meta map[string]string) (*Warning, bool) {
+ if meta == nil || meta[MetaWarning] != "true" {
+ return nil, false
+ }
+
+ out := &Warning{
+ Final: meta[MetaFinal] == "true",
+ }
+ if raw := meta[MetaExpiresAt]; raw != "" {
+ if t, err := sessionwatch.ParseExpiresAt(raw); err == nil {
+ out.ExpiresAt = t
+ }
+ }
+ if raw := meta[MetaLeadMinutes]; raw != "" {
+ if n, err := sessionwatch.ParseLeadMinutes(raw); err == nil {
+ out.LeadMinutes = n
+ }
+ }
+ return out, true
+}
+
+// ParseExpiresAt re-exports sessionwatch.ParseExpiresAt so UI-side call sites
+// don't import the daemon-internal package.
+func ParseExpiresAt(s string) (time.Time, error) {
+ return sessionwatch.ParseExpiresAt(s)
+}
diff --git a/client/ui/authsession/warning_test.go b/client/ui/authsession/warning_test.go
new file mode 100644
index 000000000..297073ded
--- /dev/null
+++ b/client/ui/authsession/warning_test.go
@@ -0,0 +1,82 @@
+//go:build !android && !ios && !freebsd && !js
+
+package authsession
+
+import (
+ "testing"
+ "time"
+)
+
+func TestWarningFromMetadata_NotASessionWarning(t *testing.T) {
+ cases := []struct {
+ name string
+ meta map[string]string
+ }{
+ {"nil metadata", nil},
+ {"empty map", map[string]string{}},
+ {"unrelated event", map[string]string{"new_version_available": "0.65.0"}},
+ {"flag not 'true'", map[string]string{"session_warning": "1"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if w, ok := WarningFromMetadata(tc.meta); ok {
+ t.Fatalf("expected (nil, false), got (%+v, %v)", w, ok)
+ }
+ })
+ }
+}
+
+func TestWarningFromMetadata_FullPayload(t *testing.T) {
+ ts := "2026-05-18T13:30:00Z"
+ meta := map[string]string{
+ "session_warning": "true",
+ "session_expires_at": ts,
+ "lead_minutes": "10",
+ }
+
+ got, ok := WarningFromMetadata(meta)
+ if !ok {
+ t.Fatalf("expected the warning to be recognised, got ok=false")
+ }
+ want, _ := time.Parse(time.RFC3339, ts)
+ if !got.ExpiresAt.Equal(want.UTC()) {
+ t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, want.UTC())
+ }
+ if got.LeadMinutes != 10 {
+ t.Errorf("LeadMinutes = %d, want 10", got.LeadMinutes)
+ }
+}
+
+func TestWarningFromMetadata_BadFieldsStillEmits(t *testing.T) {
+ // Older or buggy daemon: the flag is set but the timestamp/lead are
+ // missing or malformed. The UI should still get a warning so it can
+ // at least surface "session expires soon"; field zero-values are fine.
+ meta := map[string]string{
+ "session_warning": "true",
+ "session_expires_at": "not-a-timestamp",
+ "lead_minutes": "abc",
+ }
+
+ got, ok := WarningFromMetadata(meta)
+ if !ok {
+ t.Fatalf("warning should still be recognised even with malformed fields")
+ }
+ if !got.ExpiresAt.IsZero() {
+ t.Errorf("malformed timestamp should leave field zero, got %v", got.ExpiresAt)
+ }
+ if got.LeadMinutes != 0 {
+ t.Errorf("malformed lead_minutes should leave field 0, got %d", got.LeadMinutes)
+ }
+}
+
+func TestWarningFromMetadata_MissingFieldsStillEmits(t *testing.T) {
+ // Only the flag is present (e.g. future-trimmed event). Still emit.
+ meta := map[string]string{"session_warning": "true"}
+ got, ok := WarningFromMetadata(meta)
+ if !ok {
+ t.Fatalf("warning should still be recognised when only flag is present")
+ }
+ if got.ExpiresAt.IsZero() != true || got.LeadMinutes != 0 {
+ t.Errorf("missing fields should be zero-valued, got %+v", got)
+ }
+}
diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go
new file mode 100644
index 000000000..162922579
--- /dev/null
+++ b/client/ui/autostart_default.go
@@ -0,0 +1,121 @@
+//go:build !android && !ios && !freebsd && !js
+
+package main
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/mdm"
+ "github.com/netbirdio/netbird/client/ui/preferences"
+ "github.com/netbirdio/netbird/client/ui/services"
+)
+
+// autostartDefaultState carries the guard inputs of the one-time autostart
+// default decision so the decision itself stays a pure, testable function.
+type autostartDefaultState struct {
+ supported bool
+ mdmDisabled bool
+ priorInstall bool
+}
+
+// shouldEnableAutostartDefault applies the first-run guards in order and
+// returns whether autostart may be enabled, plus the reason when it may not.
+func shouldEnableAutostartDefault(s autostartDefaultState) (bool, string) {
+ switch {
+ case !s.supported:
+ return false, "autostart not supported on this platform"
+ case s.mdmDisabled:
+ return false, "autostart disabled by MDM policy"
+ case s.priorInstall:
+ return false, "existing NetBird installation"
+ }
+ return true, ""
+}
+
+// autostartDisabledByMDM reports whether the MDM policy manages the
+// disableAutostart key in a way that must suppress the default. An
+// unparseable managed value is treated as disabled to stay on the safe side.
+func autostartDisabledByMDM(policy *mdm.Policy) bool {
+ if !policy.HasKey(mdm.KeyDisableAutostart) {
+ return false
+ }
+ disabled, ok := policy.GetBool(mdm.KeyDisableAutostart)
+ return !ok || disabled
+}
+
+// netbirdFootprintExists reports whether the machine already carries NetBird
+// daemon config or state, meaning this is not a genuinely fresh install. It is
+// the update-safety gate for the autostart default: upgrading users always
+// have a footprint, so an update can never trigger a autostart entry write.
+func netbirdFootprintExists() bool {
+ candidates := []string{
+ profilemanager.DefaultConfigPath,
+ filepath.Join(profilemanager.DefaultConfigPathDir, "config.json"),
+ filepath.Join(profilemanager.DefaultConfigPathDir, "state.json"),
+ }
+ for _, path := range candidates {
+ if path != "" && fileExists(path) {
+ return true
+ }
+ }
+ return false
+}
+
+// applyAutostartDefault runs the one-time launch-on-login default for genuinely
+// fresh installs. The autostartInitialized marker is persisted before any
+// enable attempt so a crash mid-flow degrades to "never enabled" instead of
+// retrying autostart entry writes on every launch. A user's later disable in
+// Settings is never overridden: the marker guarantees at-most-once, ever.
+func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
+ mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy())
+
+ if mdmDisabled {
+ if enabled, err := autostart.IsEnabled(ctx); err != nil {
+ log.Warnf("MDM disableAutostart: read autostart state: %v", err)
+ } else if enabled {
+ if err := autostart.SetEnabled(ctx, false); err != nil {
+ log.Warnf("MDM disableAutostart: force off failed: %v", err)
+ } else {
+ log.Info("MDM disableAutostart enforced: autostart turned off")
+ }
+ }
+ }
+
+ priorFootprint := netbirdFootprintExists() || prefsFileExisted
+
+ if prefs.Get().AutostartInitialized {
+ return
+ }
+ if err := prefs.SetAutostartInitialized(true); err != nil {
+ log.Warnf("persist autostart marker, skipping autostart default: %v", err)
+ return
+ }
+
+ state := autostartDefaultState{
+ supported: autostart.Supported(ctx),
+ mdmDisabled: mdmDisabled,
+ priorInstall: priorFootprint,
+ }
+ enable, reason := shouldEnableAutostartDefault(state)
+ if !enable {
+ log.Debugf("skipping autostart default: %s", reason)
+ return
+ }
+
+ if err := autostart.SetEnabled(ctx, true); err != nil {
+ log.Warnf("enable autostart on fresh install: %v", err)
+ return
+ }
+ log.Info("autostart enabled by default on fresh install")
+}
+
+// fileExists reports whether path exists.
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
diff --git a/client/ui/autostart_default_test.go b/client/ui/autostart_default_test.go
new file mode 100644
index 000000000..b7bdf9f2a
--- /dev/null
+++ b/client/ui/autostart_default_test.go
@@ -0,0 +1,125 @@
+//go:build !android && !ios && !freebsd && !js
+
+package main
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/netbirdio/netbird/client/mdm"
+)
+
+func TestShouldEnableAutostartDefault(t *testing.T) {
+ allPass := autostartDefaultState{
+ supported: true,
+ mdmDisabled: false,
+ priorInstall: false,
+ }
+
+ tests := []struct {
+ name string
+ mutate func(*autostartDefaultState)
+ wantEnable bool
+ wantReason string
+ }{
+ {
+ name: "fresh install with all guards passing enables",
+ mutate: func(*autostartDefaultState) {},
+ wantEnable: true,
+ },
+ {
+ name: "unsupported platform skips",
+ mutate: func(s *autostartDefaultState) { s.supported = false },
+ wantReason: "autostart not supported on this platform",
+ },
+ {
+ name: "MDM disable skips",
+ mutate: func(s *autostartDefaultState) { s.mdmDisabled = true },
+ wantReason: "autostart disabled by MDM policy",
+ },
+ {
+ name: "existing installation (upgrade) skips",
+ mutate: func(s *autostartDefaultState) { s.priorInstall = true },
+ wantReason: "existing NetBird installation",
+ },
+ {
+ name: "unsupported wins over every other guard",
+ mutate: func(s *autostartDefaultState) {
+ s.supported = false
+ s.mdmDisabled = true
+ s.priorInstall = true
+ },
+ wantReason: "autostart not supported on this platform",
+ },
+ {
+ name: "MDM disable wins over prior install",
+ mutate: func(s *autostartDefaultState) {
+ s.mdmDisabled = true
+ s.priorInstall = true
+ },
+ wantReason: "autostart disabled by MDM policy",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ state := allPass
+ tc.mutate(&state)
+ enable, reason := shouldEnableAutostartDefault(state)
+ assert.Equal(t, tc.wantEnable, enable, "enable decision should match for state %+v", state)
+ assert.Equal(t, tc.wantReason, reason, "skip reason should identify the failing guard")
+ })
+ }
+}
+
+func TestAutostartDisabledByMDM(t *testing.T) {
+ tests := []struct {
+ name string
+ values map[string]any
+ want bool
+ }{
+ {
+ name: "empty policy does not disable",
+ values: nil,
+ want: false,
+ },
+ {
+ name: "unrelated managed keys do not disable",
+ values: map[string]any{mdm.KeyDisableAutoConnect: true},
+ want: false,
+ },
+ {
+ name: "disableAutostart true disables",
+ values: map[string]any{mdm.KeyDisableAutostart: true},
+ want: true,
+ },
+ {
+ name: "disableAutostart registry DWORD 1 disables",
+ values: map[string]any{mdm.KeyDisableAutostart: int64(1)},
+ want: true,
+ },
+ {
+ name: "disableAutostart string true disables",
+ values: map[string]any{mdm.KeyDisableAutostart: "true"},
+ want: true,
+ },
+ {
+ name: "disableAutostart explicit false allows",
+ values: map[string]any{mdm.KeyDisableAutostart: false},
+ want: false,
+ },
+ {
+ name: "unparseable managed value is treated as disabled",
+ values: map[string]any{mdm.KeyDisableAutostart: "not-a-bool"},
+ want: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := autostartDisabledByMDM(mdm.NewPolicy(tc.values))
+ assert.Equal(t, tc.want, got, "MDM disable decision should match for values %v", tc.values)
+ })
+ }
+}
diff --git a/client/ui/build/Taskfile.yml b/client/ui/build/Taskfile.yml
new file mode 100644
index 000000000..590d4791b
--- /dev/null
+++ b/client/ui/build/Taskfile.yml
@@ -0,0 +1,295 @@
+version: '3'
+
+tasks:
+ go:mod:tidy:
+ summary: Runs `go mod tidy`
+ internal: true
+ cmds:
+ - go mod tidy
+
+ install:frontend:deps:
+ summary: Install frontend dependencies
+ dir: frontend
+ sources:
+ - package.json
+ - pnpm-lock.yaml
+ generates:
+ - node_modules
+ preconditions:
+ - sh: pnpm --version
+ msg: "Looks like pnpm isn't installed. Install with: corepack enable && corepack prepare pnpm@latest --activate"
+ cmds:
+ - pnpm install
+
+ build:frontend:
+ label: build:frontend (DEV={{.DEV}})
+ summary: Build the frontend project
+ dir: frontend
+ sources:
+ - "**/*"
+ - exclude: node_modules/**/*
+ generates:
+ - dist/**/*
+ deps:
+ - task: install:frontend:deps
+ - task: generate:bindings
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ cmds:
+ - pnpm run {{.BUILD_COMMAND}}
+ env:
+ PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
+ vars:
+ BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
+
+
+ frontend:vendor:puppertino:
+ summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
+ sources:
+ - frontend/public/puppertino/puppertino.css
+ generates:
+ - frontend/public/puppertino/puppertino.css
+ cmds:
+ - |
+ set -euo pipefail
+ mkdir -p frontend/public/puppertino
+ # If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
+ if [ ! -f frontend/public/puppertino/puppertino.css ]; then
+ echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
+ if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
+ curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
+ echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
+ else
+ echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
+ fi
+ else
+ echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
+ fi
+ # Ensure index.html includes Puppertino CSS and button classes
+ INDEX_HTML=frontend/index.html
+ if [ -f "$INDEX_HTML" ]; then
+ if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
+ # Insert Puppertino link tag after style.css link
+ awk '
+ /href="\/style.css"\/?/ && !x { print; print " "; x=1; next }1
+ ' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
+ fi
+ # Replace default .btn with Puppertino primary button classes if present
+ sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
+ fi
+
+
+ generate:bindings:
+ label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
+ summary: Generates bindings for the frontend
+ deps:
+ - task: go:mod:tidy
+ sources:
+ - "**/*.[jt]s"
+ - exclude: frontend/**/*
+ - frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
+ - "**/*.go"
+ - go.mod
+ - go.sum
+ generates:
+ - frontend/bindings/**/*
+ cmds:
+ - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -ts
+
+ generate:icons:
+ summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
+ dir: build
+ sources:
+ - "appicon.png"
+ - "appicon.icon"
+ generates:
+ - "darwin/icons.icns"
+ - "windows/icon.ico"
+ cmds:
+ - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
+
+ generate:tray:icons:
+ summary: Rebuild Windows multi-res .ico files from the per-state PNGs.
+ desc: |
+ The colored tray PNGs (assets/netbird-systemtray-.png) and the
+ macOS template variants are committed to the repo as the canonical
+ source. This task only regenerates the Windows multi-resolution .ico
+ files from those PNGs by downscaling each to 16/24/32/48 px and
+ packing them with icotool, so Shell_NotifyIcon picks the frame
+ matching the user's DPI instead of downscaling a single large PNG.
+
+ Run after replacing any of the colored PNGs (e.g. when copying a new
+ version of the icons from client/ui/assets). The SVG sources in
+ assets/svg/ are kept for reference but are not built by default.
+ dir: assets
+ sources:
+ - "netbird-systemtray-connected.png"
+ - "netbird-systemtray-disconnected.png"
+ - "netbird-systemtray-connecting.png"
+ - "netbird-systemtray-error.png"
+ - "netbird-systemtray-update-connected.png"
+ - "netbird-systemtray-update-disconnected.png"
+ generates:
+ - "netbird-systemtray-*.ico"
+ preconditions:
+ - sh: command -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1
+ msg: "ImageMagick is required to downscale PNGs (apt install imagemagick)"
+ - sh: command -v icotool >/dev/null 2>&1
+ msg: "icotool is required to pack tray .ico files (apt install icoutils)"
+ cmds:
+ - |
+ set -euo pipefail
+ tmp=$(mktemp -d)
+ trap 'rm -rf "$tmp"' EXIT
+ resize=$(command -v magick || echo convert)
+ for state in connected disconnected connecting error update-connected update-disconnected; do
+ for sz in 16 24 32 48; do
+ "$resize" "netbird-systemtray-$state.png" -resize ${sz}x${sz} "$tmp/$state-$sz.png"
+ done
+ icotool -c -o "netbird-systemtray-$state.ico" \
+ "$tmp/$state-16.png" "$tmp/$state-24.png" "$tmp/$state-32.png" "$tmp/$state-48.png"
+ done
+
+ dev:frontend:
+ summary: Runs the frontend in development mode
+ dir: frontend
+ deps:
+ - task: install:frontend:deps
+ cmds:
+ - pnpm exec vite --port {{.VITE_PORT}} --strictPort
+
+ update:build-assets:
+ summary: Updates the build assets
+ dir: build
+ cmds:
+ - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
+
+ build:server:
+ summary: Builds the application in server mode (no GUI, HTTP server only)
+ desc: |
+ Builds the application with the server build tag enabled.
+ Server mode runs as a pure HTTP server without native GUI dependencies.
+ Usage: task build:server
+ deps:
+ - task: build:frontend
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ cmds:
+ - go build -tags server {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
+ vars:
+ BUILD_FLAGS: "{{.BUILD_FLAGS}}"
+
+ run:server:
+ summary: Builds and runs the application in server mode
+ deps:
+ - task: build:server
+ cmds:
+ - ./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
+
+ build:docker:
+ summary: Builds a Docker image for server mode deployment
+ desc: |
+ Creates a minimal Docker image containing the server mode binary.
+ The image is based on distroless for security and small size.
+ Usage: task build:docker [TAG=myapp:latest]
+ cmds:
+ - docker build -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} -f build/docker/Dockerfile.server .
+ vars:
+ TAG: "{{.TAG}}"
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required. Please install Docker first."
+ - sh: test -f build/docker/Dockerfile.server
+ msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
+
+ run:docker:
+ summary: Builds and runs the Docker image
+ desc: |
+ Builds the Docker image and runs it, exposing port 8080.
+ Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
+ Note: The internal container port is always 8080. The PORT variable
+ only changes the host port mapping. Ensure your app uses port 8080
+ or modify the Dockerfile to match your ServerOptions.Port setting.
+ deps:
+ - task: build:docker
+ vars:
+ TAG:
+ ref: .TAG
+ cmds:
+ - docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
+ vars:
+ TAG: "{{.TAG}}"
+ PORT: "{{.PORT}}"
+
+ setup:docker:
+ summary: Builds Docker image for cross-compilation (~800MB download)
+ desc: |
+ Builds the Docker image needed for cross-compiling to any platform.
+ Run this once to enable cross-platform builds from any OS.
+ cmds:
+ - docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required. Please install Docker first."
+
+ ios:device:list:
+ summary: Lists connected iOS devices (UDIDs)
+ cmds:
+ - xcrun xcdevice list
+
+ ios:run:device:
+ summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
+ vars:
+ PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/.xcodeproj
+ SCHEME: '{{.SCHEME}}' # e.g., ios.dev
+ CONFIG: '{{.CONFIG | default "Debug"}}'
+ DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
+ UDID: '{{.UDID}}' # from `task ios:device:list`
+ BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
+ TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
+ preconditions:
+ - sh: xcrun -f xcodebuild
+ msg: "xcodebuild not found. Please install Xcode."
+ - sh: xcrun -f devicectl
+ msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
+ - sh: test -n '{{.PROJECT}}'
+ msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
+ - sh: test -n '{{.SCHEME}}'
+ msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
+ - sh: test -n '{{.UDID}}'
+ msg: "Set UDID to your device UDID (see: task ios:device:list)."
+ - sh: test -n '{{.BUNDLE_ID}}'
+ msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
+ cmds:
+ - |
+ set -euo pipefail
+ echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
+ XCB_ARGS=(
+ -project "{{.PROJECT}}"
+ -scheme "{{.SCHEME}}"
+ -configuration "{{.CONFIG}}"
+ -destination "id={{.UDID}}"
+ -derivedDataPath "{{.DERIVED}}"
+ -allowProvisioningUpdates
+ -allowProvisioningDeviceRegistration
+ )
+ # Optionally inject signing identifiers if provided
+ if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
+ if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
+ xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
+ # If xcpretty isn't installed, run without it
+ if [ "${PIPESTATUS[0]}" -ne 0 ]; then
+ xcodebuild "${XCB_ARGS[@]}" build
+ fi
+ # Find built .app
+ APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
+ if [ -z "$APP_PATH" ]; then
+ echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
+ exit 1
+ fi
+ echo "Installing: $APP_PATH"
+ xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
+ echo "Launching: {{.BUNDLE_ID}}"
+ xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"
diff --git a/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg b/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg
new file mode 100644
index 000000000..83c4c22a9
--- /dev/null
+++ b/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/client/ui/build/appicon.icon/icon.json b/client/ui/build/appicon.icon/icon.json
new file mode 100644
index 000000000..4a0371af3
--- /dev/null
+++ b/client/ui/build/appicon.icon/icon.json
@@ -0,0 +1,26 @@
+{
+ "fill" : {
+ "solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
+ },
+ "groups" : [
+ {
+ "layers" : [
+ {
+ "image-name" : "wails_icon_vector.svg",
+ "name" : "wails_icon_vector"
+ }
+ ],
+ "shadow" : {
+ "kind" : "neutral",
+ "opacity" : 0.5
+ },
+ "specular" : true
+ }
+ ],
+ "supported-platforms" : {
+ "circles" : [
+ "watchOS"
+ ],
+ "squares" : "shared"
+ }
+}
diff --git a/client/ui/build/appicon.png b/client/ui/build/appicon.png
new file mode 100644
index 000000000..977d2400a
Binary files /dev/null and b/client/ui/build/appicon.png differ
diff --git a/client/ui/build/build-ui-linux.sh b/client/ui/build/build-ui-linux.sh
deleted file mode 100644
index eab08214d..000000000
--- a/client/ui/build/build-ui-linux.sh
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/bash
-sudo apt update
-sudo apt remove gir1.2-appindicator3-0.1
-sudo apt install -y libayatana-appindicator3-dev
-go build
\ No newline at end of file
diff --git a/client/ui/build/config.yml b/client/ui/build/config.yml
new file mode 100644
index 000000000..08b95b6bd
--- /dev/null
+++ b/client/ui/build/config.yml
@@ -0,0 +1,78 @@
+# This file contains the configuration for this project.
+# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
+# Note that this will overwrite any changes you have made to the assets.
+version: '3'
+
+# This information is used to generate the build assets.
+info:
+ companyName: "NetBird GmbH" # The name of the company
+ productName: "NetBird" # The name of the application
+ productIdentifier: "io.netbird.client" # The unique product identifier
+ description: "NetBird desktop client" # The application description
+ copyright: "NetBird GmbH" # Copyright text
+ comments: "Some Product Comments" # Comments
+ version: "0.0.1" # The application version
+ # cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
+ # # Should match the name of your .icon file without the extension
+ # # If not set and Assets.car exists, defaults to "appicon"
+
+# iOS build configuration (uncomment to customise iOS project generation)
+# Note: Keys under `ios` OVERRIDE values under `info` when set.
+# ios:
+# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
+# bundleID: "com.mycompany.myproduct"
+# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
+# displayName: "My Product"
+# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
+# version: "0.0.1"
+# # The company/organisation name for templates and project settings
+# company: "My Company"
+# # Additional comments to embed in Info.plist metadata
+# comments: "Some Product Comments"
+
+# Dev mode configuration
+dev_mode:
+ root_path: .
+ log_level: warn
+ debounce: 1000
+ ignore:
+ dir:
+ - .git
+ - node_modules
+ - frontend
+ - bin
+ file:
+ - .DS_Store
+ - .gitignore
+ - .gitkeep
+ watched_extension:
+ - "*.go"
+ - "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
+ - "*.ts" # The frontend directory will be excluded entirely by the setting above.
+ git_ignore: true
+ executes:
+ - cmd: wails3 build DEV=true
+ type: blocking
+ - cmd: wails3 task common:dev:frontend
+ type: background
+ - cmd: wails3 task run
+ type: primary
+
+# File Associations
+# More information at: https://v3.wails.io/noit/done/yet
+fileAssociations:
+# - ext: wails
+# name: Wails
+# description: Wails Application File
+# iconName: wailsFileIcon
+# role: Editor
+# - ext: jpg
+# name: JPEG
+# description: Image File
+# iconName: jpegFileIcon
+# role: Editor
+# mimeType: image/jpeg # (optional)
+
+# Other data
+other:
+ - name: My Other Data
\ No newline at end of file
diff --git a/client/ui/build/darwin/Info.dev.plist b/client/ui/build/darwin/Info.dev.plist
new file mode 100644
index 000000000..78f5a7b1c
--- /dev/null
+++ b/client/ui/build/darwin/Info.dev.plist
@@ -0,0 +1,38 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ NetBird
+ CFBundleDisplayName
+ NetBird
+ CFBundleExecutable
+ netbird-ui
+ CFBundleIdentifier
+ io.netbird.client
+ CFBundleVersion
+ 0.0.1
+ CFBundleGetInfoString
+ This is a comment
+ CFBundleShortVersionString
+ 0.0.1
+ CFBundleIconFile
+ icons
+ CFBundleIconName
+ appicon
+ LSMinimumSystemVersion
+ 10.15.0
+ NSHighResolutionCapable
+ true
+ LSUIElement
+ 1
+ NSHumanReadableCopyright
+ NetBird GmbH
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+
+
\ No newline at end of file
diff --git a/client/ui/build/darwin/Info.plist b/client/ui/build/darwin/Info.plist
new file mode 100644
index 000000000..1e12b049b
--- /dev/null
+++ b/client/ui/build/darwin/Info.plist
@@ -0,0 +1,36 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ NetBird
+ CFBundleDisplayName
+ NetBird
+ CFBundleExecutable
+ netbird-ui
+ CFBundleIdentifier
+ io.netbird.client
+ CFBundleVersion
+ 0.0.1
+ CFBundleGetInfoString
+ This is a comment
+ CFBundleShortVersionString
+ 0.0.1
+ CFBundleIconFile
+ icons
+ CFBundleIconName
+ appicon
+ LSMinimumSystemVersion
+ 10.15.0
+ NSHighResolutionCapable
+ true
+
+ LSUIElement
+ 1
+ NSHumanReadableCopyright
+ NetBird GmbH
+
+
\ No newline at end of file
diff --git a/client/ui/build/darwin/Taskfile.yml b/client/ui/build/darwin/Taskfile.yml
new file mode 100644
index 000000000..8a5c27bdc
--- /dev/null
+++ b/client/ui/build/darwin/Taskfile.yml
@@ -0,0 +1,210 @@
+version: '3'
+
+includes:
+ common: ../Taskfile.yml
+
+vars:
+ # Signing configuration - edit these values for your project
+ # SIGN_IDENTITY: "Developer ID Application: Your Company (TEAMID)"
+ # KEYCHAIN_PROFILE: "my-notarize-profile"
+ # ENTITLEMENTS: "build/darwin/entitlements.plist"
+
+ # Docker image for cross-compilation (used when building on non-macOS)
+ CROSS_IMAGE: wails-cross
+
+tasks:
+ build:
+ summary: Builds the application
+ cmds:
+ - task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}'
+ vars:
+ ARCH: '{{.ARCH}}'
+ DEV: '{{.DEV}}'
+ OUTPUT: '{{.OUTPUT}}'
+ EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+ vars:
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+
+ build:native:
+ summary: Builds the application natively on macOS
+ internal: true
+ deps:
+ - task: common:go:mod:tidy
+ - task: common:build:frontend
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ DEV:
+ ref: .DEV
+ - task: common:generate:icons
+ cmds:
+ - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
+ vars:
+ BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}'
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+ env:
+ GOOS: darwin
+ CGO_ENABLED: 1
+ GOARCH: '{{.ARCH | default ARCH}}'
+ CGO_CFLAGS: "-mmacosx-version-min=10.15"
+ CGO_LDFLAGS: "-mmacosx-version-min=10.15"
+ MACOSX_DEPLOYMENT_TARGET: "10.15"
+
+ build:docker:
+ summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts)
+ internal: true
+ deps:
+ - task: common:build:frontend
+ - task: common:generate:icons
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required for cross-compilation. Please install Docker."
+ - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+ msg: |
+ Docker image '{{.CROSS_IMAGE}}' not found.
+ Build it first: wails3 task setup:docker
+ cmds:
+ - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}}
+ - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+ - mkdir -p {{.BIN_DIR}}
+ - mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
+ vars:
+ DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}'
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+ # Mount Go module cache for faster builds
+ GO_CACHE_MOUNT:
+ sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
+ # Extract replace directives from go.mod and create -v mounts for each
+ # Handles both relative (=> ../) and absolute (=> /) paths
+ REPLACE_MOUNTS:
+ sh: |
+ grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
+ path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
+ # Convert relative paths to absolute
+ if [ "${path#/}" = "$path" ]; then
+ path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
+ fi
+ # Only mount if directory exists
+ if [ -d "$path" ]; then
+ echo "-v $path:$path:ro"
+ fi
+ done | tr '\n' ' '
+
+ build:universal:
+ summary: Builds darwin universal binary (arm64 + amd64)
+ deps:
+ - task: build
+ vars:
+ ARCH: amd64
+ OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
+ - task: build
+ vars:
+ ARCH: arm64
+ OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+ cmds:
+ - task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}'
+
+ build:universal:lipo:native:
+ summary: Creates universal binary using native lipo (macOS)
+ internal: true
+ cmds:
+ - lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+ - rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+
+ build:universal:lipo:go:
+ summary: Creates universal binary using wails3 tool lipo (Linux/Windows)
+ internal: true
+ cmds:
+ - wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+ - rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+
+ package:
+ summary: Packages the application into a `.app` bundle
+ deps:
+ - task: build
+ cmds:
+ - task: create:app:bundle
+
+ package:universal:
+ summary: Packages darwin universal binary (arm64 + amd64)
+ deps:
+ - task: build:universal
+ cmds:
+ - task: create:app:bundle
+
+
+ create:app:bundle:
+ summary: Creates an `.app` bundle
+ cmds:
+ - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
+ - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+ - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+ - |
+ if [ -f build/darwin/Assets.car ]; then
+ cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+ fi
+ - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
+ - cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents"
+ - task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}'
+
+ codesign:adhoc:
+ summary: Ad-hoc signs the app bundle (macOS only)
+ internal: true
+ cmds:
+ - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+
+ codesign:skip:
+ summary: Skips codesigning when cross-compiling
+ internal: true
+ cmds:
+ - 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
+
+ run:
+ deps:
+ - task: common:generate:icons
+ cmds:
+ - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
+ - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+ - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+ - |
+ if [ -f build/darwin/Assets.car ]; then
+ cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+ fi
+ - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
+ - cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
+ - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+ - '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}'
+
+ sign:
+ summary: Signs the application bundle with Developer ID
+ desc: |
+ Signs the .app bundle for distribution.
+ Configure SIGN_IDENTITY in the vars section at the top of this file.
+ deps:
+ - task: package
+ cmds:
+ - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}}
+ preconditions:
+ - sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
+ msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
+
+ sign:notarize:
+ summary: Signs and notarizes the application bundle
+ desc: |
+ Signs the .app bundle and submits it for notarization.
+ Configure SIGN_IDENTITY and KEYCHAIN_PROFILE in the vars section at the top of this file.
+
+ Setup (one-time):
+ wails3 signing credentials --apple-id "you@email.com" --team-id "TEAMID" --password "app-specific-password" --profile "my-profile"
+ deps:
+ - task: package
+ cmds:
+ - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} --notarize --keychain-profile {{.KEYCHAIN_PROFILE}}
+ preconditions:
+ - sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
+ msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
+ - sh: '[ -n "{{.KEYCHAIN_PROFILE}}" ]'
+ msg: "KEYCHAIN_PROFILE is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
diff --git a/client/ui/build/darwin/icons.icns b/client/ui/build/darwin/icons.icns
new file mode 100644
index 000000000..fb78a18a9
Binary files /dev/null and b/client/ui/build/darwin/icons.icns differ
diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross
new file mode 100644
index 000000000..a487b8db0
--- /dev/null
+++ b/client/ui/build/docker/Dockerfile.cross
@@ -0,0 +1,203 @@
+# Cross-compile Wails v3 apps to any platform
+#
+# Darwin: Zig + macOS SDK
+# Linux: Native GCC when host matches target, Zig for cross-arch
+# Windows: Zig + bundled mingw
+#
+# Usage:
+# docker build -t wails-cross -f Dockerfile.cross .
+# docker run --rm -v $(pwd):/app wails-cross darwin arm64
+# docker run --rm -v $(pwd):/app wails-cross darwin amd64
+# docker run --rm -v $(pwd):/app wails-cross linux amd64
+# docker run --rm -v $(pwd):/app wails-cross linux arm64
+# docker run --rm -v $(pwd):/app wails-cross windows amd64
+# docker run --rm -v $(pwd):/app wails-cross windows arm64
+
+FROM golang:1.25-bookworm
+
+ARG TARGETARCH
+
+# Install base tools, GCC, and GTK/WebKit dev packages
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl xz-utils nodejs npm pkg-config gcc libc6-dev \
+ libgtk-3-dev libwebkit2gtk-4.1-dev \
+ libgtk-4-dev libwebkitgtk-6.0-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Zig - automatically selects correct binary for host architecture
+ARG ZIG_VERSION=0.14.0
+RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
+ curl -L "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" \
+ | tar -xJ -C /opt \
+ && ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
+
+# Download macOS SDK (required for darwin targets)
+ARG MACOS_SDK_VERSION=14.5
+RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \
+ | tar -xJ -C /opt \
+ && mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk
+
+ENV MACOS_SDK_PATH=/opt/macos-sdk
+
+# Create Zig CC wrappers for cross-compilation targets
+# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch)
+
+# Darwin arm64
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+ if [ $SKIP_NEXT -eq 1 ]; then
+ SKIP_NEXT=0
+ continue
+ fi
+ case "$arg" in
+ -target) SKIP_NEXT=1 ;;
+ -mmacosx-version-min=*) ;;
+ *) ARGS="$ARGS $arg" ;;
+ esac
+done
+exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-darwin-arm64
+
+# Darwin amd64
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+ if [ $SKIP_NEXT -eq 1 ]; then
+ SKIP_NEXT=0
+ continue
+ fi
+ case "$arg" in
+ -target) SKIP_NEXT=1 ;;
+ -mmacosx-version-min=*) ;;
+ *) ARGS="$ARGS $arg" ;;
+ esac
+done
+exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-darwin-amd64
+
+# Windows amd64 - uses Zig's bundled mingw
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+ if [ $SKIP_NEXT -eq 1 ]; then
+ SKIP_NEXT=0
+ continue
+ fi
+ case "$arg" in
+ -target) SKIP_NEXT=1 ;;
+ -Wl,*) ;;
+ *) ARGS="$ARGS $arg" ;;
+ esac
+done
+exec zig cc -target x86_64-windows-gnu $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-windows-amd64
+
+# Windows arm64 - uses Zig's bundled mingw
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+ if [ $SKIP_NEXT -eq 1 ]; then
+ SKIP_NEXT=0
+ continue
+ fi
+ case "$arg" in
+ -target) SKIP_NEXT=1 ;;
+ -Wl,*) ;;
+ *) ARGS="$ARGS $arg" ;;
+ esac
+done
+exec zig cc -target aarch64-windows-gnu $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-windows-arm64
+
+# Build script
+COPY <<'SCRIPT' /usr/local/bin/build.sh
+#!/bin/sh
+set -e
+
+OS=${1:-darwin}
+ARCH=${2:-arm64}
+
+case "${OS}-${ARCH}" in
+ darwin-arm64|darwin-aarch64)
+ export CC=zcc-darwin-arm64
+ export GOARCH=arm64
+ export GOOS=darwin
+ ;;
+ darwin-amd64|darwin-x86_64)
+ export CC=zcc-darwin-amd64
+ export GOARCH=amd64
+ export GOOS=darwin
+ ;;
+ linux-arm64|linux-aarch64)
+ export CC=gcc
+ export GOARCH=arm64
+ export GOOS=linux
+ ;;
+ linux-amd64|linux-x86_64)
+ export CC=gcc
+ export GOARCH=amd64
+ export GOOS=linux
+ ;;
+ windows-arm64|windows-aarch64)
+ export CC=zcc-windows-arm64
+ export GOARCH=arm64
+ export GOOS=windows
+ ;;
+ windows-amd64|windows-x86_64)
+ export CC=zcc-windows-amd64
+ export GOARCH=amd64
+ export GOOS=windows
+ ;;
+ *)
+ echo "Usage: "
+ echo " os: darwin, linux, windows"
+ echo " arch: amd64, arm64"
+ exit 1
+ ;;
+esac
+
+export CGO_ENABLED=1
+export CGO_CFLAGS="-w"
+
+# Build frontend if exists and not already built (host may have built it)
+if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
+ (cd frontend && npm install --silent && npm run build --silent)
+fi
+
+# Build
+APP=${APP_NAME:-$(basename $(pwd))}
+mkdir -p bin
+
+EXT=""
+LDFLAGS="-s -w"
+if [ "$GOOS" = "windows" ]; then
+ EXT=".exe"
+ LDFLAGS="-s -w -H windowsgui"
+fi
+
+TAGS="production"
+if [ -n "$EXTRA_TAGS" ]; then
+ TAGS="${TAGS},${EXTRA_TAGS}"
+fi
+
+go build -tags "$TAGS" -trimpath -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} .
+echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}"
+SCRIPT
+RUN chmod +x /usr/local/bin/build.sh
+
+WORKDIR /app
+ENTRYPOINT ["/usr/local/bin/build.sh"]
+CMD ["darwin", "arm64"]
diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server
new file mode 100644
index 000000000..58fb64f76
--- /dev/null
+++ b/client/ui/build/docker/Dockerfile.server
@@ -0,0 +1,41 @@
+# Wails Server Mode Dockerfile
+# Multi-stage build for minimal image size
+
+# Build stage
+FROM golang:alpine AS builder
+
+WORKDIR /app
+
+# Install build dependencies
+RUN apk add --no-cache git
+
+# Copy source code
+COPY . .
+
+# Remove local replace directive if present (for production builds)
+RUN sed -i '/^replace/d' go.mod || true
+
+# Download dependencies
+RUN go mod tidy
+
+# Build the server binary
+RUN go build -tags server -ldflags="-s -w" -o server .
+
+# Runtime stage - minimal image
+FROM gcr.io/distroless/static-debian12
+
+# Copy the binary
+COPY --from=builder /app/server /server
+
+# Copy frontend assets
+COPY --from=builder /app/frontend/dist /frontend/dist
+
+# Expose the default port
+EXPOSE 8080
+
+# Bind to all interfaces (required for Docker)
+# Can be overridden at runtime with -e WAILS_SERVER_HOST=...
+ENV WAILS_SERVER_HOST=0.0.0.0
+
+# Run the server
+ENTRYPOINT ["/server"]
diff --git a/client/ui/build/linux/Taskfile.yml b/client/ui/build/linux/Taskfile.yml
new file mode 100644
index 000000000..94d041375
--- /dev/null
+++ b/client/ui/build/linux/Taskfile.yml
@@ -0,0 +1,235 @@
+version: '3'
+
+includes:
+ common: ../Taskfile.yml
+
+vars:
+ # Signing configuration - edit these values for your project
+ # PGP_KEY: "path/to/signing-key.asc"
+ # SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
+ #
+ # Password is stored securely in system keychain. Run: wails3 setup signing
+
+ # Docker image for cross-compilation (used when building on non-Linux or no CC available)
+ CROSS_IMAGE: wails-cross
+
+tasks:
+ build:
+ summary: Builds the application for Linux
+ cmds:
+ # Linux requires CGO - use Docker when:
+ # 1. Cross-compiling from non-Linux, OR
+ # 2. No C compiler is available, OR
+ # 3. Target architecture differs from host architecture (cross-arch compilation)
+ - task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
+ vars:
+ ARCH: '{{.ARCH}}'
+ DEV: '{{.DEV}}'
+ OUTPUT: '{{.OUTPUT}}'
+ EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+ vars:
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+ # Determine target architecture (defaults to host ARCH if not specified)
+ TARGET_ARCH: '{{.ARCH | default ARCH}}'
+ # Check if a C compiler is available (gcc or clang)
+ HAS_CC:
+ sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
+
+ build:native:
+ summary: Builds the application natively on Linux
+ internal: true
+ deps:
+ - task: common:go:mod:tidy
+ - task: common:build:frontend
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ DEV:
+ ref: .DEV
+ - task: common:generate:icons
+ - task: generate:dotdesktop
+ cmds:
+ - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
+ vars:
+ BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}'
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+ env:
+ GOOS: linux
+ CGO_ENABLED: 1
+ GOARCH: '{{.ARCH | default ARCH}}'
+
+ build:docker:
+ summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
+ internal: true
+ deps:
+ - task: common:build:frontend
+ - task: common:generate:icons
+ - task: generate:dotdesktop
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required for cross-compilation to Linux. Please install Docker."
+ - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+ msg: |
+ Docker image '{{.CROSS_IMAGE}}' not found.
+ Build it first: wails3 task setup:docker
+ cmds:
+ - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
+ - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+ - mkdir -p {{.BIN_DIR}}
+ - mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
+ vars:
+ DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+ # Mount Go module cache for faster builds
+ GO_CACHE_MOUNT:
+ sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
+ # Extract replace directives from go.mod and create -v mounts for each
+ REPLACE_MOUNTS:
+ sh: |
+ grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
+ path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
+ # Convert relative paths to absolute
+ if [ "${path#/}" = "$path" ]; then
+ path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
+ fi
+ # Only mount if directory exists
+ if [ -d "$path" ]; then
+ echo "-v $path:$path:ro"
+ fi
+ done | tr '\n' ' '
+
+ package:
+ summary: Packages the application for Linux
+ deps:
+ - task: build
+ cmds:
+ - task: create:appimage
+ - task: create:deb
+ - task: create:rpm
+ - task: create:aur
+
+ create:appimage:
+ summary: Creates an AppImage
+ dir: build/linux/appimage
+ deps:
+ - task: build
+ - task: generate:dotdesktop
+ cmds:
+ - cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
+ - cp ../../appicon.png "{{.APP_NAME}}.png"
+ - wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
+ vars:
+ APP_NAME: '{{.APP_NAME}}'
+ APP_BINARY: '../../../bin/{{.APP_NAME}}'
+ ICON: '{{.APP_NAME}}.png'
+ DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
+ OUTPUT_DIR: '../../../bin'
+
+ create:deb:
+ summary: Creates a deb package
+ deps:
+ - task: build
+ cmds:
+ - task: generate:dotdesktop
+ - task: generate:deb
+
+ create:rpm:
+ summary: Creates a rpm package
+ deps:
+ - task: build
+ cmds:
+ - task: generate:dotdesktop
+ - task: generate:rpm
+
+ create:aur:
+ summary: Creates a arch linux packager package
+ deps:
+ - task: build
+ cmds:
+ - task: generate:dotdesktop
+ - task: generate:aur
+
+ generate:deb:
+ summary: Creates a deb package
+ cmds:
+ - wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+ generate:rpm:
+ summary: Creates a rpm package
+ cmds:
+ - wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+ generate:aur:
+ summary: Creates a arch linux packager package
+ cmds:
+ - wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+ generate:dotdesktop:
+ summary: Generates a `.desktop` file
+ dir: build
+ cmds:
+ - mkdir -p {{.ROOT_DIR}}/build/linux/appimage
+ - wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
+ # Wrap Exec= with `env WEBKIT_DISABLE_DMABUF_RENDERER=1 ...` so launches
+ # from any desktop environment use the working renderer. See build/linux/Taskfile.yml :run for the matching dev-mode env block.
+ - sed -i -E 's|^Exec=([^ ]+)(.*)$|Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 \1\2|' {{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop
+ vars:
+ APP_NAME: '{{.APP_NAME}}'
+ EXEC: '{{.APP_NAME}}'
+ ICON: '{{.APP_NAME}}'
+ CATEGORIES: 'Development;'
+ OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
+
+ run:
+ cmds:
+ - '{{.BIN_DIR}}/{{.APP_NAME}}'
+ env:
+ # WebKitGTK 2.50's default DMA-BUF renderer fails on RDP, VirtualBox/QEMU,
+ # and some bare WMs (Fluxbox, dwm) where DRM dumb-buffer access is
+ # restricted. Disabling it falls back to the GLES2/cairo path which works
+ # everywhere. Production launchers must set this too.
+ WEBKIT_DISABLE_DMABUF_RENDERER: "1"
+
+ sign:deb:
+ summary: Signs the DEB package
+ desc: |
+ Signs the .deb package with a PGP key.
+ Configure PGP_KEY in the vars section at the top of this file.
+ Password is retrieved from system keychain (run: wails3 setup signing)
+ deps:
+ - task: create:deb
+ cmds:
+ - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" --pgp-key {{.PGP_KEY}} {{if .SIGN_ROLE}}--role {{.SIGN_ROLE}}{{end}}
+ preconditions:
+ - sh: '[ -n "{{.PGP_KEY}}" ]'
+ msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
+
+ sign:rpm:
+ summary: Signs the RPM package
+ desc: |
+ Signs the .rpm package with a PGP key.
+ Configure PGP_KEY in the vars section at the top of this file.
+ Password is retrieved from system keychain (run: wails3 setup signing)
+ deps:
+ - task: create:rpm
+ cmds:
+ - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" --pgp-key {{.PGP_KEY}}
+ preconditions:
+ - sh: '[ -n "{{.PGP_KEY}}" ]'
+ msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
+
+ sign:packages:
+ summary: Signs all Linux packages (DEB and RPM)
+ desc: |
+ Signs both .deb and .rpm packages with a PGP key.
+ Configure PGP_KEY in the vars section at the top of this file.
+ Password is retrieved from system keychain (run: wails3 setup signing)
+ cmds:
+ - task: sign:deb
+ - task: sign:rpm
+ preconditions:
+ - sh: '[ -n "{{.PGP_KEY}}" ]'
+ msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
diff --git a/client/ui/build/linux/appimage/build.sh b/client/ui/build/linux/appimage/build.sh
new file mode 100644
index 000000000..85901c34e
--- /dev/null
+++ b/client/ui/build/linux/appimage/build.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Copyright (c) 2018-Present Lea Anthony
+# SPDX-License-Identifier: MIT
+
+# Fail script on any error
+set -euxo pipefail
+
+# Define variables
+APP_DIR="${APP_NAME}.AppDir"
+
+# Create AppDir structure
+mkdir -p "${APP_DIR}/usr/bin"
+cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
+cp "${ICON_PATH}" "${APP_DIR}/"
+cp "${DESKTOP_FILE}" "${APP_DIR}/"
+
+if [[ $(uname -m) == *x86_64* ]]; then
+ # Download linuxdeploy and make it executable
+ wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
+ chmod +x linuxdeploy-x86_64.AppImage
+
+ # Run linuxdeploy to bundle the application
+ ./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
+else
+ # Download linuxdeploy and make it executable (arm64)
+ wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage
+ chmod +x linuxdeploy-aarch64.AppImage
+
+ # Run linuxdeploy to bundle the application (arm64)
+ ./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage
+fi
+
+# Rename the generated AppImage
+mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"
+
diff --git a/client/ui/build/linux/desktop b/client/ui/build/linux/desktop
new file mode 100644
index 000000000..deadfe9f4
--- /dev/null
+++ b/client/ui/build/linux/desktop
@@ -0,0 +1,13 @@
+[Desktop Entry]
+Version=1.0
+Name=NetBird
+Comment=NetBird desktop client
+# The Exec line includes %u to pass the URL to the application
+Exec=/usr/local/bin/netbird-ui %u
+Terminal=false
+Type=Application
+Icon=netbird-ui
+Categories=Utility;
+StartupWMClass=netbird-ui
+
+
diff --git a/client/ui/build/linux/netbird-ui.desktop b/client/ui/build/linux/netbird-ui.desktop
new file mode 100755
index 000000000..6b6ed42a5
--- /dev/null
+++ b/client/ui/build/linux/netbird-ui.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=netbird-ui
+Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
+Icon=netbird-ui
+Categories=Development;
+Terminal=false
+Keywords=wails
+Version=1.0
+StartupNotify=false
diff --git a/client/ui/build/netbird.desktop b/client/ui/build/linux/netbird.desktop
similarity index 54%
rename from client/ui/build/netbird.desktop
rename to client/ui/build/linux/netbird.desktop
index b3a1b92dc..a81f3698a 100644
--- a/client/ui/build/netbird.desktop
+++ b/client/ui/build/linux/netbird.desktop
@@ -1,8 +1,9 @@
[Desktop Entry]
Name=Netbird
-Exec=/usr/bin/netbird-ui
+Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
Icon=netbird
Type=Application
Terminal=false
Categories=Utility;
Keywords=netbird;
+StartupWMClass=org.wails.netbird
\ No newline at end of file
diff --git a/client/ui/build/linux/nfpm/nfpm.yaml b/client/ui/build/linux/nfpm/nfpm.yaml
new file mode 100644
index 000000000..764855a63
--- /dev/null
+++ b/client/ui/build/linux/nfpm/nfpm.yaml
@@ -0,0 +1,70 @@
+# Feel free to remove those if you don't want/need to use them.
+# Make sure to check the documentation at https://nfpm.goreleaser.com
+#
+# The lines below are called `modelines`. See `:help modeline`
+
+name: "netbird-ui"
+arch: ${GOARCH}
+platform: "linux"
+version: "0.0.1"
+section: "default"
+priority: "extra"
+maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
+description: "NetBird desktop client"
+vendor: "NetBird"
+homepage: "https://wails.io"
+license: "MIT"
+release: "1"
+
+contents:
+ - src: "./bin/netbird-ui"
+ dst: "/usr/local/bin/netbird-ui"
+ - src: "./build/appicon.png"
+ dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png"
+ - src: "./build/linux/netbird-ui.desktop"
+ dst: "/usr/share/applications/netbird-ui.desktop"
+
+# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
+depends:
+ - libgtk-4-1 (>= 4.14)
+ - libwebkitgtk-6.0-4
+ - xdg-utils
+
+# Distribution-specific overrides for different package formats
+overrides:
+ # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE
+ rpm:
+ depends:
+ - (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
+ - (webkitgtk6.0 or libwebkitgtk-6_0-4)
+ - xdg-utils
+
+ # Arch Linux packages
+ archlinux:
+ depends:
+ - gtk4
+ - webkitgtk-6.0
+ - xdg-utils
+
+# scripts section to ensure desktop database is updated after install
+scripts:
+ postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
+ # You can also add preremove, postremove if needed
+ # preremove: "./build/linux/nfpm/scripts/preremove.sh"
+ # postremove: "./build/linux/nfpm/scripts/postremove.sh"
+
+# replaces:
+# - foobar
+# provides:
+# - bar
+# depends:
+# - gtk3
+# - libwebkit2gtk
+# recommends:
+# - whatever
+# suggests:
+# - something-else
+# conflicts:
+# - not-foo
+# - not-bar
+# changelog: "changelog.yaml"
diff --git a/client/ui/build/linux/nfpm/scripts/postinstall.sh b/client/ui/build/linux/nfpm/scripts/postinstall.sh
new file mode 100644
index 000000000..4bbb815a3
--- /dev/null
+++ b/client/ui/build/linux/nfpm/scripts/postinstall.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+
+# Update desktop database for .desktop file changes
+# This makes the application appear in application menus and registers its capabilities.
+if command -v update-desktop-database >/dev/null 2>&1; then
+ echo "Updating desktop database..."
+ update-desktop-database -q /usr/share/applications
+else
+ echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2
+fi
+
+# Update MIME database for custom URL schemes (x-scheme-handler)
+# This ensures the system knows how to handle your custom protocols.
+if command -v update-mime-database >/dev/null 2>&1; then
+ echo "Updating MIME database..."
+ update-mime-database -n /usr/share/mime
+else
+ echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2
+fi
+
+exit 0
diff --git a/client/ui/build/linux/nfpm/scripts/postremove.sh b/client/ui/build/linux/nfpm/scripts/postremove.sh
new file mode 100644
index 000000000..a9bf588e2
--- /dev/null
+++ b/client/ui/build/linux/nfpm/scripts/postremove.sh
@@ -0,0 +1 @@
+#!/bin/bash
diff --git a/client/ui/build/linux/nfpm/scripts/preinstall.sh b/client/ui/build/linux/nfpm/scripts/preinstall.sh
new file mode 100644
index 000000000..a9bf588e2
--- /dev/null
+++ b/client/ui/build/linux/nfpm/scripts/preinstall.sh
@@ -0,0 +1 @@
+#!/bin/bash
diff --git a/client/ui/build/linux/nfpm/scripts/preremove.sh b/client/ui/build/linux/nfpm/scripts/preremove.sh
new file mode 100644
index 000000000..a9bf588e2
--- /dev/null
+++ b/client/ui/build/linux/nfpm/scripts/preremove.sh
@@ -0,0 +1 @@
+#!/bin/bash
diff --git a/client/ui/build/windows/Taskfile.yml b/client/ui/build/windows/Taskfile.yml
new file mode 100644
index 000000000..f51f7fbee
--- /dev/null
+++ b/client/ui/build/windows/Taskfile.yml
@@ -0,0 +1,243 @@
+version: '3'
+
+includes:
+ common: ../Taskfile.yml
+
+vars:
+ # Signing configuration - edit these values for your project
+ # SIGN_CERTIFICATE: "path/to/certificate.pfx"
+ # SIGN_THUMBPRINT: "certificate-thumbprint" # Alternative to SIGN_CERTIFICATE
+ # TIMESTAMP_SERVER: "http://timestamp.digicert.com"
+ #
+ # Password is stored securely in system keychain. Run: wails3 setup signing
+
+ # Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows)
+ CROSS_IMAGE: wails-cross
+
+tasks:
+ build:
+ summary: Builds the application for Windows
+ cmds:
+ # CGO Windows builds from Linux use mingw-w64 (lighter than docker).
+ # Docker is only needed if mingw-w64 is unavailable.
+ - task: build:native
+ vars:
+ ARCH: '{{.ARCH}}'
+ DEV: '{{.DEV}}'
+ EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+ vars:
+ CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
+
+ build:console:
+ summary: Builds a console-attached Windows binary so logs go to the terminal.
+ desc: |
+ Same as `windows:build` but links against the console PE subsystem
+ instead of windowsgui, so stdout/stderr (logrus, panics) print to the
+ terminal that launched the .exe. Useful for chasing tray, event-stream,
+ or daemon-RPC bugs that have no other feedback channel on Windows.
+
+ Output is bin/netbird-ui-console.exe — kept distinct so the production
+ binary built by `windows:build` isn't shadowed.
+
+ Cross-compile from Linux works the same way:
+ CGO_ENABLED=1 task windows:build:console
+
+ Pass DEV=true to drop the `production` build tag so the WebKit/WebView2
+ DevTools inspector (right-click → Inspect, or F12) stays enabled and the
+ frontend JS console is reachable — same DEV handling as windows:build:
+ CGO_ENABLED=1 task windows:build:console DEV=true
+ deps:
+ - task: common:go:mod:tidy
+ - task: common:build:frontend
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ DEV:
+ ref: .DEV
+ - task: common:generate:icons
+ preconditions:
+ - sh: '[ "{{OS}}" = "windows" ] || [ "{{.CGO_ENABLED}}" != "1" ] || command -v {{.CC}}'
+ msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64 (Debian/Ubuntu) / sudo dnf install mingw64-gcc (Fedora)"
+ cmds:
+ - task: generate:syso
+ - go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-console.exe"
+ - cmd: powershell Remove-item *.syso
+ platforms: [windows]
+ - cmd: rm -f *.syso
+ platforms: [linux, darwin]
+ vars:
+ # Identical to build:native's flags (including DEV handling) except no
+ # -H windowsgui, so the binary attaches to the launching console. With
+ # DEV=true the `production` tag is dropped, keeping the WebKit/WebView2
+ # DevTools inspector enabled so the frontend JS console is reachable.
+ BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}'
+ CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
+ CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
+ env:
+ GOOS: windows
+ CGO_ENABLED: '{{.CGO_ENABLED}}'
+ GOARCH: '{{.ARCH | default ARCH}}'
+ CC: '{{.CC}}'
+
+ build:native:
+ summary: Builds for Windows natively, or cross-compiles from Linux/macOS via mingw-w64.
+ internal: true
+ deps:
+ - task: common:go:mod:tidy
+ - task: common:build:frontend
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ DEV:
+ ref: .DEV
+ - task: common:generate:icons
+ preconditions:
+ # When cross-compiling with CGO from a non-Windows host, the mingw-w64
+ # cross-gcc must be present. Native Windows builds skip this check.
+ - sh: '[ "{{OS}}" = "windows" ] || [ "{{.CGO_ENABLED}}" != "1" ] || command -v {{.CC}}'
+ msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64 (Debian/Ubuntu) / sudo dnf install mingw64-gcc (Fedora)"
+ cmds:
+ - task: generate:syso
+ - go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
+ - cmd: powershell Remove-item *.syso
+ platforms: [windows]
+ - cmd: rm -f *.syso
+ platforms: [linux, darwin]
+ vars:
+ BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s -H windowsgui"{{end}}'
+ CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
+ CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
+ env:
+ GOOS: windows
+ CGO_ENABLED: '{{.CGO_ENABLED}}'
+ GOARCH: '{{.ARCH | default ARCH}}'
+ CC: '{{.CC}}'
+
+ build:docker:
+ summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
+ internal: true
+ deps:
+ - task: common:build:frontend
+ - task: common:generate:icons
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required for CGO cross-compilation. Please install Docker."
+ - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+ msg: |
+ Docker image '{{.CROSS_IMAGE}}' not found.
+ Build it first: wails3 task setup:docker
+ cmds:
+ - task: generate:syso
+ - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}}
+ - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+ - rm -f *.syso
+ vars:
+ DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
+ # Mount Go module cache for faster builds
+ GO_CACHE_MOUNT:
+ sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
+ # Extract replace directives from go.mod and create -v mounts for each
+ REPLACE_MOUNTS:
+ sh: |
+ grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
+ path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
+ # Convert relative paths to absolute
+ if [ "${path#/}" = "$path" ]; then
+ path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
+ fi
+ # Only mount if directory exists
+ if [ -d "$path" ]; then
+ echo "-v $path:$path:ro"
+ fi
+ done | tr '\n' ' '
+
+ package:
+ summary: Packages the application
+ cmds:
+ - task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
+ vars:
+ FORMAT: '{{.FORMAT | default "nsis"}}'
+
+ generate:syso:
+ summary: Generates Windows `.syso` file
+ dir: build
+ cmds:
+ - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
+ vars:
+ ARCH: '{{.ARCH | default ARCH}}'
+
+ create:nsis:installer:
+ summary: Creates an NSIS installer
+ dir: build/windows/nsis
+ deps:
+ - task: build
+ cmds:
+ # Create the Microsoft WebView2 bootstrapper if it doesn't exist
+ - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
+ - |
+ {{if eq OS "windows"}}
+ makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
+ {{else}}
+ makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
+ {{end}}
+ vars:
+ ARCH: '{{.ARCH | default ARCH}}'
+ ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
+
+ create:msix:package:
+ summary: Creates an MSIX package
+ deps:
+ - task: build
+ cmds:
+ - |-
+ wails3 tool msix \
+ --config "{{.ROOT_DIR}}/wails.json" \
+ --name "{{.APP_NAME}}" \
+ --executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
+ --arch "{{.ARCH}}" \
+ --out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
+ {{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
+ {{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
+ {{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
+ vars:
+ ARCH: '{{.ARCH | default ARCH}}'
+ CERT_PATH: '{{.CERT_PATH | default ""}}'
+ PUBLISHER: '{{.PUBLISHER | default ""}}'
+ USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
+
+ install:msix:tools:
+ summary: Installs tools required for MSIX packaging
+ cmds:
+ - wails3 tool msix-install-tools
+
+ run:
+ cmds:
+ - '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
+
+ sign:
+ summary: Signs the Windows executable
+ desc: |
+ Signs the .exe with an Authenticode certificate.
+ Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file.
+ Password is retrieved from system keychain (run: wails3 setup signing)
+ deps:
+ - task: build
+ cmds:
+ - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}}
+ preconditions:
+ - sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]'
+ msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml"
+
+ sign:installer:
+ summary: Signs the NSIS installer
+ desc: |
+ Creates and signs the NSIS installer.
+ Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file.
+ Password is retrieved from system keychain (run: wails3 setup signing)
+ deps:
+ - task: create:nsis:installer
+ cmds:
+ - wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}}
+ preconditions:
+ - sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]'
+ msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml"
diff --git a/client/ui/build/windows/icon.ico b/client/ui/build/windows/icon.ico
new file mode 100644
index 000000000..7abbfa5a3
Binary files /dev/null and b/client/ui/build/windows/icon.ico differ
diff --git a/client/ui/build/windows/info.json b/client/ui/build/windows/info.json
new file mode 100644
index 000000000..a67c8fd81
--- /dev/null
+++ b/client/ui/build/windows/info.json
@@ -0,0 +1,15 @@
+{
+ "fixed": {
+ "file_version": "0.0.1"
+ },
+ "info": {
+ "0000": {
+ "ProductVersion": "0.0.1",
+ "CompanyName": "NetBird",
+ "FileDescription": "NetBird desktop client",
+ "LegalCopyright": "NetBird GmbH",
+ "ProductName": "NetBird",
+ "Comments": "This is a comment"
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/ui/build/windows/msix/app_manifest.xml b/client/ui/build/windows/msix/app_manifest.xml
new file mode 100644
index 000000000..0ae55ce77
--- /dev/null
+++ b/client/ui/build/windows/msix/app_manifest.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ NetBird
+ NetBird
+ NetBird desktop client
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/ui/build/windows/msix/template.xml b/client/ui/build/windows/msix/template.xml
new file mode 100644
index 000000000..437a68097
--- /dev/null
+++ b/client/ui/build/windows/msix/template.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ NetBird
+ NetBird
+ NetBird desktop client
+ Assets\AppIcon.png
+
+
+
+
+
+
+
diff --git a/client/ui/build/windows/nsis/project.nsi b/client/ui/build/windows/nsis/project.nsi
new file mode 100644
index 000000000..8d2530972
--- /dev/null
+++ b/client/ui/build/windows/nsis/project.nsi
@@ -0,0 +1,114 @@
+Unicode true
+
+####
+## Please note: Template replacements don't work in this file. They are provided with default defines like
+## mentioned underneath.
+## If the keyword is not defined, "wails_tools.nsh" will populate them.
+## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
+## from outside of Wails for debugging and development of the installer.
+##
+## For development first make a wails nsis build to populate the "wails_tools.nsh":
+## > wails build --target windows/amd64 --nsis
+## Then you can call makensis on this file with specifying the path to your binary:
+## For a AMD64 only installer:
+## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
+## For a ARM64 only installer:
+## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
+## For a installer with both architectures:
+## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
+####
+## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
+####
+## !define INFO_PROJECTNAME "my-project" # Default "netbird-ui"
+## !define INFO_COMPANYNAME "My Company" # Default "NetBird"
+## !define INFO_PRODUCTNAME "My Product Name" # Default "NetBird"
+## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.0.1"
+## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company"
+###
+## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
+## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
+####
+## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
+####
+## Include the wails tools
+####
+!include "wails_tools.nsh"
+
+# The version information for this two must consist of 4 parts
+VIProductVersion "${INFO_PRODUCTVERSION}.0"
+VIFileVersion "${INFO_PRODUCTVERSION}.0"
+
+VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
+VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
+VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
+VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
+VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
+VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
+
+# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
+ManifestDPIAware true
+
+!include "MUI.nsh"
+
+!define MUI_ICON "..\icon.ico"
+!define MUI_UNICON "..\icon.ico"
+# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
+!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
+!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
+
+!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
+# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
+!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
+!insertmacro MUI_PAGE_INSTFILES # Installing page.
+!insertmacro MUI_PAGE_FINISH # Finished installation page.
+
+!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
+
+!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
+
+## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
+#!uninstfinalize 'signtool --file "%1"'
+#!finalize 'signtool --file "%1"'
+
+Name "${INFO_PRODUCTNAME}"
+OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
+InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
+ShowInstDetails show # This will always show the installation details.
+
+Function .onInit
+ !insertmacro wails.checkArchitecture
+FunctionEnd
+
+Section
+ !insertmacro wails.setShellContext
+
+ !insertmacro wails.webview2runtime
+
+ SetOutPath $INSTDIR
+
+ !insertmacro wails.files
+
+ CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+ CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+
+ !insertmacro wails.associateFiles
+ !insertmacro wails.associateCustomProtocols
+
+ !insertmacro wails.writeUninstaller
+SectionEnd
+
+Section "uninstall"
+ !insertmacro wails.setShellContext
+
+ RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
+
+ RMDir /r $INSTDIR
+
+ Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
+ Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+
+ !insertmacro wails.unassociateFiles
+ !insertmacro wails.unassociateCustomProtocols
+
+ !insertmacro wails.deleteUninstaller
+SectionEnd
diff --git a/client/ui/build/windows/nsis/wails_tools.nsh b/client/ui/build/windows/nsis/wails_tools.nsh
new file mode 100644
index 000000000..b63101b32
--- /dev/null
+++ b/client/ui/build/windows/nsis/wails_tools.nsh
@@ -0,0 +1,236 @@
+# DO NOT EDIT - Generated automatically by `wails build`
+
+!include "x64.nsh"
+!include "WinVer.nsh"
+!include "FileFunc.nsh"
+
+!ifndef INFO_PROJECTNAME
+ !define INFO_PROJECTNAME "netbird-ui"
+!endif
+!ifndef INFO_COMPANYNAME
+ !define INFO_COMPANYNAME "NetBird"
+!endif
+!ifndef INFO_PRODUCTNAME
+ !define INFO_PRODUCTNAME "NetBird"
+!endif
+!ifndef INFO_PRODUCTVERSION
+ !define INFO_PRODUCTVERSION "0.0.1"
+!endif
+!ifndef INFO_COPYRIGHT
+ !define INFO_COPYRIGHT "NetBird GmbH"
+!endif
+!ifndef PRODUCT_EXECUTABLE
+ !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
+!endif
+!ifndef UNINST_KEY_NAME
+ !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
+!endif
+!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
+
+!ifndef REQUEST_EXECUTION_LEVEL
+ !define REQUEST_EXECUTION_LEVEL "admin"
+!endif
+
+RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
+
+!ifdef ARG_WAILS_AMD64_BINARY
+ !define SUPPORTS_AMD64
+!endif
+
+!ifdef ARG_WAILS_ARM64_BINARY
+ !define SUPPORTS_ARM64
+!endif
+
+!ifdef SUPPORTS_AMD64
+ !ifdef SUPPORTS_ARM64
+ !define ARCH "amd64_arm64"
+ !else
+ !define ARCH "amd64"
+ !endif
+!else
+ !ifdef SUPPORTS_ARM64
+ !define ARCH "arm64"
+ !else
+ !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
+ !endif
+!endif
+
+!macro wails.checkArchitecture
+ !ifndef WAILS_WIN10_REQUIRED
+ !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
+ !endif
+
+ !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
+ !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
+ !endif
+
+ ${If} ${AtLeastWin10}
+ !ifdef SUPPORTS_AMD64
+ ${if} ${IsNativeAMD64}
+ Goto ok
+ ${EndIf}
+ !endif
+
+ !ifdef SUPPORTS_ARM64
+ ${if} ${IsNativeARM64}
+ Goto ok
+ ${EndIf}
+ !endif
+
+ IfSilent silentArch notSilentArch
+ silentArch:
+ SetErrorLevel 65
+ Abort
+ notSilentArch:
+ MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
+ Quit
+ ${else}
+ IfSilent silentWin notSilentWin
+ silentWin:
+ SetErrorLevel 64
+ Abort
+ notSilentWin:
+ MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
+ Quit
+ ${EndIf}
+
+ ok:
+!macroend
+
+!macro wails.files
+ !ifdef SUPPORTS_AMD64
+ ${if} ${IsNativeAMD64}
+ File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
+ ${EndIf}
+ !endif
+
+ !ifdef SUPPORTS_ARM64
+ ${if} ${IsNativeARM64}
+ File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
+ ${EndIf}
+ !endif
+!macroend
+
+!macro wails.writeUninstaller
+ WriteUninstaller "$INSTDIR\uninstall.exe"
+
+ SetRegView 64
+ WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
+ WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
+ WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
+ WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
+ WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
+ WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
+
+ ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
+ IntFmt $0 "0x%08X" $0
+ WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
+!macroend
+
+!macro wails.deleteUninstaller
+ Delete "$INSTDIR\uninstall.exe"
+
+ SetRegView 64
+ DeleteRegKey HKLM "${UNINST_KEY}"
+!macroend
+
+!macro wails.setShellContext
+ ${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
+ SetShellVarContext all
+ ${else}
+ SetShellVarContext current
+ ${EndIf}
+!macroend
+
+# Install webview2 by launching the bootstrapper
+# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
+!macro wails.webview2runtime
+ !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
+ !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
+ !endif
+
+ SetRegView 64
+ # If the admin key exists and is not empty then webview2 is already installed
+ ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
+ ${If} $0 != ""
+ Goto ok
+ ${EndIf}
+
+ ${If} ${REQUEST_EXECUTION_LEVEL} == "user"
+ # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
+ ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
+ ${If} $0 != ""
+ Goto ok
+ ${EndIf}
+ ${EndIf}
+
+ SetDetailsPrint both
+ DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
+ SetDetailsPrint listonly
+
+ InitPluginsDir
+ CreateDirectory "$pluginsdir\webview2bootstrapper"
+ SetOutPath "$pluginsdir\webview2bootstrapper"
+ File "MicrosoftEdgeWebview2Setup.exe"
+ ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
+
+ SetDetailsPrint both
+ ok:
+!macroend
+
+# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
+!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
+ ; Backup the previously associated file class
+ ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
+ WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
+
+ WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
+
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
+!macroend
+
+!macro APP_UNASSOCIATE EXT FILECLASS
+ ; Backup the previously associated file class
+ ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
+ WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
+
+ DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
+!macroend
+
+!macro wails.associateFiles
+ ; Create file associations
+
+!macroend
+
+!macro wails.unassociateFiles
+ ; Delete app associations
+
+!macroend
+
+!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
+ DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
+ WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
+!macroend
+
+!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
+ DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
+!macroend
+
+!macro wails.associateCustomProtocols
+ ; Create custom protocols associations
+
+!macroend
+
+!macro wails.unassociateCustomProtocols
+ ; Delete app custom protocol associations
+
+!macroend
\ No newline at end of file
diff --git a/client/ui/build/windows/wails.exe.manifest b/client/ui/build/windows/wails.exe.manifest
new file mode 100644
index 000000000..f8b7b8e14
--- /dev/null
+++ b/client/ui/build/windows/wails.exe.manifest
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ permonitorv2,permonitor
+
+
+
+
+
+
+
+
+
+
diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go
deleted file mode 100644
index d2f38cfd7..000000000
--- a/client/ui/client_ui.go
+++ /dev/null
@@ -1,1986 +0,0 @@
-//go:build !(linux && 386)
-
-package main
-
-import (
- "context"
- _ "embed"
- "errors"
- "flag"
- "fmt"
- "net/url"
- "os"
- "os/exec"
- "os/user"
- "path"
- "runtime"
- "strconv"
- "strings"
- "sync"
- "time"
- "unicode"
-
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/app"
- "fyne.io/fyne/v2/canvas"
- "fyne.io/fyne/v2/container"
- "fyne.io/fyne/v2/dialog"
- "fyne.io/fyne/v2/layout"
- "fyne.io/fyne/v2/theme"
- "fyne.io/fyne/v2/widget"
- "fyne.io/systray"
- "github.com/cenkalti/backoff/v4"
- log "github.com/sirupsen/logrus"
- "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
- "google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
-
- "github.com/netbirdio/netbird/client/iface"
- "github.com/netbirdio/netbird/client/internal"
- "github.com/netbirdio/netbird/client/internal/profilemanager"
- "github.com/netbirdio/netbird/client/mdm"
- "github.com/netbirdio/netbird/client/proto"
- "github.com/netbirdio/netbird/client/ui/desktop"
- "github.com/netbirdio/netbird/client/ui/event"
- "github.com/netbirdio/netbird/client/ui/notifier"
- "github.com/netbirdio/netbird/client/ui/process"
- "github.com/netbirdio/netbird/util"
-
- "github.com/netbirdio/netbird/version"
-)
-
-const (
- defaultFailTimeout = 3 * time.Second
- failFastTimeout = time.Second
-)
-
-const (
- censoredPreSharedKey = "**********"
- maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds
- // mdmFieldSuffix is appended to plain-text Entry widgets in the
- // advanced Settings window when the underlying field is enforced
- // by MDM, so the user sees the lock indicator inline next to the
- // value. Stripped before any read site that feeds the value back
- // into a SetConfig request (saveSettings / parseNumericSettings).
- mdmFieldSuffix = " (MDM)"
-)
-
-// main is the entry point for the UI tray/client binary. Parses CLI
-// flags, initialises logging, builds the Fyne application and tray
-// icons, and constructs the service client (which may open a
-// requested UI window). When a window-mode flag is set the Fyne event
-// loop runs and main returns; otherwise main enforces single-instance
-// behaviour (signalling an existing instance to show its window when
-// present), sets up signal handling + default fonts, and runs the
-// system tray loop.
-func main() {
- flags := parseFlags()
-
- // Initialize file logging if needed.
- var logFile string
- if flags.saveLogsInFile {
- file, err := initLogFile()
- if err != nil {
- log.Errorf("error while initializing log: %v", err)
- return
- }
- logFile = file
- } else {
- _ = util.InitLog("trace", util.LogConsole)
- }
-
- // Create the Fyne application.
- a := app.NewWithID("NetBird")
- a.SetIcon(fyne.NewStaticResource("netbird", iconDisconnected))
-
- // Show error message window if needed.
- if flags.errorMsg != "" {
- showErrorMessage(flags.errorMsg)
- return
- }
-
- // Create the service client (this also builds the settings or networks UI if requested).
- client := newServiceClient(&newServiceClientArgs{
- addr: flags.daemonAddr,
- logFile: logFile,
- app: a,
- showSettings: flags.showSettings,
- showNetworks: flags.showNetworks,
- showLoginURL: flags.showLoginURL,
- showDebug: flags.showDebug,
- showProfiles: flags.showProfiles,
- showQuickActions: flags.showQuickActions,
- showUpdate: flags.showUpdate,
- showUpdateVersion: flags.showUpdateVersion,
- })
-
- // Watch for theme/settings changes to update the icon.
- go watchSettingsChanges(a, client)
-
- // Run in window mode if any UI flag was set.
- if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions || flags.showUpdate {
- a.Run()
- return
- }
-
- // Check for another running process.
- pid, running, err := process.IsAnotherProcessRunning()
- if err != nil {
- log.Errorf("error while checking process: %v", err)
- return
- }
- if running {
- log.Infof("another process is running with pid %d, sending signal to show window", pid)
- if err := sendShowWindowSignal(pid); err != nil {
- log.Errorf("send signal to running instance: %v", err)
- }
- return
- }
-
- client.setupSignalHandler(client.ctx)
-
- client.setDefaultFonts()
- systray.Run(client.onTrayReady, client.onTrayExit)
-}
-
-type cliFlags struct {
- daemonAddr string
- showSettings bool
- showNetworks bool
- showProfiles bool
- showDebug bool
- showLoginURL bool
- showQuickActions bool
- errorMsg string
- saveLogsInFile bool
- showUpdate bool
- showUpdateVersion string
-}
-
-// parseFlags reads and returns all needed command-line flags.
-func parseFlags() *cliFlags {
- var flags cliFlags
-
- defaultDaemonAddr := "unix:///var/run/netbird.sock"
- if runtime.GOOS == "windows" {
- defaultDaemonAddr = "tcp://127.0.0.1:41731"
- }
- flag.StringVar(&flags.daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
- flag.BoolVar(&flags.showSettings, "settings", false, "run settings window")
- flag.BoolVar(&flags.showNetworks, "networks", false, "run networks window")
- flag.BoolVar(&flags.showProfiles, "profiles", false, "run profiles window")
- flag.BoolVar(&flags.showDebug, "debug", false, "run debug window")
- flag.BoolVar(&flags.showQuickActions, "quick-actions", false, "run quick actions window")
- flag.StringVar(&flags.errorMsg, "error-msg", "", "displays an error message window")
- flag.BoolVar(&flags.saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir()))
- flag.BoolVar(&flags.showLoginURL, "login-url", false, "show login URL in a popup window")
- flag.BoolVar(&flags.showUpdate, "update", false, "show update progress window")
- flag.StringVar(&flags.showUpdateVersion, "update-version", "", "version to update to")
- flag.Parse()
- return &flags
-}
-
-// initLogFile initializes logging into a file.
-func initLogFile() (string, error) {
- logFile := path.Join(os.TempDir(), fmt.Sprintf("netbird-ui-%d.log", os.Getpid()))
- return logFile, util.InitLog("trace", logFile)
-}
-
-// watchSettingsChanges listens for Fyne theme/settings changes and updates the client icon.
-func watchSettingsChanges(a fyne.App, client *serviceClient) {
- a.Settings().AddListener(func(settings fyne.Settings) {
- client.updateIcon()
- })
-}
-
-// showErrorMessage displays an error message in a simple window.
-func showErrorMessage(msg string) {
- a := app.New()
- w := a.NewWindow("NetBird Error")
- label := widget.NewLabel(msg)
- label.Wrapping = fyne.TextWrapWord
- w.SetContent(label)
- w.Resize(fyne.NewSize(400, 100))
- w.Show()
- a.Run()
-}
-
-//go:embed assets/netbird-systemtray-connected-macos.png
-var iconConnectedMacOS []byte
-
-//go:embed assets/netbird-systemtray-disconnected-macos.png
-var iconDisconnectedMacOS []byte
-
-//go:embed assets/netbird-systemtray-update-disconnected-macos.png
-var iconUpdateDisconnectedMacOS []byte
-
-//go:embed assets/netbird-systemtray-update-connected-macos.png
-var iconUpdateConnectedMacOS []byte
-
-//go:embed assets/netbird-systemtray-connecting-macos.png
-var iconConnectingMacOS []byte
-
-//go:embed assets/netbird-systemtray-error-macos.png
-var iconErrorMacOS []byte
-
-//go:embed assets/connected.png
-var iconConnectedDot []byte
-
-//go:embed assets/disconnected.png
-var iconDisconnectedDot []byte
-
-type serviceClient struct {
- ctx context.Context
- cancel context.CancelFunc
- addr string
- conn proto.DaemonServiceClient
- connLock sync.Mutex
-
- eventHandler *eventHandler
-
- profileManager *profilemanager.ProfileManager
-
- icAbout []byte
- icConnected []byte
- icConnectedDot []byte
- icDisconnected []byte
- icDisconnectedDot []byte
- icUpdateConnected []byte
- icUpdateDisconnected []byte
- icConnecting []byte
- icError []byte
-
- // systray menu items
- mStatus *systray.MenuItem
- mUp *systray.MenuItem
- mDown *systray.MenuItem
- mSettings *systray.MenuItem
- mProfile *profileMenu
- mAbout *systray.MenuItem
- mGitHub *systray.MenuItem
- mVersionUI *systray.MenuItem
- mVersionDaemon *systray.MenuItem
- mUpdate *systray.MenuItem
- mQuit *systray.MenuItem
- mNetworks *systray.MenuItem
- mAllowSSH *systray.MenuItem
- mAutoConnect *systray.MenuItem
- mEnableRosenpass *systray.MenuItem
- mLazyConnEnabled *systray.MenuItem
- mBlockInbound *systray.MenuItem
- mNotifications *systray.MenuItem
- mAdvancedSettings *systray.MenuItem
- mCreateDebugBundle *systray.MenuItem
- mExitNode *systray.MenuItem
-
- // application with main windows.
- app fyne.App
- notifier notifier.Notifier
- wSettings fyne.Window
- showAdvancedSettings bool
- sendNotification bool
-
- // input elements for settings form
- iMngURL *widget.Entry
- iLogFile *widget.Entry
- iPreSharedKey *widget.Entry
- iInterfaceName *widget.Entry
- iInterfacePort *widget.Entry
- iMTU *widget.Entry
-
- // switch elements for settings form
- sRosenpassPermissive *widget.Check
- sNetworkMonitor *widget.Check
- sDisableDNS *widget.Check
- sDisableClientRoutes *widget.Check
- sDisableServerRoutes *widget.Check
- sDisableIPv6 *widget.Check
- sBlockLANAccess *widget.Check
- sEnableSSHRoot *widget.Check
- sEnableSSHSFTP *widget.Check
- sEnableSSHLocalPortForward *widget.Check
- sEnableSSHRemotePortForward *widget.Check
- sDisableSSHAuth *widget.Check
- iSSHJWTCacheTTL *widget.Entry
-
- // observable settings over corresponding iMngURL and iPreSharedKey values.
- managementURL string
- preSharedKey string
-
- RosenpassPermissive bool
- interfaceName string
- interfacePort int
- mtu uint16
- networkMonitor bool
- disableDNS bool
- disableClientRoutes bool
- disableServerRoutes bool
- disableIPv6 bool
- blockLANAccess bool
- enableSSHRoot bool
- enableSSHSFTP bool
- enableSSHLocalPortForward bool
- enableSSHRemotePortForward bool
- disableSSHAuth bool
- sshJWTCacheTTL int
-
- connected bool
- daemonVersion string
- updateIndicationLock sync.Mutex
- isUpdateIconActive bool
- isEnforcedUpdate bool
- lastNotifiedVersion string
- profilesEnabled bool
- networksEnabled bool
- // networksMenuEnabled caches the last applied enabled-state of the
- // mNetworks + mExitNode submenu items. Combines features.DisableNetworks
- // AND s.connected — both must be true for the menus to be active.
- // Zero value (false) matches the Disable() call at AddMenuItem time.
- networksMenuEnabled bool
- showNetworks bool
- wNetworks fyne.Window
- wProfiles fyne.Window
- wQuickActions fyne.Window
-
- eventManager *event.Manager
-
- exitNodeMu sync.Mutex
- mExitNodeItems []menuHandler
- exitNodeRetryCancel context.CancelFunc
- mExitNodeSeparator *systray.MenuItem
- mExitNodeDeselectAll *systray.MenuItem
- logFile string
- wLoginURL fyne.Window
- wUpdateProgress fyne.Window
- updateContextCancel context.CancelFunc
-
- connectCancel context.CancelFunc
-
- // mdmManagedFields caches the names of MDM-enforced policy keys
- // surfaced by the daemon in GetConfigResponse. Each refresh of
- // daemon config (loadSettings, getSrvConfig, config_changed event)
- // updates this set and re-applies the lock/badge to the affected
- // menu items and settings-form widgets.
- mdmManagedFields map[string]bool
-}
-
-type menuHandler struct {
- *systray.MenuItem
- cancel context.CancelFunc
-}
-
-type newServiceClientArgs struct {
- addr string
- logFile string
- app fyne.App
- showSettings bool
- showNetworks bool
- showDebug bool
- showLoginURL bool
- showProfiles bool
- showQuickActions bool
- showUpdate bool
- showUpdateVersion string
-}
-
-// newServiceClient instance constructor
-//
-// This constructor also builds the UI elements for the settings window.
-func newServiceClient(args *newServiceClientArgs) *serviceClient {
- ctx, cancel := context.WithCancel(context.Background())
- s := &serviceClient{
- ctx: ctx,
- cancel: cancel,
- addr: args.addr,
- app: args.app,
- notifier: notifier.New(args.app),
- logFile: args.logFile,
- sendNotification: false,
-
- showAdvancedSettings: args.showSettings,
- showNetworks: args.showNetworks,
- networksEnabled: true,
- }
-
- s.eventHandler = newEventHandler(s)
- s.profileManager = profilemanager.NewProfileManager()
- s.setNewIcons()
-
- switch {
- case args.showSettings:
- s.showSettingsUI()
- case args.showNetworks:
- s.showNetworksUI()
- case args.showLoginURL:
- s.showLoginURL()
- case args.showDebug:
- s.showDebugUI()
- case args.showProfiles:
- s.showProfilesUI()
- case args.showQuickActions:
- s.showQuickActionsUI()
- case args.showUpdate:
- s.showUpdateProgress(ctx, args.showUpdateVersion)
- }
-
- return s
-}
-
-func (s *serviceClient) setNewIcons() {
- s.icAbout = iconAbout
- s.icConnectedDot = iconConnectedDot
- s.icDisconnectedDot = iconDisconnectedDot
- if s.app.Settings().ThemeVariant() == theme.VariantDark {
- s.icConnected = iconConnectedDark
- s.icDisconnected = iconDisconnected
- s.icUpdateConnected = iconUpdateConnectedDark
- s.icUpdateDisconnected = iconUpdateDisconnectedDark
- s.icConnecting = iconConnectingDark
- s.icError = iconErrorDark
- } else {
- s.icConnected = iconConnected
- s.icDisconnected = iconDisconnected
- s.icUpdateConnected = iconUpdateConnected
- s.icUpdateDisconnected = iconUpdateDisconnected
- s.icConnecting = iconConnecting
- s.icError = iconError
- }
-}
-
-func (s *serviceClient) updateIcon() {
- s.setNewIcons()
- s.updateIndicationLock.Lock()
- if s.connected {
- if s.isUpdateIconActive {
- systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected)
- } else {
- systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected)
- }
- } else {
- if s.isUpdateIconActive {
- systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected)
- } else {
- systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected)
- }
- }
- s.updateIndicationLock.Unlock()
-}
-
-func (s *serviceClient) showSettingsUI() {
- // DisableUpdateSettings no longer gates the window from opening:
- // the daemon blocks every actual mutation at SetConfig / Login,
- // so the window is safe to show as a read-only view. The previous
- // early-return also blocked Advanced Settings whenever update
- // editing was off, which conflated two distinct kill switches
- // (see comment in checkAndUpdateFeatures).
-
- // add settings window UI elements.
- s.wSettings = s.app.NewWindow("NetBird Settings")
- s.wSettings.SetOnClosed(s.cancel)
-
- s.iMngURL = widget.NewEntry()
-
- s.iLogFile = widget.NewEntry()
- s.iLogFile.Disable()
- s.iPreSharedKey = widget.NewPasswordEntry()
- s.iInterfaceName = widget.NewEntry()
- s.iInterfacePort = widget.NewEntry()
- s.iMTU = widget.NewEntry()
-
- s.sRosenpassPermissive = widget.NewCheck("Enable Rosenpass permissive mode", nil)
-
- s.sNetworkMonitor = widget.NewCheck("Restarts NetBird when the network changes", nil)
- s.sDisableDNS = widget.NewCheck("Keeps system DNS settings unchanged", nil)
- s.sDisableClientRoutes = widget.NewCheck("This peer won't route traffic to other peers", nil)
- s.sDisableServerRoutes = widget.NewCheck("This peer won't act as router for others", nil)
- s.sDisableIPv6 = widget.NewCheck("Disable IPv6 overlay addressing", nil)
- s.sBlockLANAccess = widget.NewCheck("Blocks local network access when used as exit node", nil)
- s.sEnableSSHRoot = widget.NewCheck("Enable SSH Root Login", nil)
- s.sEnableSSHSFTP = widget.NewCheck("Enable SSH SFTP", nil)
- s.sEnableSSHLocalPortForward = widget.NewCheck("Enable SSH Local Port Forwarding", nil)
- s.sEnableSSHRemotePortForward = widget.NewCheck("Enable SSH Remote Port Forwarding", nil)
- s.sDisableSSHAuth = widget.NewCheck("Disable SSH Authentication", nil)
- s.iSSHJWTCacheTTL = widget.NewEntry()
-
- s.wSettings.SetContent(s.getSettingsForm())
- s.wSettings.Resize(fyne.NewSize(600, 400))
- s.wSettings.SetFixedSize(true)
-
- s.getSrvConfig()
- s.wSettings.Show()
-}
-
-func (s *serviceClient) getConnectionForm() *widget.Form {
- var activeProfName string
- activeProf, err := s.profileManager.GetActiveProfile()
- if err != nil {
- log.Errorf("get active profile: %v", err)
- } else {
- activeProfName = activeProf.Name
- }
- return &widget.Form{
- Items: []*widget.FormItem{
- {Text: "Profile", Widget: widget.NewLabel(activeProfName)},
- {Text: "Management URL", Widget: s.iMngURL},
- {Text: "Pre-shared Key", Widget: s.iPreSharedKey},
- {Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive},
- {Text: "Interface Name", Widget: s.iInterfaceName},
- {Text: "Interface Port", Widget: s.iInterfacePort, HintText: "If set to 0, a random free port will be used"},
- {Text: "MTU", Widget: s.iMTU},
- {Text: "Log File", Widget: s.iLogFile},
- },
- }
-}
-
-func (s *serviceClient) saveSettings() {
- // Check if update settings are disabled by daemon
- features, err := s.getFeatures()
- if err != nil {
- log.Errorf("failed to get features from daemon: %v", err)
- // Continue with default behavior if features can't be retrieved
- } else if features != nil && features.DisableUpdateSettings {
- log.Warn("Configuration updates are disabled by daemon")
- dialog.ShowError(fmt.Errorf("configuration updates are disabled by daemon"), s.wSettings)
- return
- }
-
- if err := s.validateSettings(); err != nil {
- dialog.ShowError(err, s.wSettings)
- return
- }
-
- port, mtu, err := s.parseNumericSettings()
- if err != nil {
- dialog.ShowError(err, s.wSettings)
- return
- }
-
- iMngURL := strings.TrimSpace(strings.TrimSuffix(s.iMngURL.Text, mdmFieldSuffix))
-
- if s.hasSettingsChanged(iMngURL, port, mtu) {
- if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil {
- dialog.ShowError(err, s.wSettings)
- return
- }
- }
-
- s.wSettings.Close()
-}
-
-func (s *serviceClient) validateSettings() error {
- if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
- if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil {
- return fmt.Errorf("invalid pre-shared key value")
- }
- }
- return nil
-}
-
-func (s *serviceClient) parseNumericSettings() (int64, int64, error) {
- port, err := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(s.iInterfacePort.Text, mdmFieldSuffix)), 10, 64)
- if err != nil {
- return 0, 0, errors.New("invalid interface port")
- }
- if port < 0 || port > 65535 {
- return 0, 0, errors.New("invalid interface port: out of range 0-65535")
- }
-
- var mtu int64
- mtuText := strings.TrimSpace(s.iMTU.Text)
- if mtuText != "" {
- mtu, err = strconv.ParseInt(mtuText, 10, 64)
- if err != nil {
- return 0, 0, errors.New("invalid MTU value")
- }
- if mtu < iface.MinMTU || mtu > iface.MaxMTU {
- return 0, 0, fmt.Errorf("MTU must be between %d and %d bytes", iface.MinMTU, iface.MaxMTU)
- }
- }
-
- return port, mtu, nil
-}
-
-func (s *serviceClient) hasSettingsChanged(iMngURL string, port, mtu int64) bool {
- return s.managementURL != iMngURL ||
- s.preSharedKey != s.iPreSharedKey.Text ||
- s.RosenpassPermissive != s.sRosenpassPermissive.Checked ||
- s.interfaceName != s.iInterfaceName.Text ||
- s.interfacePort != int(port) ||
- s.mtu != uint16(mtu) ||
- s.networkMonitor != s.sNetworkMonitor.Checked ||
- s.disableDNS != s.sDisableDNS.Checked ||
- s.disableClientRoutes != s.sDisableClientRoutes.Checked ||
- s.disableServerRoutes != s.sDisableServerRoutes.Checked ||
- s.disableIPv6 != s.sDisableIPv6.Checked ||
- s.blockLANAccess != s.sBlockLANAccess.Checked ||
- s.hasSSHChanges()
-}
-
-func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) error {
- s.managementURL = iMngURL
- s.preSharedKey = s.iPreSharedKey.Text
- s.mtu = uint16(mtu)
-
- req, err := s.buildSetConfigRequest(iMngURL, port, mtu)
- if err != nil {
- return fmt.Errorf("build config request: %w", err)
- }
-
- if err := s.sendConfigUpdate(req); err != nil {
- return fmt.Errorf("set configuration: %w", err)
- }
-
- return nil
-}
-
-func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (*proto.SetConfigRequest, error) {
- currUser, err := user.Current()
- if err != nil {
- return nil, fmt.Errorf("get current user: %w", err)
- }
-
- activeProf, err := s.profileManager.GetActiveProfile()
- if err != nil {
- return nil, fmt.Errorf("get active profile: %w", err)
- }
-
- req := &proto.SetConfigRequest{
- ProfileName: activeProf.ID.String(),
- Username: currUser.Username,
- }
-
- if iMngURL != "" {
- req.ManagementUrl = iMngURL
- }
-
- req.RosenpassPermissive = &s.sRosenpassPermissive.Checked
- req.InterfaceName = &s.iInterfaceName.Text
- req.WireguardPort = &port
- if mtu > 0 {
- req.Mtu = &mtu
- }
-
- req.NetworkMonitor = &s.sNetworkMonitor.Checked
- req.DisableDns = &s.sDisableDNS.Checked
- req.DisableClientRoutes = &s.sDisableClientRoutes.Checked
- req.DisableServerRoutes = &s.sDisableServerRoutes.Checked
- req.DisableIpv6 = &s.sDisableIPv6.Checked
- req.BlockLanAccess = &s.sBlockLANAccess.Checked
-
- req.EnableSSHRoot = &s.sEnableSSHRoot.Checked
- req.EnableSSHSFTP = &s.sEnableSSHSFTP.Checked
- req.EnableSSHLocalPortForwarding = &s.sEnableSSHLocalPortForward.Checked
- req.EnableSSHRemotePortForwarding = &s.sEnableSSHRemotePortForward.Checked
- req.DisableSSHAuth = &s.sDisableSSHAuth.Checked
-
- sshJWTCacheTTLText := strings.TrimSpace(s.iSSHJWTCacheTTL.Text)
- if sshJWTCacheTTLText != "" {
- sshJWTCacheTTL, err := strconv.ParseInt(sshJWTCacheTTLText, 10, 32)
- if err != nil {
- return nil, errors.New("invalid SSH JWT Cache TTL value")
- }
- if sshJWTCacheTTL < 0 || sshJWTCacheTTL > maxSSHJWTCacheTTL {
- return nil, fmt.Errorf("SSH JWT Cache TTL must be between 0 and %d seconds", maxSSHJWTCacheTTL)
- }
- sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
- req.SshJWTCacheTTL = &sshJWTCacheTTL32
- }
-
- // Only attach the PSK when the user actually typed something:
- // - "" means the field was left untouched (we deliberately render
- // an empty Text + placeholder hint to avoid leaking the daemon's
- // "**********" redaction through the password reveal toggle);
- // sending an empty pointer would tell the daemon to clear / overwrite
- // the on-disk or MDM-enforced PSK, which then trips the MDM
- // conflict gate when PSK is policy-managed.
- // - "**********" is the redacted echo (legacy non-MDM path); also a no-op.
- if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
- req.OptionalPreSharedKey = &s.iPreSharedKey.Text
- }
-
- return req, nil
-}
-
-func (s *serviceClient) sendConfigUpdate(req *proto.SetConfigRequest) error {
- conn, err := s.getSrvClient(failFastTimeout)
- if err != nil {
- return fmt.Errorf("get client: %w", err)
- }
-
- _, err = conn.SetConfig(s.ctx, req)
- if err != nil {
- return fmt.Errorf("set config: %w", err)
- }
-
- // Reconnect if connected to apply the new settings.
- // Use a background context so the reconnect outlives the settings window.
- go func() {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- status, err := conn.Status(ctx, &proto.StatusRequest{})
- if err != nil {
- log.Errorf("failed to get service status: %v", err)
- return
- }
- if status.Status == string(internal.StatusConnected) {
- if _, err = conn.Down(ctx, &proto.DownRequest{}); err != nil {
- log.Errorf("failed to stop service: %v", err)
- }
- // TODO: wait for the service to be idle before calling Up, or use a fresh connection
- if _, err = conn.Up(ctx, &proto.UpRequest{}); err != nil {
- log.Errorf("failed to start service: %v", err)
- }
- }
- }()
-
- return nil
-}
-
-func (s *serviceClient) getSettingsForm() fyne.CanvasObject {
- connectionForm := s.getConnectionForm()
- networkForm := s.getNetworkForm()
- sshForm := s.getSSHForm()
- tabs := container.NewAppTabs(
- container.NewTabItem("Connection", connectionForm),
- container.NewTabItem("Network", networkForm),
- container.NewTabItem("SSH", sshForm),
- )
- saveButton := widget.NewButtonWithIcon("Save", theme.ConfirmIcon(), s.saveSettings)
- saveButton.Importance = widget.HighImportance
- cancelButton := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
- s.wSettings.Close()
- })
- buttonContainer := container.NewHBox(
- layout.NewSpacer(),
- cancelButton,
- saveButton,
- )
- return container.NewBorder(nil, buttonContainer, nil, nil, tabs)
-}
-
-func (s *serviceClient) getNetworkForm() *widget.Form {
- return &widget.Form{
- Items: []*widget.FormItem{
- {Text: "Network Monitor", Widget: s.sNetworkMonitor},
- {Text: "Disable DNS", Widget: s.sDisableDNS},
- {Text: "Disable Client Routes", Widget: s.sDisableClientRoutes},
- {Text: "Disable Server Routes", Widget: s.sDisableServerRoutes},
- {Text: "Disable IPv6", Widget: s.sDisableIPv6},
- {Text: "Disable LAN Access", Widget: s.sBlockLANAccess},
- },
- }
-}
-
-func (s *serviceClient) getSSHForm() *widget.Form {
- return &widget.Form{
- Items: []*widget.FormItem{
- {Text: "Enable SSH Root Login", Widget: s.sEnableSSHRoot},
- {Text: "Enable SSH SFTP", Widget: s.sEnableSSHSFTP},
- {Text: "Enable SSH Local Port Forwarding", Widget: s.sEnableSSHLocalPortForward},
- {Text: "Enable SSH Remote Port Forwarding", Widget: s.sEnableSSHRemotePortForward},
- {Text: "Disable SSH Authentication", Widget: s.sDisableSSHAuth},
- {Text: "JWT Cache TTL (seconds, 0=disabled)", Widget: s.iSSHJWTCacheTTL},
- },
- }
-}
-
-func (s *serviceClient) hasSSHChanges() bool {
- currentSSHJWTCacheTTL := s.sshJWTCacheTTL
- if text := strings.TrimSpace(s.iSSHJWTCacheTTL.Text); text != "" {
- val, err := strconv.Atoi(text)
- if err != nil {
- return true
- }
- currentSSHJWTCacheTTL = val
- }
-
- return s.enableSSHRoot != s.sEnableSSHRoot.Checked ||
- s.enableSSHSFTP != s.sEnableSSHSFTP.Checked ||
- s.enableSSHLocalPortForward != s.sEnableSSHLocalPortForward.Checked ||
- s.enableSSHRemotePortForward != s.sEnableSSHRemotePortForward.Checked ||
- s.disableSSHAuth != s.sDisableSSHAuth.Checked ||
- s.sshJWTCacheTTL != currentSSHJWTCacheTTL
-}
-
-func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginResponse, error) {
- conn, err := s.getSrvClient(defaultFailTimeout)
- if err != nil {
- return nil, fmt.Errorf("get daemon client: %w", err)
- }
-
- activeProf, err := s.profileManager.GetActiveProfile()
- if err != nil {
- return nil, fmt.Errorf("get active profile: %w", err)
- }
-
- currUser, err := user.Current()
- if err != nil {
- return nil, fmt.Errorf("get current user: %w", err)
- }
-
- handle := activeProf.ID.String()
-
- loginReq := &proto.LoginRequest{
- IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
- ProfileName: &handle,
- Username: &currUser.Username,
- }
-
- profileState, err := s.profileManager.GetProfileState(activeProf.ID)
- if err != nil {
- log.Debugf("failed to get profile state for login hint: %v", err)
- } else if profileState.Email != "" {
- loginReq.Hint = &profileState.Email
- }
-
- loginResp, err := conn.Login(ctx, loginReq)
- if err != nil {
- return nil, fmt.Errorf("login to management: %w", err)
- }
-
- if loginResp.NeedsSSOLogin && openURL {
- if err = s.handleSSOLogin(ctx, loginResp, conn); err != nil {
- return nil, fmt.Errorf("SSO login: %w", err)
- }
- }
-
- return loginResp, nil
-}
-
-func (s *serviceClient) handleSSOLogin(ctx context.Context, loginResp *proto.LoginResponse, conn proto.DaemonServiceClient) error {
- if err := openURL(loginResp.VerificationURIComplete); err != nil {
- return fmt.Errorf("open browser: %w", err)
- }
-
- resp, err := conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode})
- if err != nil {
- return fmt.Errorf("wait for SSO login: %w", err)
- }
-
- if resp.Email != "" {
- if err := s.profileManager.SetActiveProfileState(&profilemanager.ProfileState{
- Email: resp.Email,
- }); err != nil {
- log.Debugf("failed to set profile state: %v", err)
- } else {
- s.mProfile.refresh()
- }
- }
-
- return nil
-}
-
-func (s *serviceClient) menuUpClick(ctx context.Context) error {
- systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting)
- conn, err := s.getSrvClient(defaultFailTimeout)
- if err != nil {
- systray.SetTemplateIcon(iconErrorMacOS, s.icError)
- return fmt.Errorf("get daemon client: %w", err)
- }
-
- _, err = s.login(ctx, true)
- if err != nil {
- return fmt.Errorf("login: %w", err)
- }
-
- status, err := conn.Status(ctx, &proto.StatusRequest{})
- if err != nil {
- return fmt.Errorf("get status: %w", err)
- }
-
- if status.Status == string(internal.StatusConnected) {
- return nil
- }
-
- if _, err := s.conn.Up(s.ctx, &proto.UpRequest{}); err != nil {
- return fmt.Errorf("start connection: %w", err)
- }
-
- return nil
-}
-
-func (s *serviceClient) menuDownClick() error {
- systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting)
- conn, err := s.getSrvClient(defaultFailTimeout)
- if err != nil {
- return fmt.Errorf("get daemon client: %w", err)
- }
-
- status, err := conn.Status(s.ctx, &proto.StatusRequest{})
- if err != nil {
- return fmt.Errorf("get status: %w", err)
- }
-
- if status.Status != string(internal.StatusConnected) && status.Status != string(internal.StatusConnecting) {
- return nil
- }
-
- if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil {
- return fmt.Errorf("stop connection: %w", err)
- }
-
- return nil
-}
-
-func (s *serviceClient) updateStatus() error {
- conn, err := s.getSrvClient(defaultFailTimeout)
- if err != nil {
- return err
- }
- err = backoff.Retry(func() error {
- status, err := conn.Status(s.ctx, &proto.StatusRequest{})
- if err != nil {
- log.Errorf("get service status: %v", err)
- if s.connected {
- s.notifier.Send("Error", "Connection to service lost")
- }
- s.setDisconnectedStatus()
- return err
- }
-
- s.updateIndicationLock.Lock()
- defer s.updateIndicationLock.Unlock()
-
- // notify the user when the session has expired
- if status.Status == string(internal.StatusSessionExpired) {
- s.onSessionExpire()
- }
-
- var systrayIconState bool
-
- switch {
- case status.Status == string(internal.StatusConnected) && !s.connected:
- s.connected = true
- s.sendNotification = true
- if s.isUpdateIconActive {
- systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected)
- } else {
- systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected)
- }
- systray.SetTooltip("NetBird (Connected)")
- s.mStatus.SetTitle("Connected")
- s.mStatus.SetIcon(s.icConnectedDot)
- s.mUp.Disable()
- s.mDown.Enable()
- if s.networksEnabled {
- s.mNetworks.Enable()
- s.mExitNode.Enable()
- }
- s.startExitNodeRefresh()
- systrayIconState = true
- case status.Status == string(internal.StatusConnecting):
- s.setConnectingStatus()
- case status.Status != string(internal.StatusConnected) && s.mUp.Disabled():
- s.setDisconnectedStatus()
- systrayIconState = false
- }
-
- // if the daemon version changed (e.g. after a successful update), reset the update indication
- if s.daemonVersion != status.DaemonVersion {
- if s.daemonVersion != "" {
- s.mUpdate.Hide()
- s.isUpdateIconActive = false
- }
- s.daemonVersion = status.DaemonVersion
- if !s.isUpdateIconActive {
- if systrayIconState {
- systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected)
- } else {
- systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected)
- }
- }
-
- daemonVersionTitle := normalizedVersion(s.daemonVersion)
- s.mVersionDaemon.SetTitle(fmt.Sprintf("Daemon: %s", daemonVersionTitle))
- s.mVersionDaemon.SetTooltip(fmt.Sprintf("Daemon version: %s", daemonVersionTitle))
- s.mVersionDaemon.Show()
- }
-
- return nil
- }, &backoff.ExponentialBackOff{
- InitialInterval: time.Second,
- RandomizationFactor: backoff.DefaultRandomizationFactor,
- Multiplier: backoff.DefaultMultiplier,
- MaxInterval: 300 * time.Millisecond,
- MaxElapsedTime: 2 * time.Second,
- Stop: backoff.Stop,
- Clock: backoff.SystemClock,
- })
- if err != nil {
- return err
- }
-
- return nil
-}
-
-func (s *serviceClient) setDisconnectedStatus() {
- s.connected = false
- if s.isUpdateIconActive {
- systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected)
- } else {
- systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected)
- }
- systray.SetTooltip("NetBird (Disconnected)")
- s.mStatus.SetTitle("Disconnected")
- s.mStatus.SetIcon(s.icDisconnectedDot)
- s.mDown.Disable()
- s.mUp.Enable()
- s.mNetworks.Disable()
- s.mExitNode.Disable()
- s.cancelExitNodeRetry()
- go s.updateExitNodes()
-}
-
-func (s *serviceClient) setConnectingStatus() {
- s.connected = false
- systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting)
- systray.SetTooltip("NetBird (Connecting)")
- s.mStatus.SetTitle("Connecting")
- s.mUp.Disable()
- s.mDown.Enable()
- s.mNetworks.Disable()
- s.mExitNode.Disable()
-}
-
-func (s *serviceClient) onTrayReady() {
- systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected)
- systray.SetTooltip("NetBird")
-
- // setup systray menu items
- s.mStatus = systray.AddMenuItem("Disconnected", "Disconnected")
- s.mStatus.SetIcon(s.icDisconnectedDot)
- s.mStatus.Disable()
-
- profileMenuItem := systray.AddMenuItem("", "")
- emailMenuItem := systray.AddMenuItem("", "")
-
- newProfileMenuArgs := &newProfileMenuArgs{
- ctx: s.ctx,
- serviceClient: s,
- profileManager: s.profileManager,
- eventHandler: s.eventHandler,
- profileMenuItem: profileMenuItem,
- emailMenuItem: emailMenuItem,
- downClickCallback: s.menuDownClick,
- upClickCallback: s.menuUpClick,
- getSrvClientCallback: s.getSrvClient,
- loadSettingsCallback: s.loadSettings,
- app: s.app,
- }
-
- s.mProfile = newProfileMenu(*newProfileMenuArgs)
- // Seed the transition cache to match the actual default menu
- // state (visible / enabled). Without this, the first
- // checkAndUpdateFeatures tick that observes DisableProfiles=true
- // is a no-op (cache zero-value == desired-false) and the menu
- // never gets hidden — symptom: MDM enforces the kill switch but
- // the profile menu stays clickable.
- s.profilesEnabled = true
-
- systray.AddSeparator()
- s.mUp = systray.AddMenuItem("Connect", "Connect")
- s.mDown = systray.AddMenuItem("Disconnect", "Disconnect")
- s.mDown.Disable()
- systray.AddSeparator()
-
- s.mSettings = systray.AddMenuItem("Settings", disabledMenuDescr)
- s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", allowSSHMenuDescr, false)
- s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", autoConnectMenuDescr, false)
- s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", quantumResistanceMenuDescr, false)
- s.mLazyConnEnabled = s.mSettings.AddSubMenuItemCheckbox("Enable Lazy Connections", lazyConnMenuDescr, false)
- s.mBlockInbound = s.mSettings.AddSubMenuItemCheckbox("Block Inbound Connections", blockInboundMenuDescr, false)
- s.mNotifications = s.mSettings.AddSubMenuItemCheckbox("Notifications", notificationsMenuDescr, false)
- s.mSettings.AddSeparator()
- s.mAdvancedSettings = s.mSettings.AddSubMenuItem("Advanced Settings", advancedSettingsMenuDescr)
- s.mCreateDebugBundle = s.mSettings.AddSubMenuItem("Create Debug Bundle", debugBundleMenuDescr)
- s.loadSettings()
-
- // Disable profile menu if profiles are disabled by daemon.
- // DisableUpdateSettings is enforced at the daemon's SetConfig /
- // Login gates, not by hiding the UI — so the Settings menu (and
- // its Advanced Settings submenu, which has its own kill switch)
- // stays visible and the user can still inspect current values.
- features, err := s.getFeatures()
- if err != nil {
- log.Errorf("failed to get features from daemon: %v", err)
- // Continue with default behavior if features can't be retrieved
- } else if features != nil && features.DisableProfiles {
- s.mProfile.setEnabled(false)
- s.profilesEnabled = false
- }
-
- s.exitNodeMu.Lock()
- s.mExitNode = systray.AddMenuItem("Exit Node", disabledMenuDescr)
- s.mExitNode.Disable()
- s.exitNodeMu.Unlock()
-
- s.mNetworks = systray.AddMenuItem("Networks", networksMenuDescr)
- s.mNetworks.Disable()
- systray.AddSeparator()
-
- s.mAbout = systray.AddMenuItem("About", "About")
- s.mAbout.SetIcon(s.icAbout)
-
- s.mGitHub = s.mAbout.AddSubMenuItem("GitHub", "GitHub")
-
- versionString := normalizedVersion(version.NetbirdVersion())
- s.mVersionUI = s.mAbout.AddSubMenuItem(fmt.Sprintf("GUI: %s", versionString), fmt.Sprintf("GUI Version: %s", versionString))
- s.mVersionUI.Disable()
-
- s.mVersionDaemon = s.mAbout.AddSubMenuItem("", "")
- s.mVersionDaemon.Disable()
- s.mVersionDaemon.Hide()
-
- s.mUpdate = s.mAbout.AddSubMenuItem("Download latest version", latestVersionMenuDescr)
- s.mUpdate.Hide()
-
- systray.AddSeparator()
- s.mQuit = systray.AddMenuItem("Quit", quitMenuDescr)
-
- // update exit node menu in case service is already connected
- go s.updateExitNodes()
-
- // Features (DisableProfiles, DisableUpdateSettings, DisableNetworks,
- // ...) only change in two ways: at service install time (CLI flag,
- // static) and at MDM ticker diff time. The daemon already publishes
- // a SystemEvent{type=config_changed} on every MDM-driven engine
- // restart, so the UI no longer needs to poll GetFeatures every 2 s.
- // A single fetch at startup covers the static CLI-flag case; the
- // event handler below covers MDM transitions. updateStatus stays in
- // the 2 s loop because connection / peer state genuinely change
- // continuously and have no event yet.
- s.checkAndUpdateFeatures()
- go func() {
- s.getSrvConfig()
- time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon
- for {
- err := s.updateStatus()
- if err != nil {
- log.Errorf("error while updating status: %v", err)
- }
-
- time.Sleep(2 * time.Second)
- }
- }()
-
- s.eventManager = event.NewManager(s.notifier, s.addr)
- s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
- s.eventManager.AddHandler(func(event *proto.SystemEvent) {
- if event.Category == proto.SystemEvent_SYSTEM {
- s.updateExitNodes()
- }
- })
- s.eventManager.AddHandler(func(event *proto.SystemEvent) {
- // todo use new Category
- if windowAction, ok := event.Metadata["progress_window"]; ok {
- targetVersion, ok := event.Metadata["version"]
- if !ok {
- targetVersion = "unknown"
- }
- log.Debugf("window action: %v", windowAction)
- if windowAction == "show" {
- if s.updateContextCancel != nil {
- s.updateContextCancel()
- s.updateContextCancel = nil
- }
-
- subCtx, cancel := context.WithCancel(s.ctx)
- go s.eventHandler.runSelfCommand(subCtx, "update", "--update-version", targetVersion)
- s.updateContextCancel = cancel
- }
- }
- })
- s.eventManager.AddHandler(func(event *proto.SystemEvent) {
- if newVersion, ok := event.Metadata["new_version_available"]; ok {
- _, enforced := event.Metadata["enforced"]
- log.Infof("received new_version_available event: version=%s enforced=%v", newVersion, enforced)
- s.onUpdateAvailable(newVersion, enforced)
- }
- })
- s.eventManager.AddHandler(func(event *proto.SystemEvent) {
- // Daemon emits a config_changed event after every engine spawn
- // (Server.Start, Server.Up, MDM ticker restart). Re-sync the
- // tray submenu checkboxes from the fresh daemon-side config so
- // the user does not have to restart the tray to see CLI- or
- // MDM-driven changes.
- if event.Category == proto.SystemEvent_SYSTEM && event.Metadata["type"] == "config_changed" {
- log.Infof("config_changed event received (source=%s); refreshing settings + features", event.Metadata["source"])
- s.loadSettings()
- // MDM-driven feature kill switches (DisableProfiles /
- // DisableUpdateSettings / DisableNetworks) ride the same
- // config_changed signal because the daemon re-applies its
- // MDM policy on every engine spawn. Pull them in here so
- // the UI is up to date without a periodic GetFeatures poll.
- s.checkAndUpdateFeatures()
- }
- })
-
- go s.eventManager.Start(s.ctx)
- go s.eventHandler.listen(s.ctx)
-}
-
-func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File {
- if s.logFile == "" {
- // attach child's streams to parent's streams
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
-
- return nil
- }
-
- out, err := os.OpenFile(s.logFile, os.O_WRONLY|os.O_APPEND, 0)
- if err != nil {
- log.Errorf("Failed to open log file %s: %v", s.logFile, err)
- return nil
- }
- cmd.Stdout = out
- cmd.Stderr = out
- return out
-}
-
-func normalizedVersion(version string) string {
- versionString := version
- if unicode.IsDigit(rune(versionString[0])) {
- versionString = fmt.Sprintf("v%s", versionString)
- }
- return versionString
-}
-
-// onTrayExit is called when the tray icon is closed.
-func (s *serviceClient) onTrayExit() {
- s.cancel()
-}
-
-// getSrvClient connection to the service.
-func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonServiceClient, error) {
- s.connLock.Lock()
- defer s.connLock.Unlock()
- if s.conn != nil {
- return s.conn, nil
- }
-
- ctx, cancel := context.WithTimeout(s.ctx, timeout)
- defer cancel()
-
- conn, err := grpc.DialContext(
- ctx,
- strings.TrimPrefix(s.addr, "tcp://"),
- grpc.WithTransportCredentials(insecure.NewCredentials()),
- grpc.WithBlock(),
- grpc.WithUserAgent(desktop.GetUIUserAgent()),
- )
- if err != nil {
- return nil, fmt.Errorf("dial service: %w", err)
- }
-
- s.conn = proto.NewDaemonServiceClient(conn)
- return s.conn, nil
-}
-
-// checkAndUpdateFeatures checks the current features and updates the UI accordingly
-func (s *serviceClient) checkAndUpdateFeatures() {
- features, err := s.getFeatures()
- if err != nil {
- log.Errorf("failed to get features from daemon: %v", err)
- return
- }
-
- s.updateIndicationLock.Lock()
- defer s.updateIndicationLock.Unlock()
-
- // DisableUpdateSettings is enforced server-side by the daemon gates
- // on SetConfig + Login: any attempt to mutate config from UI or
- // CLI is rejected at that layer. The UI deliberately keeps the
- // Settings menu visible so the user can still inspect current
- // values — read-only by virtue of the daemon refusing edits.
-
- // Update profile menu based on current features
- if s.mProfile != nil {
- profilesEnabled := features == nil || !features.DisableProfiles
- if s.profilesEnabled != profilesEnabled {
- s.profilesEnabled = profilesEnabled
- s.mProfile.setEnabled(profilesEnabled)
- }
- }
-
- // Update networks and exit node menus based on current features.
- // `networksEnabled` is the bare feature flag (read elsewhere, e.g. at
- // connection-status transitions). `networksMenuEnabled` is the
- // transition-cached state actually applied to the menu items —
- // it folds in the connection state so a Connected client with the
- // kill switch off shows the menus active, and only flips on diff.
- s.networksEnabled = features == nil || !features.DisableNetworks
- desiredNetworksMenu := s.networksEnabled && s.connected
- if desiredNetworksMenu != s.networksMenuEnabled {
- s.networksMenuEnabled = desiredNetworksMenu
- if desiredNetworksMenu {
- s.mNetworks.Enable()
- s.mExitNode.Enable()
- } else {
- s.mNetworks.Disable()
- s.mExitNode.Disable()
- }
- }
-}
-
-// getFeatures from the daemon to determine which features are enabled/disabled.
-func (s *serviceClient) getFeatures() (*proto.GetFeaturesResponse, error) {
- conn, err := s.getSrvClient(failFastTimeout)
- if err != nil {
- return nil, fmt.Errorf("get client for features: %w", err)
- }
-
- features, err := conn.GetFeatures(s.ctx, &proto.GetFeaturesRequest{})
- if err != nil {
- return nil, fmt.Errorf("get features from daemon: %w", err)
- }
-
- return features, nil
-}
-
-// getSrvConfig from the service to show it in the settings window.
-func (s *serviceClient) getSrvConfig() {
- s.managementURL = profilemanager.DefaultManagementURL
-
- _, err := s.profileManager.GetActiveProfile()
- if err != nil {
- log.Errorf("get active profile: %v", err)
- return
- }
-
- var cfg *profilemanager.Config
-
- conn, err := s.getSrvClient(failFastTimeout)
- if err != nil {
- log.Errorf("get client: %v", err)
- return
- }
-
- currUser, err := user.Current()
- if err != nil {
- log.Errorf("get current user: %v", err)
- return
- }
-
- activeProf, err := s.profileManager.GetActiveProfile()
- if err != nil {
- log.Errorf("get active profile: %v", err)
- return
- }
-
- srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
- ProfileName: activeProf.ID.String(),
- Username: currUser.Username,
- })
- if err != nil {
- log.Errorf("get config settings from server: %v", err)
- return
- }
-
- cfg = protoConfigToConfig(srvCfg)
-
- if cfg.ManagementURL.String() != "" {
- s.managementURL = cfg.ManagementURL.String()
- }
- s.preSharedKey = cfg.PreSharedKey
- s.RosenpassPermissive = cfg.RosenpassPermissive
- s.interfaceName = cfg.WgIface
- s.interfacePort = cfg.WgPort
- s.mtu = cfg.MTU
-
- s.networkMonitor = *cfg.NetworkMonitor
- s.disableDNS = cfg.DisableDNS
- s.disableClientRoutes = cfg.DisableClientRoutes
- s.disableServerRoutes = cfg.DisableServerRoutes
- s.disableIPv6 = cfg.DisableIPv6
- s.blockLANAccess = cfg.BlockLANAccess
-
- if cfg.EnableSSHRoot != nil {
- s.enableSSHRoot = *cfg.EnableSSHRoot
- }
- if cfg.EnableSSHSFTP != nil {
- s.enableSSHSFTP = *cfg.EnableSSHSFTP
- }
- if cfg.EnableSSHLocalPortForwarding != nil {
- s.enableSSHLocalPortForward = *cfg.EnableSSHLocalPortForwarding
- }
- if cfg.EnableSSHRemotePortForwarding != nil {
- s.enableSSHRemotePortForward = *cfg.EnableSSHRemotePortForwarding
- }
- if cfg.DisableSSHAuth != nil {
- s.disableSSHAuth = *cfg.DisableSSHAuth
- }
- if cfg.SSHJWTCacheTTL != nil {
- s.sshJWTCacheTTL = *cfg.SSHJWTCacheTTL
- }
-
- if s.showAdvancedSettings {
- s.iMngURL.SetText(s.managementURL)
- // PSK is rendered with an empty Text and a hint via the
- // placeholder so the eye toggle never reveals literal asterisks
- // (the daemon returns the "**********" sentinel — writing that
- // into a PasswordEntry would surface the literal sentinel when
- // the user unmasks the field). The placeholder communicates the
- // configured / MDM-managed state without exposing any value.
- s.iPreSharedKey.SetText("")
- s.iPreSharedKey.SetPlaceHolder(preSharedKeyPlaceholder(srvCfg))
- s.iInterfaceName.SetText(cfg.WgIface)
- s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort))
- if cfg.MTU != 0 {
- s.iMTU.SetText(strconv.Itoa(int(cfg.MTU)))
- } else {
- s.iMTU.SetText("")
- s.iMTU.SetPlaceHolder(strconv.Itoa(int(iface.DefaultMTU)))
- }
- s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive)
- // Re-baseline the enabled state on every refresh: when Rosenpass
- // is on the checkbox is editable, when it's off the field is
- // inert. Without an explicit Enable() here the control stays
- // stuck disabled after a previous refresh (or an MDM unlock) had
- // turned it off — applyMDMLocksToSettingsForm below adds the
- // MDM lock on top of this baseline.
- if cfg.RosenpassEnabled {
- s.sRosenpassPermissive.Enable()
- } else {
- s.sRosenpassPermissive.Disable()
- }
- s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor)
- s.sDisableDNS.SetChecked(cfg.DisableDNS)
- s.sDisableClientRoutes.SetChecked(cfg.DisableClientRoutes)
- s.sDisableServerRoutes.SetChecked(cfg.DisableServerRoutes)
- s.sDisableIPv6.SetChecked(cfg.DisableIPv6)
- s.sBlockLANAccess.SetChecked(cfg.BlockLANAccess)
- if cfg.EnableSSHRoot != nil {
- s.sEnableSSHRoot.SetChecked(*cfg.EnableSSHRoot)
- }
- if cfg.EnableSSHSFTP != nil {
- s.sEnableSSHSFTP.SetChecked(*cfg.EnableSSHSFTP)
- }
- if cfg.EnableSSHLocalPortForwarding != nil {
- s.sEnableSSHLocalPortForward.SetChecked(*cfg.EnableSSHLocalPortForwarding)
- }
- if cfg.EnableSSHRemotePortForwarding != nil {
- s.sEnableSSHRemotePortForward.SetChecked(*cfg.EnableSSHRemotePortForwarding)
- }
- if cfg.DisableSSHAuth != nil {
- s.sDisableSSHAuth.SetChecked(*cfg.DisableSSHAuth)
- }
- if cfg.SSHJWTCacheTTL != nil {
- s.iSSHJWTCacheTTL.SetText(strconv.Itoa(*cfg.SSHJWTCacheTTL))
- }
- }
-
- // MDM locks must run before the mNotifications-nil early return:
- // the Settings window is rendered by a separate UI process launched
- // with --settings (see handleAdvancedSettingsClick), and that child
- // process does NOT run onReady — so its mNotifications is nil and
- // the early return below skipped the lock pass entirely.
- s.applyMDMLocks(srvCfg.MDMManagedFields)
-
- if s.mNotifications == nil {
- return
- }
- if cfg.DisableNotifications != nil && *cfg.DisableNotifications {
- s.mNotifications.Uncheck()
- } else {
- s.mNotifications.Check()
- }
- if s.eventManager != nil {
- s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
- }
-}
-
-func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
-
- var config profilemanager.Config
-
- if cfg.ManagementUrl != "" {
- parsed, err := url.Parse(cfg.ManagementUrl)
- if err != nil {
- log.Errorf("parse management URL: %v", err)
- } else {
- config.ManagementURL = parsed
- }
- }
-
- if cfg.PreSharedKey != "" {
- if cfg.PreSharedKey != censoredPreSharedKey {
- config.PreSharedKey = cfg.PreSharedKey
- } else {
- config.PreSharedKey = ""
- }
- }
- if cfg.AdminURL != "" {
- parsed, err := url.Parse(cfg.AdminURL)
- if err != nil {
- log.Errorf("parse admin URL: %v", err)
- } else {
- config.AdminURL = parsed
- }
- }
-
- config.WgIface = cfg.InterfaceName
- if cfg.WireguardPort >= 0 && cfg.WireguardPort <= 65535 {
- config.WgPort = int(cfg.WireguardPort)
- } else {
- config.WgPort = iface.DefaultWgPort
- }
-
- if cfg.Mtu != 0 {
- config.MTU = uint16(cfg.Mtu)
- } else {
- config.MTU = iface.DefaultMTU
- }
-
- config.DisableAutoConnect = cfg.DisableAutoConnect
- config.ServerSSHAllowed = &cfg.ServerSSHAllowed
- config.RosenpassEnabled = cfg.RosenpassEnabled
- config.RosenpassPermissive = cfg.RosenpassPermissive
- config.DisableNotifications = &cfg.DisableNotifications
- config.LazyConnectionEnabled = cfg.LazyConnectionEnabled
- config.BlockInbound = cfg.BlockInbound
- config.NetworkMonitor = &cfg.NetworkMonitor
- config.DisableDNS = cfg.DisableDns
- config.DisableClientRoutes = cfg.DisableClientRoutes
- config.DisableServerRoutes = cfg.DisableServerRoutes
- config.DisableIPv6 = cfg.DisableIpv6
- config.BlockLANAccess = cfg.BlockLanAccess
-
- config.EnableSSHRoot = &cfg.EnableSSHRoot
- config.EnableSSHSFTP = &cfg.EnableSSHSFTP
- config.EnableSSHLocalPortForwarding = &cfg.EnableSSHLocalPortForwarding
- config.EnableSSHRemotePortForwarding = &cfg.EnableSSHRemotePortForwarding
- config.DisableSSHAuth = &cfg.DisableSSHAuth
-
- ttl := int(cfg.SshJWTCacheTTL)
- config.SSHJWTCacheTTL = &ttl
-
- return &config
-}
-
-func (s *serviceClient) onUpdateAvailable(newVersion string, enforced bool) {
- s.updateIndicationLock.Lock()
- defer s.updateIndicationLock.Unlock()
-
- s.isEnforcedUpdate = enforced
- if enforced {
- s.mUpdate.SetTitle("Install version " + newVersion)
- } else {
- s.lastNotifiedVersion = ""
- s.mUpdate.SetTitle("Download latest version")
- }
-
- s.mUpdate.Show()
- s.isUpdateIconActive = true
-
- if s.connected {
- systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected)
- } else {
- systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected)
- }
-
- if enforced && s.lastNotifiedVersion != newVersion {
- s.lastNotifiedVersion = newVersion
- s.notifier.Send("Update available", "A new version "+newVersion+" is ready to install")
- }
-}
-
-// onSessionExpire sends a notification to the user when the session expires.
-func (s *serviceClient) onSessionExpire() {
- s.sendNotification = true
- if s.sendNotification {
- go s.eventHandler.runSelfCommand(s.ctx, "login-url", "true")
- s.sendNotification = false
- }
-}
-
-// loadSettings loads the settings from the config file and updates the UI elements accordingly.
-func (s *serviceClient) loadSettings() {
- conn, err := s.getSrvClient(failFastTimeout)
- if err != nil {
- log.Errorf("get client: %v", err)
- return
- }
-
- currUser, err := user.Current()
- if err != nil {
- log.Errorf("get current user: %v", err)
- return
- }
-
- activeProf, err := s.profileManager.GetActiveProfile()
- if err != nil {
- log.Errorf("get active profile: %v", err)
- return
- }
-
- cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
- ProfileName: activeProf.ID.String(),
- Username: currUser.Username,
- })
- if err != nil {
- log.Errorf("get config settings from server: %v", err)
- return
- }
-
- if cfg.ServerSSHAllowed {
- s.mAllowSSH.Check()
- } else {
- s.mAllowSSH.Uncheck()
- }
-
- if cfg.DisableAutoConnect {
- s.mAutoConnect.Uncheck()
- } else {
- s.mAutoConnect.Check()
- }
-
- if cfg.RosenpassEnabled {
- s.mEnableRosenpass.Check()
- } else {
- s.mEnableRosenpass.Uncheck()
- }
-
- if cfg.LazyConnectionEnabled {
- s.mLazyConnEnabled.Check()
- } else {
- s.mLazyConnEnabled.Uncheck()
- }
-
- if cfg.BlockInbound {
- s.mBlockInbound.Check()
- } else {
- s.mBlockInbound.Uncheck()
- }
-
- if cfg.DisableNotifications {
- s.mNotifications.Uncheck()
- } else {
- s.mNotifications.Check()
- }
- if s.eventManager != nil {
- s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
- }
- s.applyMDMLocks(cfg.MDMManagedFields)
-}
-
-// applyMDMLocks disables and badges any tray submenu item or settings-
-// form widget whose underlying field is enforced by the active MDM
-// policy. Called from loadSettings (submenu refresh) and from
-// getSrvConfig (settings-window refresh). Locked items keep their value
-// already set by the surrounding refresh code — this routine only
-// flips the enabled state and the title suffix, never the value.
-func (s *serviceClient) applyMDMLocks(managed []string) {
- set := make(map[string]bool, len(managed))
- for _, k := range managed {
- set[k] = true
- }
- s.mdmManagedFields = set
- if len(managed) > 0 {
- log.Infof("MDM-managed UI fields: %v", managed)
- }
-
- type submenuTarget struct {
- item *systray.MenuItem
- title string
- key string
- }
- for _, t := range []submenuTarget{
- {s.mAllowSSH, "Allow SSH", mdm.KeyAllowServerSSH},
- {s.mAutoConnect, "Connect on Startup", mdm.KeyDisableAutoConnect},
- {s.mEnableRosenpass, "Enable Quantum-Resistance", mdm.KeyRosenpassEnabled},
- {s.mBlockInbound, "Block Inbound Connections", mdm.KeyBlockInbound},
- } {
- if t.item == nil {
- continue
- }
- if set[t.key] {
- t.item.SetTitle(t.title + " (MDM)")
- t.item.Disable()
- } else {
- t.item.SetTitle(t.title)
- t.item.Enable()
- }
- }
-
- s.applyMDMLocksToSettingsForm(set)
-}
-
-// preSharedKeyPlaceholder returns the hint string shown in the PSK
-// Entry's placeholder slot. The placeholder is the only signal the
-// user gets that a PSK is configured, because the entry's Text is
-// forced to empty to keep the password reveal toggle from leaking
-// the daemon-returned "**********" redaction sentinel. Returns "" if
-// no PSK is present, "MDM-managed" if the key is enforced by MDM,
-// and "configured" otherwise.
-func preSharedKeyPlaceholder(cfg *proto.GetConfigResponse) string {
- if cfg == nil || cfg.PreSharedKey == "" {
- return ""
- }
- for _, k := range cfg.MDMManagedFields {
- if k == mdm.KeyPreSharedKey {
- return "MDM-managed"
- }
- }
- return "configured"
-}
-
-// applyMDMLocksToSettingsForm disables the per-field input widgets in
-// the advanced Settings window when the corresponding MDM key is set.
-// For plain-text entries (Management URL, Interface Port) the visible
-// value is suffixed with " (MDM)" so the user sees the lock indicator
-// inline; for the password entry the suffix is skipped (a password
-// widget renders every char as a dot and the indicator would not be
-// readable). The widgets are created lazily by showSettingsUI, so
-// guard each ref against nil.
-func (s *serviceClient) applyMDMLocksToSettingsForm(set map[string]bool) {
- type entryTarget struct {
- entry *widget.Entry
- key string
- inlineTag bool
- }
- for _, t := range []entryTarget{
- {s.iMngURL, mdm.KeyManagementURL, true},
- {s.iPreSharedKey, mdm.KeyPreSharedKey, false},
- {s.iInterfacePort, mdm.KeyWireguardPort, true},
- } {
- if t.entry == nil {
- continue
- }
- if set[t.key] {
- if t.inlineTag && t.entry.Text != "" && !strings.HasSuffix(t.entry.Text, mdmFieldSuffix) {
- t.entry.SetText(t.entry.Text + mdmFieldSuffix)
- }
- t.entry.Disable()
- } else {
- if t.inlineTag {
- t.entry.SetText(strings.TrimSuffix(t.entry.Text, mdmFieldSuffix))
- }
- t.entry.Enable()
- }
- }
- type checkTarget struct {
- check *widget.Check
- key string
- }
- for _, t := range []checkTarget{
- {s.sDisableClientRoutes, mdm.KeyDisableClientRoutes},
- {s.sDisableServerRoutes, mdm.KeyDisableServerRoutes},
- } {
- if t.check == nil {
- continue
- }
- if set[t.key] {
- t.check.Disable()
- } else {
- t.check.Enable()
- }
- }
- if s.sRosenpassPermissive != nil && set[mdm.KeyRosenpassPermissive] {
- // MDM lock layered on top of the Rosenpass-on/off baseline
- // applied by getSrvConfig. No Enable() branch here: when the
- // MDM key is removed, the next getSrvConfig refresh re-baselines
- // the control on cfg.RosenpassEnabled and brings it back if
- // Rosenpass is on.
- s.sRosenpassPermissive.Disable()
- }
-}
-
-// updateConfig updates the configuration parameters
-// based on the values selected in the settings window.
-func (s *serviceClient) updateConfig() error {
- disableAutoStart := !s.mAutoConnect.Checked()
- sshAllowed := s.mAllowSSH.Checked()
- rosenpassEnabled := s.mEnableRosenpass.Checked()
- lazyConnectionEnabled := s.mLazyConnEnabled.Checked()
- blockInbound := s.mBlockInbound.Checked()
- notificationsDisabled := !s.mNotifications.Checked()
-
- activeProf, err := s.profileManager.GetActiveProfile()
- if err != nil {
- log.Errorf("get active profile: %v", err)
- return err
- }
-
- currUser, err := user.Current()
- if err != nil {
- log.Errorf("get current user: %v", err)
- return err
- }
-
- conn, err := s.getSrvClient(failFastTimeout)
- if err != nil {
- log.Errorf("get client: %v", err)
- return err
- }
-
- req := proto.SetConfigRequest{
- ProfileName: activeProf.ID.String(),
- Username: currUser.Username,
- DisableAutoConnect: &disableAutoStart,
- ServerSSHAllowed: &sshAllowed,
- RosenpassEnabled: &rosenpassEnabled,
- LazyConnectionEnabled: &lazyConnectionEnabled,
- BlockInbound: &blockInbound,
- DisableNotifications: ¬ificationsDisabled,
- }
-
- if _, err := conn.SetConfig(s.ctx, &req); err != nil {
- log.Errorf("set config settings on server: %v", err)
- return err
- }
-
- return nil
-}
-
-// showLoginURL creates a borderless window styled like a pop-up in the top-right corner using s.wLoginURL.
-// It also starts a background goroutine that periodically checks if the client is already connected
-// and closes the window if so. The goroutine can be cancelled by the returned CancelFunc, and it is
-// also cancelled when the window is closed.
-func (s *serviceClient) showLoginURL() context.CancelFunc {
-
- // create a cancellable context for the background check goroutine
- ctx, cancel := context.WithCancel(s.ctx)
-
- resIcon := fyne.NewStaticResource("netbird.png", iconAbout)
-
- if s.wLoginURL == nil {
- s.wLoginURL = s.app.NewWindow("NetBird Session Expired")
- s.wLoginURL.Resize(fyne.NewSize(400, 200))
- s.wLoginURL.SetIcon(resIcon)
- }
- // ensure goroutine is cancelled when the window is closed
- s.wLoginURL.SetOnClosed(func() { cancel() })
- // add a description label
- label := widget.NewLabel("Your NetBird session has expired.\nPlease re-authenticate to continue using NetBird.")
-
- btn := widget.NewButtonWithIcon("Re-authenticate", theme.ViewRefreshIcon(), func() {
-
- conn, err := s.getSrvClient(defaultFailTimeout)
- if err != nil {
- log.Errorf("get client: %v", err)
- return
- }
-
- resp, err := s.login(ctx, false)
- if err != nil {
- log.Errorf("failed to fetch login URL: %v", err)
- return
- }
- verificationURL := resp.VerificationURIComplete
- if verificationURL == "" {
- verificationURL = resp.VerificationURI
- }
-
- if verificationURL == "" {
- log.Error("no verification URL provided in the login response")
- return
- }
-
- if err := openURL(verificationURL); err != nil {
- log.Errorf("failed to open login URL: %v", err)
- return
- }
-
- _, err = conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: resp.UserCode})
- if err != nil {
- log.Errorf("Waiting sso login failed with: %v", err)
- label.SetText("Waiting login failed, please create \na debug bundle in the settings and contact support.")
- return
- }
-
- label.SetText("Re-authentication successful.\nReconnecting")
- status, err := conn.Status(ctx, &proto.StatusRequest{})
- if err != nil {
- log.Errorf("get service status: %v", err)
- return
- }
-
- if status.Status == string(internal.StatusConnected) {
- label.SetText("Already connected.\nClosing this window.")
- time.Sleep(2 * time.Second)
- s.wLoginURL.Close()
- return
- }
-
- _, err = conn.Up(ctx, &proto.UpRequest{})
- if err != nil {
- label.SetText("Reconnecting failed, please create \na debug bundle in the settings and contact support.")
- log.Errorf("Reconnecting failed with: %v", err)
- return
- }
-
- label.SetText("Connection successful.\nClosing this window.")
- time.Sleep(time.Second)
-
- s.wLoginURL.Close()
- })
-
- img := canvas.NewImageFromResource(resIcon)
- img.FillMode = canvas.ImageFillContain
- img.SetMinSize(fyne.NewSize(64, 64))
- img.Resize(fyne.NewSize(64, 64))
-
- // center the content vertically
- content := container.NewVBox(
- layout.NewSpacer(),
- img,
- label,
- btn,
- layout.NewSpacer(),
- )
- s.wLoginURL.SetContent(container.NewCenter(content))
-
- // start a goroutine to check connection status and close the window if connected
- go func() {
- ticker := time.NewTicker(5 * time.Second)
- defer ticker.Stop()
-
- conn, err := s.getSrvClient(failFastTimeout)
- if err != nil {
- return
- }
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- status, err := conn.Status(s.ctx, &proto.StatusRequest{})
- if err != nil {
- continue
- }
- if status.Status == string(internal.StatusConnected) {
- if s.wLoginURL != nil {
- s.wLoginURL.Close()
- }
- return
- }
- }
- }
- }()
-
- s.wLoginURL.Show()
-
- // return cancel func so callers can stop the background goroutine if desired
- return cancel
-}
-
-func openURL(url string) error {
- if browser := os.Getenv("BROWSER"); browser != "" {
- return exec.Command(browser, url).Start()
- }
-
- var err error
- switch runtime.GOOS {
- case "windows":
- err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
- case "darwin":
- err = exec.Command("open", url).Start()
- case "linux", "freebsd":
- err = exec.Command("xdg-open", url).Start()
- default:
- err = fmt.Errorf("unsupported platform")
- }
- return err
-}
diff --git a/client/ui/const.go b/client/ui/const.go
deleted file mode 100644
index 48619be75..000000000
--- a/client/ui/const.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package main
-
-const (
- allowSSHMenuDescr = "Allow SSH connections"
- autoConnectMenuDescr = "Connect automatically when the service starts"
- quantumResistanceMenuDescr = "Enable post-quantum security via Rosenpass"
- lazyConnMenuDescr = "[Experimental] Enable lazy connections"
- blockInboundMenuDescr = "Block inbound connections to the local machine and routed networks"
- notificationsMenuDescr = "Enable notifications"
- advancedSettingsMenuDescr = "Advanced settings of the application"
- debugBundleMenuDescr = "Create and open debug information bundle"
- disabledMenuDescr = ""
- networksMenuDescr = "Open the networks management window"
- latestVersionMenuDescr = "Download latest version"
- quitMenuDescr = "Quit the client app"
-)
diff --git a/client/ui/debug.go b/client/ui/debug.go
deleted file mode 100644
index d3d4fa4f8..000000000
--- a/client/ui/debug.go
+++ /dev/null
@@ -1,730 +0,0 @@
-//go:build !(linux && 386)
-
-package main
-
-import (
- "context"
- "fmt"
- "path/filepath"
- "strconv"
- "sync"
- "time"
-
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/container"
- "fyne.io/fyne/v2/dialog"
- "fyne.io/fyne/v2/widget"
- log "github.com/sirupsen/logrus"
- "github.com/skratchdot/open-golang/open"
- "google.golang.org/protobuf/types/known/durationpb"
-
- "github.com/netbirdio/netbird/client/internal"
- "github.com/netbirdio/netbird/client/proto"
- uptypes "github.com/netbirdio/netbird/upload-server/types"
- "github.com/netbirdio/netbird/version"
-)
-
-// Initial state for the debug collection
-type debugInitialState struct {
- wasDown bool
- needsRestoreUp bool
- logLevel proto.LogLevel
- isLevelTrace bool
-}
-
-// Debug collection parameters
-type debugCollectionParams struct {
- duration time.Duration
- anonymize bool
- systemInfo bool
- upload bool
- uploadURL string
- enablePersistence bool
- capture bool
-}
-
-// UI components for progress tracking
-type progressUI struct {
- statusLabel *widget.Label
- progressBar *widget.ProgressBar
- uiControls []fyne.Disableable
- window fyne.Window
-}
-
-func (s *serviceClient) showDebugUI() {
- w := s.app.NewWindow("NetBird Debug")
- w.SetOnClosed(s.cancel)
- w.Resize(fyne.NewSize(600, 500))
- w.SetFixedSize(true)
-
- anonymizeCheck := widget.NewCheck("Anonymize sensitive information (public IPs, domains, ...)", nil)
- systemInfoCheck := widget.NewCheck("Include system information (routes, interfaces, ...)", nil)
- systemInfoCheck.SetChecked(true)
- captureCheck := widget.NewCheck("Include packet capture", nil)
- uploadCheck := widget.NewCheck("Upload bundle automatically after creation", nil)
- uploadCheck.SetChecked(true)
-
- uploadURLContainer, uploadURL := s.buildUploadSection(uploadCheck)
-
- debugModeContainer, runForDurationCheck, durationInput, noteLabel := s.buildDurationSection()
-
- statusLabel := widget.NewLabel("")
- statusLabel.Hide()
- progressBar := widget.NewProgressBar()
- progressBar.Hide()
- createButton := widget.NewButton("Create Debug Bundle", nil)
-
- uiControls := []fyne.Disableable{
- anonymizeCheck, systemInfoCheck, captureCheck,
- uploadCheck, uploadURL, runForDurationCheck, durationInput, createButton,
- }
-
- createButton.OnTapped = s.getCreateHandler(
- statusLabel, progressBar, uploadCheck, uploadURL,
- anonymizeCheck, systemInfoCheck, captureCheck,
- runForDurationCheck, durationInput, uiControls, w,
- )
-
- content := container.NewVBox(
- widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"),
- widget.NewLabel(""),
- anonymizeCheck, systemInfoCheck, captureCheck,
- uploadCheck, uploadURLContainer,
- widget.NewLabel(""),
- debugModeContainer, noteLabel,
- widget.NewLabel(""),
- statusLabel, progressBar, createButton,
- )
-
- w.SetContent(container.NewPadded(content))
- w.Show()
-}
-
-func (s *serviceClient) buildUploadSection(uploadCheck *widget.Check) (*fyne.Container, *widget.Entry) {
- uploadURL := widget.NewEntry()
- uploadURL.SetText(uptypes.DefaultBundleURL)
- uploadURL.SetPlaceHolder("Enter upload URL")
-
- uploadURLContainer := container.NewVBox(widget.NewLabel("Debug upload URL:"), uploadURL)
-
- uploadCheck.OnChanged = func(checked bool) {
- if checked {
- uploadURLContainer.Show()
- } else {
- uploadURLContainer.Hide()
- }
- }
- return uploadURLContainer, uploadURL
-}
-
-func (s *serviceClient) buildDurationSection() (*fyne.Container, *widget.Check, *widget.Entry, *widget.Label) {
- runForDurationCheck := widget.NewCheck("Run with trace logs before creating bundle", nil)
- runForDurationCheck.SetChecked(true)
-
- forLabel := widget.NewLabel("for")
- durationInput := widget.NewEntry()
- durationInput.SetText("1")
- minutesLabel := widget.NewLabel("minute")
- durationInput.Validator = func(s string) error {
- return validateMinute(s, minutesLabel)
- }
-
- noteLabel := widget.NewLabel("Note: NetBird will be brought up and down during collection")
-
- runForDurationCheck.OnChanged = func(checked bool) {
- if checked {
- forLabel.Show()
- durationInput.Show()
- minutesLabel.Show()
- noteLabel.Show()
- } else {
- forLabel.Hide()
- durationInput.Hide()
- minutesLabel.Hide()
- noteLabel.Hide()
- }
- }
-
- modeContainer := container.NewHBox(runForDurationCheck, forLabel, durationInput, minutesLabel)
- return modeContainer, runForDurationCheck, durationInput, noteLabel
-}
-
-func validateMinute(s string, minutesLabel *widget.Label) error {
- if val, err := strconv.Atoi(s); err != nil || val < 1 {
- return fmt.Errorf("must be a number ≥ 1")
- }
- if s == "1" {
- minutesLabel.SetText("minute")
- } else {
- minutesLabel.SetText("minutes")
- }
- return nil
-}
-
-// disableUIControls disables the provided UI controls
-func disableUIControls(controls []fyne.Disableable) {
- for _, control := range controls {
- control.Disable()
- }
-}
-
-// enableUIControls enables the provided UI controls
-func enableUIControls(controls []fyne.Disableable) {
- for _, control := range controls {
- control.Enable()
- }
-}
-
-func (s *serviceClient) getCreateHandler(
- statusLabel *widget.Label,
- progressBar *widget.ProgressBar,
- uploadCheck *widget.Check,
- uploadURL *widget.Entry,
- anonymizeCheck *widget.Check,
- systemInfoCheck *widget.Check,
- captureCheck *widget.Check,
- runForDurationCheck *widget.Check,
- duration *widget.Entry,
- uiControls []fyne.Disableable,
- w fyne.Window,
-) func() {
- return func() {
- disableUIControls(uiControls)
- statusLabel.Show()
-
- var url string
- if uploadCheck.Checked {
- url = uploadURL.Text
- if url == "" {
- statusLabel.SetText("Error: Upload URL is required when upload is enabled")
- enableUIControls(uiControls)
- return
- }
- }
-
- params := &debugCollectionParams{
- anonymize: anonymizeCheck.Checked,
- systemInfo: systemInfoCheck.Checked,
- capture: captureCheck.Checked,
- upload: uploadCheck.Checked,
- uploadURL: url,
- enablePersistence: true,
- }
-
- runForDuration := runForDurationCheck.Checked
- if runForDuration {
- minutes, err := time.ParseDuration(duration.Text + "m")
- if err != nil {
- statusLabel.SetText(fmt.Sprintf("Error: Invalid duration: %v", err))
- enableUIControls(uiControls)
- return
- }
- params.duration = minutes
-
- statusLabel.SetText(fmt.Sprintf("Running in debug mode for %d minutes...", int(minutes.Minutes())))
- progressBar.Show()
- progressBar.SetValue(0)
-
- go s.handleRunForDuration(
- statusLabel,
- progressBar,
- uiControls,
- w,
- params,
- )
- return
- }
-
- statusLabel.SetText("Creating debug bundle...")
- go s.handleDebugCreation(
- params,
- statusLabel,
- uiControls,
- w,
- )
- }
-}
-
-func (s *serviceClient) handleRunForDuration(
- statusLabel *widget.Label,
- progressBar *widget.ProgressBar,
- uiControls []fyne.Disableable,
- w fyne.Window,
- params *debugCollectionParams,
-) {
- progressUI := &progressUI{
- statusLabel: statusLabel,
- progressBar: progressBar,
- uiControls: uiControls,
- window: w,
- }
-
- conn, err := s.getSrvClient(failFastTimeout)
- if err != nil {
- handleError(progressUI, fmt.Sprintf("Failed to get client for debug: %v", err))
- return
- }
-
- initialState, err := s.getInitialState(conn)
- if err != nil {
- handleError(progressUI, err.Error())
- return
- }
-
- defer s.restoreServiceState(conn, initialState)
-
- if err := s.collectDebugData(conn, initialState, params, progressUI); err != nil {
- handleError(progressUI, err.Error())
- return
- }
-
- if err := s.createDebugBundleFromCollection(conn, params, progressUI); err != nil {
- handleError(progressUI, err.Error())
- return
- }
-
- progressUI.statusLabel.SetText("Bundle created successfully")
-}
-
-// Get initial state of the service
-func (s *serviceClient) getInitialState(conn proto.DaemonServiceClient) (*debugInitialState, error) {
- statusResp, err := conn.Status(s.ctx, &proto.StatusRequest{})
- if err != nil {
- return nil, fmt.Errorf(" get status: %v", err)
- }
-
- logLevelResp, err := conn.GetLogLevel(s.ctx, &proto.GetLogLevelRequest{})
- if err != nil {
- return nil, fmt.Errorf("get log level: %v", err)
- }
-
- wasDown := statusResp.Status != string(internal.StatusConnected) &&
- statusResp.Status != string(internal.StatusConnecting)
-
- initialLogLevel := logLevelResp.GetLevel()
- initialLevelTrace := initialLogLevel >= proto.LogLevel_TRACE
-
- return &debugInitialState{
- wasDown: wasDown,
- logLevel: initialLogLevel,
- isLevelTrace: initialLevelTrace,
- }, nil
-}
-
-// Handle progress tracking during collection
-func startProgressTracker(ctx context.Context, wg *sync.WaitGroup, duration time.Duration, progress *progressUI) {
- progress.progressBar.Show()
- progress.progressBar.SetValue(0)
-
- startTime := time.Now()
- endTime := startTime.Add(duration)
- wg.Add(1)
-
- go func() {
- defer wg.Done()
- ticker := time.NewTicker(500 * time.Millisecond)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- remaining := time.Until(endTime)
- if remaining <= 0 {
- remaining = 0
- }
-
- elapsed := time.Since(startTime)
- progressVal := float64(elapsed) / float64(duration)
- if progressVal > 1.0 {
- progressVal = 1.0
- }
-
- progress.progressBar.SetValue(progressVal)
- progress.statusLabel.SetText(fmt.Sprintf("Running with trace logs... %s remaining", formatDuration(remaining)))
- }
- }
- }()
-
-}
-
-func (s *serviceClient) configureServiceForDebug(
- conn proto.DaemonServiceClient,
- state *debugInitialState,
- params *debugCollectionParams,
-) {
- if state.wasDown {
- if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil {
- log.Warnf("failed to bring service up: %v", err)
- } else {
- log.Info("Service brought up for debug")
- time.Sleep(time.Second * 10)
- }
- }
-
- if !state.isLevelTrace {
- if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel_TRACE}); err != nil {
- log.Warnf("failed to set log level to TRACE: %v", err)
- } else {
- log.Info("Log level set to TRACE for debug")
- }
- }
-
- if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil {
- log.Warnf("failed to bring service down: %v", err)
- } else {
- state.needsRestoreUp = !state.wasDown
- time.Sleep(time.Second)
- }
-
- if params.enablePersistence {
- if _, err := conn.SetSyncResponsePersistence(s.ctx, &proto.SetSyncResponsePersistenceRequest{
- Enabled: true,
- }); err != nil {
- log.Warnf("failed to enable sync response persistence: %v", err)
- } else {
- log.Info("Sync response persistence enabled for debug")
- }
- }
-
- if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil {
- log.Warnf("failed to bring service back up: %v", err)
- } else {
- state.needsRestoreUp = false
- time.Sleep(time.Second * 3)
- }
-
- if _, err := conn.StartCPUProfile(s.ctx, &proto.StartCPUProfileRequest{}); err != nil {
- log.Warnf("failed to start CPU profiling: %v", err)
- }
-
- s.startBundleCaptureIfEnabled(conn, params)
-}
-
-func (s *serviceClient) startBundleCaptureIfEnabled(conn proto.DaemonServiceClient, params *debugCollectionParams) {
- if !params.capture {
- return
- }
-
- const maxCapture = 10 * time.Minute
- timeout := params.duration + 30*time.Second
- if timeout > maxCapture {
- timeout = maxCapture
- log.Warnf("packet capture clamped to %s (server maximum)", maxCapture)
- }
- if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{
- Timeout: durationpb.New(timeout),
- }); err != nil {
- log.Warnf("failed to start bundle capture: %v", err)
- }
-}
-
-func (s *serviceClient) collectDebugData(
- conn proto.DaemonServiceClient,
- state *debugInitialState,
- params *debugCollectionParams,
- progress *progressUI,
-) error {
- ctx, cancel := context.WithTimeout(s.ctx, params.duration)
- defer cancel()
- var wg sync.WaitGroup
- startProgressTracker(ctx, &wg, params.duration, progress)
-
- s.configureServiceForDebug(conn, state, params)
-
- wg.Wait()
- progress.progressBar.Hide()
- progress.statusLabel.SetText("Collecting debug data...")
-
- if _, err := conn.StopCPUProfile(s.ctx, &proto.StopCPUProfileRequest{}); err != nil {
- log.Warnf("failed to stop CPU profiling: %v", err)
- }
-
- if params.capture {
- stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
- log.Warnf("failed to stop bundle capture: %v", err)
- }
- }
-
- return nil
-}
-
-// Create the debug bundle with collected data
-func (s *serviceClient) createDebugBundleFromCollection(
- conn proto.DaemonServiceClient,
- params *debugCollectionParams,
- progress *progressUI,
-) error {
- progress.statusLabel.SetText("Creating debug bundle with collected logs...")
-
- request := &proto.DebugBundleRequest{
- Anonymize: params.anonymize,
- SystemInfo: params.systemInfo,
- CliVersion: version.NetbirdVersion(),
- }
-
- if params.upload {
- request.UploadURL = params.uploadURL
- }
-
- resp, err := conn.DebugBundle(s.ctx, request)
- if err != nil {
- return fmt.Errorf("create debug bundle: %v", err)
- }
-
- // Show appropriate dialog based on upload status
- localPath := resp.GetPath()
- uploadFailureReason := resp.GetUploadFailureReason()
- uploadedKey := resp.GetUploadedKey()
-
- if params.upload {
- if uploadFailureReason != "" {
- showUploadFailedDialog(progress.window, localPath, uploadFailureReason)
- } else {
- showUploadSuccessDialog(s.app, progress.window, localPath, uploadedKey)
- }
- } else {
- showBundleCreatedDialog(progress.window, localPath)
- }
-
- enableUIControls(progress.uiControls)
- return nil
-}
-
-// Restore service to original state
-func (s *serviceClient) restoreServiceState(conn proto.DaemonServiceClient, state *debugInitialState) {
- if state.needsRestoreUp {
- if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil {
- log.Warnf("failed to restore up state: %v", err)
- } else {
- log.Info("Service state restored to up")
- }
- }
-
- if state.wasDown {
- if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil {
- log.Warnf("failed to restore down state: %v", err)
- } else {
- log.Info("Service state restored to down")
- }
- }
-
- if !state.isLevelTrace {
- if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: state.logLevel}); err != nil {
- log.Warnf("failed to restore log level: %v", err)
- } else {
- log.Info("Log level restored to original setting")
- }
- }
-}
-
-// Handle errors during debug collection
-func handleError(progress *progressUI, errMsg string) {
- log.Errorf("%s", errMsg)
- progress.statusLabel.SetText(errMsg)
- progress.progressBar.Hide()
- enableUIControls(progress.uiControls)
-}
-
-func (s *serviceClient) handleDebugCreation(
- params *debugCollectionParams,
- statusLabel *widget.Label,
- uiControls []fyne.Disableable,
- w fyne.Window,
-) {
- conn, err := s.getSrvClient(failFastTimeout)
- if err != nil {
- log.Errorf("Failed to get client for debug: %v", err)
- statusLabel.SetText(fmt.Sprintf("Error: %v", err))
- enableUIControls(uiControls)
- return
- }
-
- if params.capture {
- if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{
- Timeout: durationpb.New(30 * time.Second),
- }); err != nil {
- log.Warnf("failed to start bundle capture: %v", err)
- } else {
- defer func() {
- stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
- log.Warnf("failed to stop bundle capture: %v", err)
- }
- }()
- time.Sleep(2 * time.Second)
- }
- }
-
- resp, err := s.createDebugBundle(params.anonymize, params.systemInfo, params.uploadURL)
- if err != nil {
- log.Errorf("Failed to create debug bundle: %v", err)
- statusLabel.SetText(fmt.Sprintf("Error creating bundle: %v", err))
- enableUIControls(uiControls)
- return
- }
-
- localPath := resp.GetPath()
- uploadFailureReason := resp.GetUploadFailureReason()
- uploadedKey := resp.GetUploadedKey()
-
- if params.upload {
- if uploadFailureReason != "" {
- showUploadFailedDialog(w, localPath, uploadFailureReason)
- } else {
- showUploadSuccessDialog(s.app, w, localPath, uploadedKey)
- }
- } else {
- showBundleCreatedDialog(w, localPath)
- }
-
- enableUIControls(uiControls)
- statusLabel.SetText("Bundle created successfully")
-}
-
-func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploadURL string) (*proto.DebugBundleResponse, error) {
- conn, err := s.getSrvClient(failFastTimeout)
- if err != nil {
- return nil, fmt.Errorf("get client: %v", err)
- }
-
- request := &proto.DebugBundleRequest{
- Anonymize: anonymize,
- SystemInfo: systemInfo,
- CliVersion: version.NetbirdVersion(),
- }
-
- if uploadURL != "" {
- request.UploadURL = uploadURL
- }
-
- resp, err := conn.DebugBundle(s.ctx, request)
- if err != nil {
- return nil, fmt.Errorf("failed to create debug bundle via daemon: %v", err)
- }
-
- return resp, nil
-}
-
-// formatDuration formats a duration in HH:MM:SS format
-func formatDuration(d time.Duration) string {
- d = d.Round(time.Second)
- h := d / time.Hour
- d %= time.Hour
- m := d / time.Minute
- d %= time.Minute
- s := d / time.Second
- return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
-}
-
-// createButtonWithAction creates a button with the given label and action
-func createButtonWithAction(label string, action func()) *widget.Button {
- button := widget.NewButton(label, action)
- return button
-}
-
-// showUploadFailedDialog displays a dialog when upload fails
-func showUploadFailedDialog(w fyne.Window, localPath, failureReason string) {
- content := container.NewVBox(
- widget.NewLabel(fmt.Sprintf("Bundle upload failed:\n%s\n\n"+
- "A local copy was saved at:\n%s", failureReason, localPath)),
- )
-
- customDialog := dialog.NewCustom("Upload Failed", "Cancel", content, w)
-
- buttonBox := container.NewHBox(
- createButtonWithAction("Open file", func() {
- log.Infof("Attempting to open local file: %s", localPath)
- if openErr := open.Start(localPath); openErr != nil {
- log.Errorf("Failed to open local file '%s': %v", localPath, openErr)
- dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w)
- }
- }),
- createButtonWithAction("Open folder", func() {
- folderPath := filepath.Dir(localPath)
- log.Infof("Attempting to open local folder: %s", folderPath)
- if openErr := open.Start(folderPath); openErr != nil {
- log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr)
- dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w)
- }
- }),
- )
-
- content.Add(buttonBox)
- customDialog.Show()
-}
-
-// showUploadSuccessDialog displays a dialog when upload succeeds
-func showUploadSuccessDialog(a fyne.App, w fyne.Window, localPath, uploadedKey string) {
- log.Infof("Upload key: %s", uploadedKey)
- keyEntry := widget.NewEntry()
- keyEntry.SetText(uploadedKey)
- keyEntry.Disable()
-
- content := container.NewVBox(
- widget.NewLabel("Bundle uploaded successfully!"),
- widget.NewLabel(""),
- widget.NewLabel("Upload key:"),
- keyEntry,
- widget.NewLabel(""),
- widget.NewLabel(fmt.Sprintf("Local copy saved at:\n%s", localPath)),
- )
-
- customDialog := dialog.NewCustom("Upload Successful", "OK", content, w)
-
- copyBtn := createButtonWithAction("Copy key", func() {
- a.Clipboard().SetContent(uploadedKey)
- log.Info("Upload key copied to clipboard")
- })
-
- buttonBox := createButtonBox(localPath, w, copyBtn)
- content.Add(buttonBox)
- customDialog.Show()
-}
-
-// showBundleCreatedDialog displays a dialog when bundle is created without upload
-func showBundleCreatedDialog(w fyne.Window, localPath string) {
- content := container.NewVBox(
- widget.NewLabel(fmt.Sprintf("Bundle created locally at:\n%s\n\n"+
- "Administrator privileges may be required to access the file.", localPath)),
- )
-
- customDialog := dialog.NewCustom("Debug Bundle Created", "Cancel", content, w)
-
- buttonBox := createButtonBox(localPath, w, nil)
- content.Add(buttonBox)
- customDialog.Show()
-}
-
-func createButtonBox(localPath string, w fyne.Window, elems ...fyne.Widget) *fyne.Container {
- box := container.NewHBox()
- for _, elem := range elems {
- box.Add(elem)
- }
-
- fileBtn := createButtonWithAction("Open file", func() {
- log.Infof("Attempting to open local file: %s", localPath)
- if openErr := open.Start(localPath); openErr != nil {
- log.Errorf("Failed to open local file '%s': %v", localPath, openErr)
- dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w)
- }
- })
-
- folderBtn := createButtonWithAction("Open folder", func() {
- folderPath := filepath.Dir(localPath)
- log.Infof("Attempting to open local folder: %s", folderPath)
- if openErr := open.Start(folderPath); openErr != nil {
- log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr)
- dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w)
- }
- })
-
- box.Add(fileBtn)
- box.Add(folderBtn)
-
- return box
-}
diff --git a/client/ui/dock_darwin.go b/client/ui/dock_darwin.go
new file mode 100644
index 000000000..dd8c60073
--- /dev/null
+++ b/client/ui/dock_darwin.go
@@ -0,0 +1,70 @@
+//go:build darwin
+
+package main
+
+/*
+#cgo CFLAGS: -x objective-c
+#cgo LDFLAGS: -framework Cocoa
+#import
+
+static int lastDockState = -1;
+
+static void refreshDockPolicy(void) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ Class cls = NSClassFromString(@"WebviewWindow");
+ if (cls == nil) {
+ return;
+ }
+ int visible = 0;
+ for (NSWindow *w in [NSApp windows]) {
+ if ([w isKindOfClass:cls] && [w isVisible]) {
+ visible = 1;
+ break;
+ }
+ }
+ if (visible == lastDockState) {
+ return;
+ }
+ lastDockState = visible;
+
+ // Set application to "Regular" and show dock icon (when visible) or to "Accessory" (when hidden)
+ if (visible) {
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
+ [NSApp activateIgnoringOtherApps:YES];
+ } else {
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
+ }
+ });
+}
+
+static int dockObserverInstalled = 0;
+
+static void initDockObserver(void) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (dockObserverInstalled) {
+ return;
+ }
+ dockObserverInstalled = 1;
+ NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
+ void (^trigger)(NSNotification *) = ^(NSNotification *_) {
+ refreshDockPolicy();
+ };
+
+ [nc addObserverForName:NSWindowDidChangeOcclusionStateNotification
+ object:nil
+ queue:nil
+ usingBlock:trigger];
+ [nc addObserverForName:NSWindowWillCloseNotification
+ object:nil
+ queue:nil
+ usingBlock:trigger];
+
+ refreshDockPolicy();
+ });
+}
+*/
+import "C"
+
+func initDockObserver() {
+ C.initDockObserver()
+}
diff --git a/client/ui/dock_other.go b/client/ui/dock_other.go
new file mode 100644
index 000000000..0ace89552
--- /dev/null
+++ b/client/ui/dock_other.go
@@ -0,0 +1,7 @@
+//go:build !darwin && !android && !ios && !freebsd && !js
+
+package main
+
+func initDockObserver() {
+ // macOS-only; Linux and Windows taskbar entries already gate on window visibility natively.
+}
diff --git a/client/ui/event/event.go b/client/ui/event/event.go
deleted file mode 100644
index 3b43fdc7f..000000000
--- a/client/ui/event/event.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package event
-
-import (
- "context"
- "fmt"
- "slices"
- "strings"
- "sync"
- "time"
-
- "github.com/cenkalti/backoff/v4"
- log "github.com/sirupsen/logrus"
- "google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
-
- "github.com/netbirdio/netbird/client/proto"
- "github.com/netbirdio/netbird/client/ui/desktop"
-)
-
-// Notifier sends desktop notifications. Defined here so the event package
-// does not depend on fyne or the platform-specific notifier implementation.
-type Notifier interface {
- Send(title, body string)
-}
-
-type Handler func(*proto.SystemEvent)
-
-type Manager struct {
- notifier Notifier
- addr string
-
- mu sync.Mutex
- ctx context.Context
- cancel context.CancelFunc
- enabled bool
- handlers []Handler
-}
-
-func NewManager(notifier Notifier, addr string) *Manager {
- return &Manager{
- notifier: notifier,
- addr: addr,
- }
-}
-
-func (e *Manager) Start(ctx context.Context) {
- e.mu.Lock()
- e.ctx, e.cancel = context.WithCancel(ctx)
- e.mu.Unlock()
-
- expBackOff := backoff.WithContext(&backoff.ExponentialBackOff{
- InitialInterval: time.Second,
- RandomizationFactor: backoff.DefaultRandomizationFactor,
- Multiplier: backoff.DefaultMultiplier,
- MaxInterval: 10 * time.Second,
- MaxElapsedTime: 0,
- Stop: backoff.Stop,
- Clock: backoff.SystemClock,
- }, ctx)
-
- if err := backoff.Retry(e.streamEvents, expBackOff); err != nil {
- log.Errorf("event stream ended: %v", err)
- }
-}
-
-func (e *Manager) streamEvents() error {
- e.mu.Lock()
- ctx := e.ctx
- e.mu.Unlock()
-
- client, err := getClient(e.addr)
- if err != nil {
- return fmt.Errorf("create client: %w", err)
- }
-
- stream, err := client.SubscribeEvents(ctx, &proto.SubscribeRequest{})
- if err != nil {
- return fmt.Errorf("failed to subscribe to events: %w", err)
- }
-
- log.Info("subscribed to daemon events")
- defer func() {
- log.Info("unsubscribed from daemon events")
- }()
-
- for {
- event, err := stream.Recv()
- if err != nil {
- return fmt.Errorf("error receiving event: %w", err)
- }
- e.handleEvent(event)
- }
-}
-
-func (e *Manager) Stop() {
- e.mu.Lock()
- defer e.mu.Unlock()
- if e.cancel != nil {
- e.cancel()
- }
-}
-
-func (e *Manager) SetNotificationsEnabled(enabled bool) {
- e.mu.Lock()
- defer e.mu.Unlock()
- e.enabled = enabled
-}
-
-func (e *Manager) handleEvent(event *proto.SystemEvent) {
- e.mu.Lock()
- enabled := e.enabled
- handlers := slices.Clone(e.handlers)
- e.mu.Unlock()
-
- if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) && !isV6DefaultRoutePartner(event) {
- title := e.getEventTitle(event)
- body := event.UserMessage
- id := event.Metadata["id"]
- if id != "" {
- body += fmt.Sprintf(" ID: %s", id)
- }
- e.notifier.Send(title, body)
- }
-
- for _, handler := range handlers {
- go handler(event)
- }
-}
-
-func (e *Manager) AddHandler(handler Handler) {
- e.mu.Lock()
- defer e.mu.Unlock()
- e.handlers = append(e.handlers, handler)
-}
-
-// isV6DefaultRoutePartner reports whether the event is the IPv6 half of a
-// paired v4/v6 default-route event. Management always pairs ::/0 with 0.0.0.0/0
-// for exit nodes, so the v4 partner already drives the user-facing toast and
-// the v6 one is suppressed to avoid a duplicate notification.
-func isV6DefaultRoutePartner(event *proto.SystemEvent) bool {
- return event.Category == proto.SystemEvent_NETWORK && event.Metadata["network"] == "::/0"
-}
-
-func (e *Manager) getEventTitle(event *proto.SystemEvent) string {
- var prefix string
- switch event.Severity {
- case proto.SystemEvent_CRITICAL:
- prefix = "Critical"
- case proto.SystemEvent_ERROR:
- prefix = "Error"
- case proto.SystemEvent_WARNING:
- prefix = "Warning"
- default:
- prefix = "Info"
- }
-
- var category string
- switch event.Category {
- case proto.SystemEvent_DNS:
- category = "DNS"
- case proto.SystemEvent_NETWORK:
- category = "Network"
- case proto.SystemEvent_AUTHENTICATION:
- category = "Authentication"
- case proto.SystemEvent_CONNECTIVITY:
- category = "Connectivity"
- default:
- category = "System"
- }
-
- return fmt.Sprintf("%s: %s", prefix, category)
-}
-
-func getClient(addr string) (proto.DaemonServiceClient, error) {
- conn, err := grpc.NewClient(
- strings.TrimPrefix(addr, "tcp://"),
- grpc.WithTransportCredentials(insecure.NewCredentials()),
- grpc.WithUserAgent(desktop.GetUIUserAgent()),
- )
- if err != nil {
- return nil, err
- }
- return proto.NewDaemonServiceClient(conn), nil
-}
diff --git a/client/ui/event_handler.go b/client/ui/event_handler.go
deleted file mode 100644
index 876fcef5f..000000000
--- a/client/ui/event_handler.go
+++ /dev/null
@@ -1,326 +0,0 @@
-//go:build !(linux && 386)
-
-package main
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "os/exec"
-
- "fyne.io/systray"
- log "github.com/sirupsen/logrus"
- "google.golang.org/grpc/codes"
- "google.golang.org/grpc/status"
-
- "github.com/netbirdio/netbird/client/proto"
- "github.com/netbirdio/netbird/version"
-)
-
-type eventHandler struct {
- client *serviceClient
-}
-
-func newEventHandler(client *serviceClient) *eventHandler {
- return &eventHandler{
- client: client,
- }
-}
-
-func (h *eventHandler) listen(ctx context.Context) {
- for {
- select {
- case <-ctx.Done():
- return
- case <-h.client.mUp.ClickedCh:
- h.handleConnectClick()
- case <-h.client.mDown.ClickedCh:
- h.handleDisconnectClick()
- case <-h.client.mAllowSSH.ClickedCh:
- h.handleAllowSSHClick()
- case <-h.client.mAutoConnect.ClickedCh:
- h.handleAutoConnectClick()
- case <-h.client.mEnableRosenpass.ClickedCh:
- h.handleRosenpassClick()
- case <-h.client.mLazyConnEnabled.ClickedCh:
- h.handleLazyConnectionClick()
- case <-h.client.mBlockInbound.ClickedCh:
- h.handleBlockInboundClick()
- case <-h.client.mAdvancedSettings.ClickedCh:
- h.handleAdvancedSettingsClick()
- case <-h.client.mCreateDebugBundle.ClickedCh:
- h.handleCreateDebugBundleClick()
- case <-h.client.mQuit.ClickedCh:
- h.handleQuitClick()
- return
- case <-h.client.mGitHub.ClickedCh:
- h.handleGitHubClick()
- case <-h.client.mUpdate.ClickedCh:
- h.handleUpdateClick()
- case <-h.client.mNetworks.ClickedCh:
- h.handleNetworksClick()
- case <-h.client.mNotifications.ClickedCh:
- h.handleNotificationsClick()
- case <-systray.TrayOpenedCh:
- h.client.updateExitNodes()
- }
- }
-}
-
-func (h *eventHandler) handleConnectClick() {
- h.client.mUp.Disable()
-
- if h.client.connectCancel != nil {
- h.client.connectCancel()
- }
-
- connectCtx, connectCancel := context.WithCancel(h.client.ctx)
- h.client.connectCancel = connectCancel
-
- go func() {
- defer connectCancel()
-
- if err := h.client.menuUpClick(connectCtx); err != nil {
- st, ok := status.FromError(err)
- if errors.Is(err, context.Canceled) || (ok && st.Code() == codes.Canceled) {
- log.Debugf("connect operation cancelled by user")
- } else {
- h.client.notifier.Send("Error", "Failed to connect")
- log.Errorf("connect failed: %v", err)
- }
- }
-
- if err := h.client.updateStatus(); err != nil {
- log.Debugf("failed to update status after connect: %v", err)
- }
- }()
-}
-
-func (h *eventHandler) handleDisconnectClick() {
- h.client.mDown.Disable()
- h.client.cancelExitNodeRetry()
-
- if h.client.connectCancel != nil {
- log.Debugf("cancelling ongoing connect operation")
- h.client.connectCancel()
- h.client.connectCancel = nil
- }
-
- go func() {
- if err := h.client.menuDownClick(); err != nil {
- st, ok := status.FromError(err)
- if !errors.Is(err, context.Canceled) && !(ok && st.Code() == codes.Canceled) {
- h.client.notifier.Send("Error", "Failed to disconnect")
- log.Errorf("disconnect failed: %v", err)
- } else {
- log.Debugf("disconnect cancelled or already disconnecting")
- }
- }
-
- if err := h.client.updateStatus(); err != nil {
- log.Debugf("failed to update status after disconnect: %v", err)
- }
- }()
-}
-
-func (h *eventHandler) handleAllowSSHClick() {
- h.toggleCheckbox(h.client.mAllowSSH)
- if err := h.updateConfigWithErr(); err != nil {
- h.toggleCheckbox(h.client.mAllowSSH) // revert checkbox state on error
- log.Errorf("failed to update config: %v", err)
- h.client.notifier.Send("Error", "Failed to update SSH settings")
- }
-
-}
-
-func (h *eventHandler) handleAutoConnectClick() {
- h.toggleCheckbox(h.client.mAutoConnect)
- if err := h.updateConfigWithErr(); err != nil {
- h.toggleCheckbox(h.client.mAutoConnect) // revert checkbox state on error
- log.Errorf("failed to update config: %v", err)
- h.client.notifier.Send("Error", "Failed to update auto-connect settings")
- }
-}
-
-func (h *eventHandler) handleRosenpassClick() {
- h.toggleCheckbox(h.client.mEnableRosenpass)
- if err := h.updateConfigWithErr(); err != nil {
- h.toggleCheckbox(h.client.mEnableRosenpass) // revert checkbox state on error
- log.Errorf("failed to update config: %v", err)
- h.client.notifier.Send("Error", "Failed to update Rosenpass settings")
- }
-}
-
-func (h *eventHandler) handleLazyConnectionClick() {
- h.toggleCheckbox(h.client.mLazyConnEnabled)
- if err := h.updateConfigWithErr(); err != nil {
- h.toggleCheckbox(h.client.mLazyConnEnabled) // revert checkbox state on error
- log.Errorf("failed to update config: %v", err)
- h.client.notifier.Send("Error", "Failed to update lazy connection settings")
- }
-}
-
-func (h *eventHandler) handleBlockInboundClick() {
- h.toggleCheckbox(h.client.mBlockInbound)
- if err := h.updateConfigWithErr(); err != nil {
- h.toggleCheckbox(h.client.mBlockInbound) // revert checkbox state on error
- log.Errorf("failed to update config: %v", err)
- h.client.notifier.Send("Error", "Failed to update block inbound settings")
- }
-}
-
-func (h *eventHandler) handleNotificationsClick() {
- h.toggleCheckbox(h.client.mNotifications)
- if err := h.updateConfigWithErr(); err != nil {
- h.toggleCheckbox(h.client.mNotifications) // revert checkbox state on error
- log.Errorf("failed to update config: %v", err)
- h.client.notifier.Send("Error", "Failed to update notifications settings")
- } else if h.client.eventManager != nil {
- h.client.eventManager.SetNotificationsEnabled(h.client.mNotifications.Checked())
- }
-
-}
-
-func (h *eventHandler) handleAdvancedSettingsClick() {
- h.client.mAdvancedSettings.Disable()
- go func() {
- defer h.client.mAdvancedSettings.Enable()
- defer h.client.getSrvConfig()
- h.runSelfCommand(h.client.ctx, "settings")
- }()
-}
-
-func (h *eventHandler) handleCreateDebugBundleClick() {
- h.client.mCreateDebugBundle.Disable()
- go func() {
- defer h.client.mCreateDebugBundle.Enable()
- h.runSelfCommand(h.client.ctx, "debug")
- }()
-}
-
-func (h *eventHandler) handleQuitClick() {
- systray.Quit()
-}
-
-func (h *eventHandler) handleGitHubClick() {
- if err := openURL("https://github.com/netbirdio/netbird"); err != nil {
- log.Errorf("failed to open GitHub URL: %v", err)
- }
-}
-
-func (h *eventHandler) handleUpdateClick() {
- h.client.updateIndicationLock.Lock()
- enforced := h.client.isEnforcedUpdate
- h.client.updateIndicationLock.Unlock()
-
- if !enforced {
- if err := openURL(version.DownloadUrl()); err != nil {
- log.Errorf("failed to open download URL: %v", err)
- }
- return
- }
-
- // prevent blocking against a busy server
- h.client.mUpdate.Disable()
- go func() {
- defer h.client.mUpdate.Enable()
- conn, err := h.client.getSrvClient(defaultFailTimeout)
- if err != nil {
- log.Errorf("failed to get service client for update: %v", err)
- _ = openURL(version.DownloadUrl())
- return
- }
-
- resp, err := conn.TriggerUpdate(h.client.ctx, &proto.TriggerUpdateRequest{})
- if err != nil {
- log.Errorf("TriggerUpdate failed: %v", err)
- _ = openURL(version.DownloadUrl())
- return
- }
- if !resp.Success {
- log.Errorf("TriggerUpdate failed: %s", resp.ErrorMsg)
- _ = openURL(version.DownloadUrl())
- return
- }
-
- log.Infof("update triggered via daemon")
- }()
-}
-
-func (h *eventHandler) handleNetworksClick() {
- h.client.mNetworks.Disable()
- go func() {
- defer h.client.mNetworks.Enable()
- h.runSelfCommand(h.client.ctx, "networks")
- }()
-}
-
-func (h *eventHandler) toggleCheckbox(item *systray.MenuItem) {
- if item.Checked() {
- item.Uncheck()
- } else {
- item.Check()
- }
-}
-
-func (h *eventHandler) updateConfigWithErr() error {
- if err := h.client.updateConfig(); err != nil {
- return err
- }
-
- return nil
-}
-
-func (h *eventHandler) runSelfCommand(ctx context.Context, command string, args ...string) {
- proc, err := os.Executable()
- if err != nil {
- log.Errorf("error getting executable path: %v", err)
- return
- }
-
- // Build the full command arguments
- cmdArgs := []string{
- fmt.Sprintf("--%s=true", command),
- fmt.Sprintf("--daemon-addr=%s", h.client.addr),
- }
- cmdArgs = append(cmdArgs, args...)
-
- cmd := exec.CommandContext(ctx, proc, cmdArgs...)
-
- if out := h.client.attachOutput(cmd); out != nil {
- defer func() {
- if err := out.Close(); err != nil {
- log.Errorf("error closing log file %s: %v", h.client.logFile, err)
- }
- }()
- }
-
- log.Printf("running command: %s", cmd.String())
-
- if err := cmd.Run(); err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
- log.Printf("command '%s' failed with exit code %d", cmd.String(), exitErr.ExitCode())
- }
- return
- }
-
- log.Printf("command '%s' completed successfully", cmd.String())
-}
-
-func (h *eventHandler) logout(ctx context.Context) error {
- client, err := h.client.getSrvClient(defaultFailTimeout)
- if err != nil {
- return fmt.Errorf("failed to get service client: %w", err)
- }
-
- _, err = client.Logout(ctx, &proto.LogoutRequest{})
- if err != nil {
- return fmt.Errorf("logout failed: %w", err)
- }
-
- h.client.getSrvConfig()
-
- return nil
-}
diff --git a/client/ui/font_bsd.go b/client/ui/font_bsd.go
deleted file mode 100644
index 139f38f40..000000000
--- a/client/ui/font_bsd.go
+++ /dev/null
@@ -1,30 +0,0 @@
-//go:build freebsd || openbsd || netbsd || dragonfly
-
-package main
-
-import (
- "os"
- "runtime"
-
- log "github.com/sirupsen/logrus"
-)
-
-func (s *serviceClient) setDefaultFonts() {
- paths := []string{
- "/usr/local/share/fonts/TTF/DejaVuSans.ttf",
- "/usr/local/share/fonts/dejavu/DejaVuSans.ttf",
- "/usr/local/share/noto/NotoSans-Regular.ttf",
- "/usr/local/share/fonts/noto/NotoSans-Regular.ttf",
- "/usr/local/share/fonts/liberation-fonts-ttf/LiberationSans-Regular.ttf",
- }
-
- for _, fontPath := range paths {
- if _, err := os.Stat(fontPath); err == nil {
- os.Setenv("FYNE_FONT", fontPath)
- log.Debugf("Using font: %s", fontPath)
- return
- }
- }
-
- log.Errorf("Failed to find any suitable font files for %s", runtime.GOOS)
-}
diff --git a/client/ui/font_darwin.go b/client/ui/font_darwin.go
deleted file mode 100644
index cafb72f59..000000000
--- a/client/ui/font_darwin.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package main
-
-import (
- "os"
-
- log "github.com/sirupsen/logrus"
-)
-
-const defaultFontPath = "/Library/Fonts/Arial Unicode.ttf"
-
-func (s *serviceClient) setDefaultFonts() {
- if _, err := os.Stat(defaultFontPath); err != nil {
- log.Errorf("Failed to find default font file: %v", err)
- return
- }
-
- os.Setenv("FYNE_FONT", defaultFontPath)
-}
diff --git a/client/ui/font_linux.go b/client/ui/font_linux.go
deleted file mode 100644
index 4aa92494a..000000000
--- a/client/ui/font_linux.go
+++ /dev/null
@@ -1,7 +0,0 @@
-//go:build !386
-
-package main
-
-func (s *serviceClient) setDefaultFonts() {
- //TODO: Linux Multiple Language Support
-}
diff --git a/client/ui/font_windows.go b/client/ui/font_windows.go
deleted file mode 100644
index 6346a9fb9..000000000
--- a/client/ui/font_windows.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package main
-
-import (
- "os"
- "path"
- "unsafe"
-
- log "github.com/sirupsen/logrus"
- "golang.org/x/sys/windows"
-)
-
-func (s *serviceClient) setDefaultFonts() {
- defaultFontPath := s.getWindowsFontFilePath()
-
- if _, err := os.Stat(defaultFontPath); err != nil {
- log.Errorf("Failed to find default font file: %v", err)
- return
- }
-
- os.Setenv("FYNE_FONT", defaultFontPath)
-}
-
-func (s *serviceClient) getWindowsFontFilePath() string {
- var (
- fontFolder = "C:/Windows/Fonts"
- fontMapping = map[string]string{
- "default": "Segoeui.ttf",
- "zh-CN": "Segoeui.ttf",
- "am-ET": "Ebrima.ttf",
- "nirmala": "Nirmala.ttf",
- "chr-CHER-US": "Gadugi.ttf",
- "zh-HK": "Segoeui.ttf",
- "zh-TW": "Segoeui.ttf",
- "km-KH": "Leelawui.ttf",
- "ko-KR": "Malgun.ttf",
- "th-TH": "Leelawui.ttf",
- "ti-ET": "Ebrima.ttf",
- }
- nirMalaLang = []string{
- "as-IN",
- "bn-BD",
- "bn-IN",
- "gu-IN",
- "hi-IN",
- "kn-IN",
- "kok-IN",
- "ml-IN",
- "mr-IN",
- "ne-NP",
- "or-IN",
- "pa-IN",
- "si-LK",
- "ta-IN",
- "te-IN",
- }
- )
-
- // getUserDefaultLocaleName.Call() panics if the func is not found
- defer func() {
- if r := recover(); r != nil {
- log.Errorf("Recovered from panic: %v", r)
- }
- }()
-
- kernel32 := windows.NewLazySystemDLL("kernel32.dll")
- getUserDefaultLocaleName := kernel32.NewProc("GetUserDefaultLocaleName")
-
- buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH is usually 85
- r, _, err := getUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
- // returns 0 on failure, err is always non-nil
- // https://learn.microsoft.com/en-us/windows/win32/api/winnls/nf-winnls-getuserdefaultlocalename
- if r == 0 {
- log.Errorf("GetUserDefaultLocaleName call failed: %v", err)
- return path.Join(fontFolder, fontMapping["default"])
- }
-
- defaultLanguage := windows.UTF16ToString(buf)
-
- for _, lang := range nirMalaLang {
- if defaultLanguage == lang {
- return path.Join(fontFolder, fontMapping["nirmala"])
- }
- }
-
- if font, ok := fontMapping[defaultLanguage]; ok {
- return path.Join(fontFolder, font)
- }
-
- return path.Join(fontFolder, fontMapping["default"])
-}
diff --git a/client/ui/frontend/.prettierignore b/client/ui/frontend/.prettierignore
new file mode 100644
index 000000000..c78cb7cc3
--- /dev/null
+++ b/client/ui/frontend/.prettierignore
@@ -0,0 +1,7 @@
+dist
+build
+node_modules
+pnpm-lock.yaml
+wailsjs
+*.min.js
+*.min.css
diff --git a/client/ui/frontend/.prettierrc b/client/ui/frontend/.prettierrc
new file mode 100644
index 000000000..e47a94f56
--- /dev/null
+++ b/client/ui/frontend/.prettierrc
@@ -0,0 +1,12 @@
+{
+ "tabWidth": 4,
+ "useTabs": false,
+ "semi": true,
+ "singleQuote": false,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "arrowParens": "always",
+ "endOfLine": "lf",
+ "plugins": ["prettier-plugin-tailwindcss"],
+ "tailwindFunctions": ["cn", "clsx", "cva", "tw"]
+}
diff --git a/client/ui/frontend/WAILS-API.md b/client/ui/frontend/WAILS-API.md
new file mode 100644
index 000000000..494812d35
--- /dev/null
+++ b/client/ui/frontend/WAILS-API.md
@@ -0,0 +1,296 @@
+# Wails Go API reference (frontend)
+
+Reference for every binding method and model shape exposed to the frontend. Generated from `client/ui/services/*.go` via `wails3 generate bindings -clean=true -ts` — regenerate after any Go-side change. Authoritative source is always `bindings/github.com/netbirdio/netbird/client/ui/services/*.ts`.
+
+Every method returns `$CancellablePromise` (a Wails3 wrapper around `Promise`). Call `.cancel()` to abort the underlying gRPC call; in practice we just `await` and let it run.
+
+## Imports
+
+```ts
+// Services
+import {
+ Connection, Peers, ProfileSwitcher, Profiles,
+ Settings, Networks, Forwarding, Debug, Update, WindowManager,
+ I18n, Preferences,
+} from "@bindings/services";
+
+// Models (types-only)
+import type {
+ Status, PeerStatus, PeerLink, LocalPeer, SystemEvent,
+ Profile, ProfileRef, ActiveProfile,
+ Config, ConfigParams, SetConfigParams, Features,
+ Network, SelectNetworksParams,
+ ForwardingRule, PortInfo, PortRange,
+ LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams,
+ DebugBundleParams, DebugBundleResult, LogLevel,
+ UpdateResult, UpdateAvailable, UpdateProgress,
+} from "@bindings/services/models.js";
+
+// i18n / preferences models live in sibling packages, not services/models
+import { LanguageCode, type Language } from "@bindings/i18n/models.js";
+import type { UIPreferences } from "@bindings/preferences/models.js";
+```
+
+## Push events
+
+Subscribe with `Events.On(name, handler)` from `@wailsio/runtime`. Handlers receive `{ data: }`.
+
+| Event | Payload | Fires on |
+|---|---|---|
+| `netbird:status` | `Status` | Daemon SubscribeStatus snapshot — connection-state change, peer-list change, address change, mgmt/signal flip. Synthetic `StatusDaemonUnavailable` is emitted when the gRPC socket is unreachable, and a synthetic `Connecting` is emitted at the start of an active profile switch. |
+| `netbird:event` | `SystemEvent` | One push per daemon SubscribeEvents item (DNS / network / authentication / connectivity / system). Used by the tray for OS toasts; the TS side reads events through `Status.events` instead. |
+| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version (fan-out of the `new_version_available` metadata key). |
+| `netbird:preferences:changed` | `{ language: string }` | Fires after every successful `Preferences.SetLanguage` (including the caller's own window). `src/lib/i18n.ts` subscribes and calls `i18next.changeLanguage`. |
+| `netbird:update:progress` | `UpdateProgress` | Daemon enforced-update install progress (`action: "show"` etc.). |
+| `browser-login:cancel` | (none) | Either the user closed the `BrowserLogin` window (Go-emitted) or the page's Cancel button (frontend-emitted). |
+| `trigger-login` | (none) | Reserved by the tray for asking the frontend to start an SSO flow. `layouts/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()`; no Go-side emitter today. |
+
+The two stream loops behind `netbird:status` and `netbird:event` start automatically — `main.go` calls `peers.Watch(context.Background())` at boot. `Peers.Watch` is still exported but the frontend doesn't need to invoke it.
+
+## `Connection`
+
+```ts
+Connection.Login(p: LoginParams): Promise
+Connection.WaitSSOLogin(p: WaitSSOParams): Promise // returns email
+Connection.Up(p: UpParams): Promise // async on the daemon
+Connection.Down(): Promise
+Connection.Logout(p: LogoutParams): Promise
+Connection.OpenURL(url: string): Promise // honors $BROWSER
+```
+
+`Login` Down-resets the daemon first to dislodge a stale `WaitSSOLogin` (so a previously abandoned SSO flow doesn't fail the next attempt). `Up` always uses async mode — status flows back through `netbird:status`. **Do not call `Up` on an `Idle` / `NeedsLogin` daemon** — the daemon's internal 50s `waitForUp` will block and return `DeadlineExceeded`.
+
+Full SSO sequence: `Login` → if `result.needsSsoLogin`, open `result.verificationUriComplete` via `OpenURL` + `WindowManager.OpenBrowserLogin(uri)` → `WaitSSOLogin({ userCode })` → `Up({})`. The canonical implementation is `startLogin()` in `layouts/ConnectionStatusSwitch.tsx`.
+
+## `Peers`
+
+```ts
+Peers.Get(): Promise // one-shot snapshot
+Peers.Watch(): Promise // already invoked from main.go
+Peers.BeginProfileSwitch(): Promise
+Peers.CancelProfileSwitch(): Promise
+```
+
+`BeginProfileSwitch` and `CancelProfileSwitch` are normally driven by `ProfileSwitcher` / the tray, not the frontend.
+
+## `ProfileSwitcher`
+
+```ts
+ProfileSwitcher.SwitchActive(p: ProfileRef): Promise
+```
+
+The single entry point both tray and frontend should use for profile flips. Applies the reconnect policy below, mirrors the switch into the user-side `profilemanager` (so the CLI's `netbird up` reads a consistent active profile), and drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`.
+
+Reconnect policy (driven by `prevStatus` captured at entry):
+
+| Previous status | Action | Optimistic UI | Suppressed events until new flow |
+|---|---|---|---|
+| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle |
+| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle |
+| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — |
+| Idle | Switch only | (no change) | — |
+
+## `Profiles`
+
+```ts
+Profiles.Username(): Promise // current OS username
+Profiles.List(username: string): Promise
+Profiles.GetActive(): Promise
+Profiles.Switch(p: ProfileRef): Promise // raw daemon RPC; prefer ProfileSwitcher.SwitchActive
+Profiles.Add(p: ProfileRef): Promise
+Profiles.Remove(p: ProfileRef): Promise
+```
+
+`Profile.email` is populated by the **UI process** reading the per-profile state file (`~/Library/Application Support/netbird/.state.json` on macOS), not by the daemon — the daemon runs as root and can't read user-owned files.
+
+## `Settings`
+
+```ts
+Settings.GetConfig(p: ConfigParams): Promise
+Settings.SetConfig(p: SetConfigParams): Promise // partial update
+Settings.GetFeatures(): Promise // operator-disabled UI sections
+```
+
+`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons.
+
+**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`.
+
+`SetConfigParams` carries one field that `Config` does not: `disableFirewall`. There's no current GET path for it.
+
+## `Networks`
+
+```ts
+Networks.List(): Promise
+Networks.Select(p: SelectNetworksParams): Promise
+Networks.Deselect(p: SelectNetworksParams): Promise
+```
+
+`SelectNetworksParams.append=true` merges into the existing selection; `false` replaces. `all=true` ignores `networkIds` and targets every network (Select-All / Deselect-All).
+
+Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side.
+
+## `Forwarding`
+
+```ts
+Forwarding.List(): Promise
+```
+
+`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`).
+
+## `Debug`
+
+```ts
+Debug.GetLogLevel(): Promise
+Debug.SetLogLevel(lvl: LogLevel): Promise
+Debug.Bundle(p: DebugBundleParams): Promise
+Debug.RevealFile(path: string): Promise // OS file-manager focus
+```
+
+**Log level case sensitivity bug:** `proto.LogLevel_value` is keyed on uppercase enum names (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`, `"UNKNOWN"`). `Debug.SetLogLevel` calls `proto.LogLevel_value[lvl.Level]` and falls back to `INFO` on miss. `useDebugBundle` currently passes `"trace"` (lowercase), which silently maps to `INFO` — the trace-capture flow doesn't actually raise the log level today. To raise to trace, pass `{ level: "TRACE" }`. Fix on the cleanup list.
+
+`Debug.Bundle` uploads when `uploadUrl != ""`. Result fields: `path` (local copy), `uploadedKey` (set on success), `uploadFailureReason` (set on upload failure — the local copy is still saved).
+
+## `Update`
+
+```ts
+Update.Trigger(): Promise // start the install
+Update.GetInstallerResult(): Promise // poll the outcome (long-running)
+Update.Quit(): Promise // 100ms later, app.Quit()
+```
+
+Typical enforced-update flow on the `/update` route: call `Trigger` once, then poll `GetInstallerResult` every 2s with a 15-minute total timeout. On `success: true` call `Quit`. On `success: false` show `errorMsg`. If the gRPC poll itself starts failing for `DAEMON_DOWN_GRACE_MS` (5s), treat that as success and quit too — the installer commonly takes the daemon offline mid-upgrade. See `pages/Update.tsx` for the canonical implementation.
+
+## `WindowManager`
+
+```ts
+WindowManager.OpenSettings(): Promise
+WindowManager.OpenBrowserLogin(uri: string): Promise // uri appended as ?uri=…
+WindowManager.CloseBrowserLogin(): Promise
+WindowManager.OpenError(title: string, message: string): Promise // custom branded error window; both query-escaped as ?title=…&message=…
+WindowManager.CloseError(): Promise
+```
+
+Prefer `errorDialog({Title, Message})` from `lib/dialogs.ts` over calling `OpenError` directly — it's the app's single error surface (the old native MessageBox wrapper now routes here). Both strings must be pre-localised.
+
+Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`.
+
+## `I18n`
+
+```ts
+I18n.Languages(): Promise // from _index.json
+I18n.Bundle(code: LanguageCode): Promise> // full key→text map
+```
+
+Source of truth is `client/ui/i18n/locales/` (shared with the Go tray). The frontend's i18next bootstrap doesn't need `I18n.Bundle` at runtime (bundles are statically imported by Vite via the glob in `src/lib/i18n.ts`), but the language picker reads `I18n.Languages()` so the list matches `_index.json` without duplicating it in TS.
+
+## `Preferences`
+
+```ts
+Preferences.Get(): Promise // { language: string }
+Preferences.SetLanguage(code: LanguageCode): Promise // rejects on unknown code
+```
+
+`SetLanguage` validates against the loaded `i18n.Bundle`, persists to `os.UserConfigDir()/netbird/ui-preferences.json`, and emits `netbird:preferences:changed`. The frontend's `src/lib/i18n.ts` listens to that event and calls `i18next.changeLanguage` so a flip in any window paints in all of them. Missing preferences file → defaults to `en`, written on first read.
+
+## Daemon `Status.status` values
+
+Mirror `internal.Status*` in `client/internal/state.go` plus the synthetic UI label:
+
+| Value | Meaning |
+|---|---|
+| `"Idle"` | Tunnel down (Up never invoked or Down completed) |
+| `"Connecting"` | Up in progress |
+| `"Connected"` | Tunnel up |
+| `"NeedsLogin"` | Fresh install or token cleared; needs Login → SSO → Up |
+| `"LoginFailed"` | Previous Login attempt errored |
+| `"SessionExpired"` | SSO token expired; needs re-Login |
+| `"DaemonUnavailable"` | **Synthetic** — UI side, emitted when the daemon gRPC socket is unreachable. Not a real daemon enum. |
+
+The tray also reads a tray-only synthetic `"Error"` for icon purposes; the frontend doesn't see that.
+
+## Model field reference
+
+`Status`:
+```ts
+{ status, daemonVersion: string;
+ management: PeerLink; signal: PeerLink;
+ local: LocalPeer;
+ peers: PeerStatus[];
+ events: SystemEvent[]; }
+```
+
+`PeerLink`: `{ url: string; connected: boolean; error?: string }`.
+
+`LocalPeer`: `{ ip, pubKey, fqdn: string; networks: string[] }`.
+
+`PeerStatus`:
+```ts
+{ ip, pubKey, fqdn, connStatus: string;
+ connStatusUpdateUnix: number;
+ relayed: boolean;
+ localIceCandidateType, remoteIceCandidateType: string; // pion: "host"|"srflx"|"prflx"|"relay"|""
+ localIceCandidateEndpoint, remoteIceCandidateEndpoint: string;
+ bytesRx, bytesTx, latencyMs, lastHandshakeUnix: number;
+ relayAddress: string; // set when relayed=true
+ rosenpassEnabled: boolean;
+ networks: string[]; }
+```
+
+`SystemEvent`:
+```ts
+{ id: string;
+ severity: string; // "info"|"warning"|"error"|"critical" (lowercased proto enum, "SystemEvent_" prefix stripped)
+ category: string; // "network"|"dns"|"authentication"|"connectivity"|"system" (same casing rules)
+ message: string; // technical / log line
+ userMessage: string; // human-friendly — render this
+ timestamp: number; // unix seconds
+ metadata: Record; } // keys: "new_version_available", "enforced", "id", "network", "version", "progress_window", …
+```
+
+`Profile`: `{ name: string; isActive: boolean; email: string }`.
+
+`Config` (read-only mirror, all required):
+```ts
+{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string;
+ wireguardPort, mtu, sshJwtCacheTtl: number;
+ disableAutoConnect, serverSshAllowed,
+ rosenpassEnabled, rosenpassPermissive,
+ disableNotifications, lazyConnectionEnabled, blockInbound,
+ networkMonitor, disableClientRoutes, disableServerRoutes,
+ disableDns, disableIpv6, blockLanAccess,
+ enableSshRoot, enableSshSftp,
+ enableSshLocalPortForwarding, enableSshRemotePortForwarding,
+ disableSshAuth: boolean; }
+```
+
+`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings.
+
+`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`.
+
+`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record }`.
+
+`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`.
+
+`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated).
+
+`PortRange`: `{ start, end: number }` (inclusive).
+
+`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`.
+
+`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`.
+
+`WaitSSOParams`: `{ userCode, hostname: string }`. Resolves to the user's email.
+
+`UpParams` / `LogoutParams` / `ProfileRef` / `ConfigParams` / `ActiveProfile`: all `{ profileName, username: string }` (different names but same shape — kept distinct by Wails for clarity).
+
+`DebugBundleParams`: `{ anonymize, systemInfo: boolean; uploadUrl: string; logFileCount: number }`.
+
+`DebugBundleResult`: `{ path, uploadedKey, uploadFailureReason: string }`.
+
+`LogLevel`: `{ level: string }` — **uppercase** proto enum name (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`).
+
+`UpdateResult`: `{ success: boolean; errorMsg: string }`.
+
+`UpdateAvailable`: `{ version: string; enforced: boolean }`.
+
+`UpdateProgress`: `{ action: string; version: string }`.
diff --git a/client/ui/frontend/eslint.config.js b/client/ui/frontend/eslint.config.js
new file mode 100644
index 000000000..f00623b68
--- /dev/null
+++ b/client/ui/frontend/eslint.config.js
@@ -0,0 +1,75 @@
+import js from "@eslint/js";
+import tseslint from "typescript-eslint";
+import react from "eslint-plugin-react";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import jsxA11y from "eslint-plugin-jsx-a11y";
+import globals from "globals";
+
+export default tseslint.config(
+ {
+ ignores: ["dist/**", "node_modules/**", "bindings/**", "sonar/**"],
+ },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ files: ["src/**/*.{ts,tsx}"],
+ plugins: {
+ react,
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ "jsx-a11y": jsxA11y,
+ },
+ languageOptions: {
+ ecmaVersion: 2022,
+ sourceType: "module",
+ globals: { ...globals.browser },
+ parserOptions: {
+ ecmaFeatures: { jsx: true },
+ },
+ },
+ settings: {
+ react: { version: "detect" },
+ },
+ rules: {
+ // ----- a11y / semantic HTML (jsx-a11y recommended) -----
+ ...jsxA11y.configs.recommended.rules,
+ "jsx-a11y/no-autofocus": ["warn", { ignoreNonDOM: true }],
+
+ // ----- React -----
+ ...react.configs.recommended.rules,
+ ...react.configs["jsx-runtime"].rules,
+ "react/prop-types": "off",
+ "react/jsx-no-target-blank": ["error", { allowReferrer: true }],
+ "react/self-closing-comp": "warn",
+
+ // ----- React hooks -----
+ "react-hooks/rules-of-hooks": "error",
+ "react-hooks/exhaustive-deps": "warn",
+
+ // ----- Vite / HMR (Fast Refresh) -----
+ "react-refresh/only-export-components": "off",
+
+ // ----- TypeScript -----
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ argsIgnorePattern: "^_",
+ varsIgnorePattern: "^_",
+ caughtErrorsIgnorePattern: "^_",
+ },
+ ],
+ "@typescript-eslint/consistent-type-imports": [
+ "warn",
+ { prefer: "type-imports", fixStyle: "inline-type-imports" },
+ ],
+ "@typescript-eslint/no-explicit-any": "warn",
+
+ // ----- General correctness -----
+ eqeqeq: ["error", "smart"],
+ "no-console": ["warn", { allow: ["warn", "error", "info"] }],
+ "no-debugger": "error",
+ "prefer-const": "warn",
+ },
+ },
+);
diff --git a/client/ui/frontend/index.html b/client/ui/frontend/index.html
new file mode 100644
index 000000000..e62139956
--- /dev/null
+++ b/client/ui/frontend/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ NetBird
+
+
+
+
+
+
+
diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json
new file mode 100644
index 000000000..dcef99ad3
--- /dev/null
+++ b/client/ui/frontend/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "netbird-ui",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build:dev": "tsc && vite build --minify false --mode development",
+ "build": "tsc && vite build --mode production",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "bindings": "cd .. && wails3 generate bindings -clean=true -ts",
+ "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
+ "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
+ "lint": "eslint \"src/**/*.{ts,tsx}\"",
+ "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
+ "check": "pnpm lint && pnpm typecheck && pnpm format:check",
+ "check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck",
+ "i18n:check": "node ../i18n/check-translations.mjs"
+ },
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-radio-group": "^1.3.8",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@radix-ui/react-visually-hidden": "^1.2.4",
+ "@wailsio/runtime": "latest",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "framer-motion": "^12.38.0",
+ "i18next": "^26.2.0",
+ "lucide-react": "^0.566.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-i18next": "^17.0.8",
+ "react-loading-skeleton": "^3.5.0",
+ "react-router-dom": "^7.1.3",
+ "react-virtuoso": "^4.12.5",
+ "tailwind-merge": "^2.6.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@types/node": "^25.6.0",
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.39.4",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.3",
+ "globals": "^17.6.0",
+ "postcss": "^8.5.1",
+ "prettier": "^3.8.3",
+ "prettier-plugin-tailwindcss": "^0.8.0",
+ "tailwindcss": "^3.4.17",
+ "tailwindcss-animate": "^1.0.7",
+ "typescript": "^5.7.3",
+ "typescript-eslint": "^8.61.1",
+ "vite": "^6.0.7"
+ },
+ "packageManager": "pnpm@11.4.0+sha512.f0febc7e37552ab485494a914241b338e0b3580b93d54ce31f00933015880863129038a1b4ae4e414a0ee63ac35bf21197e990172c4a68256450b5636310968f"
+}
diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml
new file mode 100644
index 000000000..b6b3dd336
--- /dev/null
+++ b/client/ui/frontend/pnpm-lock.yaml
@@ -0,0 +1,5240 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@radix-ui/react-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-dropdown-menu':
+ specifier: ^2.1.16
+ version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-label':
+ specifier: ^2.1.8
+ version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-popover':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-radio-group':
+ specifier: ^1.3.8
+ version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-scroll-area':
+ specifier: ^1.2.10
+ version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-switch':
+ specifier: ^1.2.6
+ version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-tabs':
+ specifier: ^1.1.13
+ version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-tooltip':
+ specifier: ^1.2.8
+ version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-visually-hidden':
+ specifier: ^1.2.4
+ version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wailsio/runtime':
+ specifier: latest
+ version: 3.0.0-alpha.79
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ cmdk:
+ specifier: ^1.1.1
+ version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ framer-motion:
+ specifier: ^12.38.0
+ version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ i18next:
+ specifier: ^26.2.0
+ version: 26.3.0(typescript@5.9.3)
+ lucide-react:
+ specifier: ^0.566.0
+ version: 0.566.0(react@18.3.1)
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ react-i18next:
+ specifier: ^17.0.8
+ version: 17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
+ react-loading-skeleton:
+ specifier: ^3.5.0
+ version: 3.5.0(react@18.3.1)
+ react-router-dom:
+ specifier: ^7.1.3
+ version: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react-virtuoso:
+ specifier: ^4.12.5
+ version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ tailwind-merge:
+ specifier: ^2.6.0
+ version: 2.6.1
+ devDependencies:
+ '@eslint/js':
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@9.39.4(jiti@1.21.7))
+ '@types/node':
+ specifier: ^25.6.0
+ version: 25.9.1
+ '@types/react':
+ specifier: ^18.3.18
+ version: 18.3.29
+ '@types/react-dom':
+ specifier: ^18.3.5
+ version: 18.3.7(@types/react@18.3.29)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7))
+ autoprefixer:
+ specifier: ^10.4.20
+ version: 10.5.0(postcss@8.5.15)
+ eslint:
+ specifier: ^9.39.4
+ version: 9.39.4(jiti@1.21.7)
+ eslint-plugin-jsx-a11y:
+ specifier: ^6.10.2
+ version: 6.10.2(eslint@9.39.4(jiti@1.21.7))
+ eslint-plugin-react:
+ specifier: ^7.37.5
+ version: 7.37.5(eslint@9.39.4(jiti@1.21.7))
+ eslint-plugin-react-hooks:
+ specifier: ^7.1.1
+ version: 7.1.1(eslint@9.39.4(jiti@1.21.7))
+ eslint-plugin-react-refresh:
+ specifier: ^0.5.3
+ version: 0.5.3(eslint@9.39.4(jiti@1.21.7))
+ globals:
+ specifier: ^17.6.0
+ version: 17.6.0
+ postcss:
+ specifier: ^8.5.1
+ version: 8.5.15
+ prettier:
+ specifier: ^3.8.3
+ version: 3.8.3
+ prettier-plugin-tailwindcss:
+ specifier: ^0.8.0
+ version: 0.8.0(prettier@3.8.3)
+ tailwindcss:
+ specifier: ^3.4.17
+ version: 3.4.19
+ tailwindcss-animate:
+ specifier: ^1.0.7
+ version: 1.0.7(tailwindcss@3.4.19)
+ typescript:
+ specifier: ^5.7.3
+ version: 5.9.3
+ typescript-eslint:
+ specifier: ^8.61.1
+ version: 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ vite:
+ specifier: ^6.0.7
+ version: 6.4.2(@types/node@25.9.1)(jiti@1.21.7)
+
+packages:
+
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.7':
+ resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.7':
+ resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7':
+ resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7':
+ resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.7':
+ resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.9.1':
+ resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.21.2':
+ resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.5':
+ resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@10.0.1':
+ resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ peerDependencies:
+ eslint: ^10.0.0
+ peerDependenciesMeta:
+ eslint:
+ optional: true
+
+ '@eslint/js@9.39.4':
+ resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.7':
+ resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@floating-ui/core@1.7.5':
+ resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
+
+ '@floating-ui/dom@1.7.6':
+ resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+
+ '@floating-ui/react-dom@2.1.8':
+ resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/utils@0.2.11':
+ resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+
+ '@humanfs/core@0.19.2':
+ resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.8':
+ resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/types@0.15.0':
+ resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@nodelib/fs.scandir@2.1.5':
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.stat@2.0.5':
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.walk@1.2.8':
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+
+ '@radix-ui/number@1.1.1':
+ resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
+
+ '@radix-ui/primitive@1.1.3':
+ resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+
+ '@radix-ui/react-arrow@1.1.7':
+ resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-collection@1.1.7':
+ resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-compose-refs@1.1.2':
+ resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-context@1.1.2':
+ resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-dialog@1.1.15':
+ resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-direction@1.1.1':
+ resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-dismissable-layer@1.1.11':
+ resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-dropdown-menu@2.1.16':
+ resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-focus-guards@1.1.3':
+ resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-focus-scope@1.1.7':
+ resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-id@1.1.1':
+ resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-label@2.1.8':
+ resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-menu@2.1.16':
+ resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-popover@1.1.15':
+ resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-popper@1.2.8':
+ resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-portal@1.1.9':
+ resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-presence@1.1.5':
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-primitive@2.1.3':
+ resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-primitive@2.1.4':
+ resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-radio-group@1.3.8':
+ resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-roving-focus@1.1.11':
+ resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-scroll-area@1.2.10':
+ resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.3':
+ resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.4':
+ resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-switch@1.2.6':
+ resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-tabs@1.1.13':
+ resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-tooltip@1.2.8':
+ resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-use-callback-ref@1.1.1':
+ resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-controllable-state@1.2.2':
+ resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-effect-event@0.0.2':
+ resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-escape-keydown@1.1.1':
+ resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-layout-effect@1.1.1':
+ resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-previous@1.1.1':
+ resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-rect@1.1.1':
+ resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-size@1.1.1':
+ resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-visually-hidden@1.2.3':
+ resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-visually-hidden@1.2.4':
+ resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/rect@1.1.1':
+ resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+
+ '@rolldown/pluginutils@1.0.0-beta.27':
+ resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+ '@rollup/rollup-android-arm-eabi@4.60.4':
+ resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.60.4':
+ resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.60.4':
+ resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.60.4':
+ resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.60.4':
+ resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.60.4':
+ resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.4':
+ resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.4':
+ resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.4':
+ resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.60.4':
+ resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.4':
+ resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.60.4':
+ resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.4':
+ resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.4':
+ resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.4':
+ resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.4':
+ resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.4':
+ resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.60.4':
+ resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.60.4':
+ resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.60.4':
+ resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.60.4':
+ resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.4':
+ resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.4':
+ resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.60.4':
+ resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.60.4':
+ resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/node@25.9.1':
+ resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
+
+ '@types/prop-types@15.7.15':
+ resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
+
+ '@types/react-dom@18.3.7':
+ resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
+ peerDependencies:
+ '@types/react': ^18.0.0
+
+ '@types/react@18.3.29':
+ resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==}
+
+ '@typescript-eslint/eslint-plugin@8.61.1':
+ resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.61.1
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/parser@8.61.1':
+ resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/project-service@8.61.1':
+ resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/scope-manager@8.61.1':
+ resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.61.1':
+ resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/type-utils@8.61.1':
+ resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/types@8.61.1':
+ resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.61.1':
+ resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.61.1':
+ resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/visitor-keys@8.61.1':
+ resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@vitejs/plugin-react@4.7.0':
+ resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ '@wailsio/runtime@3.0.0-alpha.79':
+ resolution: {integrity: sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==}
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.17.0:
+ resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv@6.15.0:
+ resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ any-promise@1.3.0:
+ resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
+ anymatch@3.1.3:
+ resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+ engines: {node: '>= 8'}
+
+ arg@5.0.2:
+ resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ aria-hidden@1.2.6:
+ resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
+ engines: {node: '>=10'}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ array-buffer-byte-length@1.0.2:
+ resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
+ engines: {node: '>= 0.4'}
+
+ array-includes@3.1.9:
+ resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.findlast@1.2.5:
+ resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flat@1.3.3:
+ resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flatmap@1.3.3:
+ resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.tosorted@1.1.4:
+ resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
+ engines: {node: '>= 0.4'}
+
+ arraybuffer.prototype.slice@1.0.4:
+ resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
+ engines: {node: '>= 0.4'}
+
+ ast-types-flow@0.0.8:
+ resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
+
+ async-function@1.0.0:
+ resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
+ engines: {node: '>= 0.4'}
+
+ autoprefixer@10.5.0:
+ resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+
+ available-typed-arrays@1.0.7:
+ resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+ engines: {node: '>= 0.4'}
+
+ axe-core@4.12.1:
+ resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
+ engines: {node: '>=4'}
+
+ axobject-query@4.1.0:
+ resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
+ engines: {node: '>= 0.4'}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ baseline-browser-mapping@2.10.32:
+ resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ binary-extensions@2.3.0:
+ resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+ engines: {node: '>=8'}
+
+ brace-expansion@1.1.15:
+ resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
+
+ brace-expansion@5.0.6:
+ resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
+ engines: {node: 18 || 20 || >=22}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bind@1.0.9:
+ resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ camelcase-css@2.0.1:
+ resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+ engines: {node: '>= 6'}
+
+ caniuse-lite@1.0.30001793:
+ resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ chokidar@3.6.0:
+ resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+ engines: {node: '>= 8.10.0'}
+
+ class-variance-authority@0.7.1:
+ resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ cmdk@1.1.1:
+ resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
+ peerDependencies:
+ react: ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^18 || ^19 || ^19.0.0-rc
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ commander@4.1.1:
+ resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
+ engines: {node: '>= 6'}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ damerau-levenshtein@1.0.8:
+ resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
+
+ data-view-buffer@1.0.2:
+ resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-length@1.0.2:
+ resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-offset@1.0.1:
+ resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
+ engines: {node: '>= 0.4'}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
+
+ define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+
+ detect-node-es@1.1.0:
+ resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+
+ didyoumean@1.2.2:
+ resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+
+ dlv@1.1.3:
+ resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+
+ doctrine@2.1.0:
+ resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
+ engines: {node: '>=0.10.0'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ electron-to-chromium@1.5.362:
+ resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==}
+
+ emoji-regex@9.2.2:
+ resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+
+ es-abstract-get@1.0.0:
+ resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
+ engines: {node: '>= 0.4'}
+
+ es-abstract@1.24.2:
+ resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
+ engines: {node: '>= 0.4'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-iterator-helpers@1.3.3:
+ resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ es-shim-unscopables@1.1.0:
+ resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
+ engines: {node: '>= 0.4'}
+
+ es-to-primitive@1.3.1:
+ resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==}
+ engines: {node: '>= 0.4'}
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-plugin-jsx-a11y@6.10.2:
+ resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
+
+ eslint-plugin-react-hooks@7.1.1:
+ resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
+
+ eslint-plugin-react-refresh@0.5.3:
+ resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==}
+ peerDependencies:
+ eslint: ^9 || ^10
+
+ eslint-plugin-react@7.37.5:
+ resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@5.0.1:
+ resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ eslint@9.39.4:
+ resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-glob@3.3.3:
+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+ engines: {node: '>=8.6.0'}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fastq@1.20.1:
+ resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.4.2:
+ resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+
+ for-each@0.3.5:
+ resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+ engines: {node: '>= 0.4'}
+
+ fraction.js@5.3.4:
+ resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
+ framer-motion@12.40.0:
+ resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ function.prototype.name@1.2.0:
+ resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
+ engines: {node: '>= 0.4'}
+
+ functions-have-names@1.2.3:
+ resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+
+ generator-function@2.0.1:
+ resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
+ engines: {node: '>= 0.4'}
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-nonce@1.0.1:
+ resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
+ engines: {node: '>=6'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ get-symbol-description@1.1.0:
+ resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
+ engines: {node: '>= 0.4'}
+
+ glob-parent@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ globals@17.6.0:
+ resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
+ engines: {node: '>=18'}
+
+ globalthis@1.0.4:
+ resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ has-bigints@1.1.0:
+ resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
+ engines: {node: '>= 0.4'}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
+ has-proto@1.2.0:
+ resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
+ engines: {node: '>= 0.4'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.3:
+ resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ hermes-estree@0.25.1:
+ resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
+
+ hermes-parser@0.25.1:
+ resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+
+ html-parse-stringify@3.0.1:
+ resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+
+ i18next@26.3.0:
+ resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==}
+ peerDependencies:
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.5:
+ resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ internal-slot@1.1.0:
+ resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
+ engines: {node: '>= 0.4'}
+
+ is-array-buffer@3.0.5:
+ resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
+ engines: {node: '>= 0.4'}
+
+ is-async-function@2.1.1:
+ resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
+ engines: {node: '>= 0.4'}
+
+ is-bigint@1.1.0:
+ resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
+ engines: {node: '>= 0.4'}
+
+ is-binary-path@2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+
+ is-boolean-object@1.2.2:
+ resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
+ engines: {node: '>= 0.4'}
+
+ is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
+
+ is-core-module@2.16.2:
+ resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
+ engines: {node: '>= 0.4'}
+
+ is-data-view@1.0.2:
+ resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
+ engines: {node: '>= 0.4'}
+
+ is-date-object@1.1.0:
+ resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
+ engines: {node: '>= 0.4'}
+
+ is-document.all@1.0.0:
+ resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
+ engines: {node: '>= 0.4'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-finalizationregistry@1.1.1:
+ resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
+ engines: {node: '>= 0.4'}
+
+ is-generator-function@1.1.2:
+ resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
+ engines: {node: '>= 0.4'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-map@2.0.3:
+ resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
+ engines: {node: '>= 0.4'}
+
+ is-negative-zero@2.0.3:
+ resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
+ engines: {node: '>= 0.4'}
+
+ is-number-object@1.1.1:
+ resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
+ engines: {node: '>= 0.4'}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ is-regex@1.2.1:
+ resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+ engines: {node: '>= 0.4'}
+
+ is-set@2.0.3:
+ resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
+ engines: {node: '>= 0.4'}
+
+ is-shared-array-buffer@1.0.4:
+ resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
+ engines: {node: '>= 0.4'}
+
+ is-string@1.1.1:
+ resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
+ engines: {node: '>= 0.4'}
+
+ is-symbol@1.1.1:
+ resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
+ engines: {node: '>= 0.4'}
+
+ is-typed-array@1.1.15:
+ resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+ engines: {node: '>= 0.4'}
+
+ is-weakmap@2.0.2:
+ resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
+ engines: {node: '>= 0.4'}
+
+ is-weakref@1.1.1:
+ resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
+ engines: {node: '>= 0.4'}
+
+ is-weakset@2.0.4:
+ resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
+ engines: {node: '>= 0.4'}
+
+ isarray@2.0.5:
+ resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ iterator.prototype@1.1.5:
+ resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
+ engines: {node: '>= 0.4'}
+
+ jiti@1.21.7:
+ resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
+ hasBin: true
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ js-yaml@4.2.0:
+ resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
+ hasBin: true
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ jsx-ast-utils@3.3.5:
+ resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
+ engines: {node: '>=4.0'}
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ language-subtag-registry@0.3.23:
+ resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
+
+ language-tags@1.0.9:
+ resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
+ engines: {node: '>=0.10'}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ lilconfig@3.1.3:
+ resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
+ engines: {node: '>=14'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ lucide-react@0.566.0:
+ resolution: {integrity: sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ merge2@1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ minimatch@10.2.5:
+ resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
+ engines: {node: 18 || 20 || >=22}
+
+ minimatch@3.1.5:
+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
+ motion-dom@12.40.0:
+ resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==}
+
+ motion-utils@12.39.0:
+ resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ mz@2.7.0:
+ resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
+ nanoid@3.3.12:
+ resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ node-exports-info@1.6.0:
+ resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
+ engines: {node: '>= 0.4'}
+
+ node-releases@2.0.46:
+ resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
+ engines: {node: '>=18'}
+
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ object-keys@1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+
+ object.assign@4.1.7:
+ resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
+ engines: {node: '>= 0.4'}
+
+ object.entries@1.1.9:
+ resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
+ engines: {node: '>= 0.4'}
+
+ object.fromentries@2.0.8:
+ resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
+ engines: {node: '>= 0.4'}
+
+ object.values@1.2.1:
+ resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
+ engines: {node: '>= 0.4'}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ own-keys@1.0.1:
+ resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
+ engines: {node: '>= 0.4'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
+ pify@2.3.0:
+ resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
+ engines: {node: '>=0.10.0'}
+
+ pirates@4.0.7:
+ resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
+ engines: {node: '>= 6'}
+
+ possible-typed-array-names@1.1.0:
+ resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+ engines: {node: '>= 0.4'}
+
+ postcss-import@15.1.0:
+ resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ postcss: ^8.0.0
+
+ postcss-js@4.1.0:
+ resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
+ engines: {node: ^12 || ^14 || >= 16}
+ peerDependencies:
+ postcss: ^8.4.21
+
+ postcss-load-config@6.0.1:
+ resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ jiti: '>=1.21.0'
+ postcss: '>=8.0.9'
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+ postcss:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ postcss-nested@6.2.0:
+ resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+
+ postcss-selector-parser@6.1.2:
+ resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
+ engines: {node: '>=4'}
+
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
+ postcss@8.5.15:
+ resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ prettier-plugin-tailwindcss@0.8.0:
+ resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
+ engines: {node: '>=20.19'}
+ peerDependencies:
+ '@ianvs/prettier-plugin-sort-imports': '*'
+ '@prettier/plugin-hermes': '*'
+ '@prettier/plugin-oxc': '*'
+ '@prettier/plugin-pug': '*'
+ '@shopify/prettier-plugin-liquid': '*'
+ '@trivago/prettier-plugin-sort-imports': '*'
+ '@zackad/prettier-plugin-twig': '*'
+ prettier: ^3.0
+ prettier-plugin-astro: '*'
+ prettier-plugin-css-order: '*'
+ prettier-plugin-jsdoc: '*'
+ prettier-plugin-marko: '*'
+ prettier-plugin-multiline-arrays: '*'
+ prettier-plugin-organize-attributes: '*'
+ prettier-plugin-organize-imports: '*'
+ prettier-plugin-sort-imports: '*'
+ prettier-plugin-svelte: '*'
+ peerDependenciesMeta:
+ '@ianvs/prettier-plugin-sort-imports':
+ optional: true
+ '@prettier/plugin-hermes':
+ optional: true
+ '@prettier/plugin-oxc':
+ optional: true
+ '@prettier/plugin-pug':
+ optional: true
+ '@shopify/prettier-plugin-liquid':
+ optional: true
+ '@trivago/prettier-plugin-sort-imports':
+ optional: true
+ '@zackad/prettier-plugin-twig':
+ optional: true
+ prettier-plugin-astro:
+ optional: true
+ prettier-plugin-css-order:
+ optional: true
+ prettier-plugin-jsdoc:
+ optional: true
+ prettier-plugin-marko:
+ optional: true
+ prettier-plugin-multiline-arrays:
+ optional: true
+ prettier-plugin-organize-attributes:
+ optional: true
+ prettier-plugin-organize-imports:
+ optional: true
+ prettier-plugin-sort-imports:
+ optional: true
+ prettier-plugin-svelte:
+ optional: true
+
+ prettier@3.8.3:
+ resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
+ engines: {node: '>=14'}
+ hasBin: true
+
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ queue-microtask@1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+ react-dom@18.3.1:
+ resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
+ peerDependencies:
+ react: ^18.3.1
+
+ react-i18next@17.0.8:
+ resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
+ peerDependencies:
+ i18next: '>= 26.2.0'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
+ react-loading-skeleton@3.5.0:
+ resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ react-refresh@0.17.0:
+ resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+ engines: {node: '>=0.10.0'}
+
+ react-remove-scroll-bar@2.3.8:
+ resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-remove-scroll@2.7.2:
+ resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-router-dom@7.15.1:
+ resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ react-router@7.15.1:
+ resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react-style-singleton@2.2.3:
+ resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-virtuoso@4.18.7:
+ resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
+ peerDependencies:
+ react: '>=16 || >=17 || >= 18 || >= 19'
+ react-dom: '>=16 || >=17 || >= 18 || >=19'
+
+ react@18.3.1:
+ resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
+ engines: {node: '>=0.10.0'}
+
+ read-cache@1.0.0:
+ resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
+
+ readdirp@3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+
+ reflect.getprototypeof@1.0.10:
+ resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
+ engines: {node: '>= 0.4'}
+
+ regexp.prototype.flags@1.5.4:
+ resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
+ engines: {node: '>= 0.4'}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ resolve@1.22.12:
+ resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ resolve@2.0.0-next.7:
+ resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ reusify@1.1.0:
+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+ rollup@4.60.4:
+ resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ run-parallel@1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+ safe-array-concat@1.1.4:
+ resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
+ engines: {node: '>=0.4'}
+
+ safe-push-apply@1.0.0:
+ resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
+ engines: {node: '>= 0.4'}
+
+ safe-regex-test@1.1.0:
+ resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+ engines: {node: '>= 0.4'}
+
+ scheduler@0.23.2:
+ resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.8.4:
+ resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ set-function-length@1.2.2:
+ resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+ engines: {node: '>= 0.4'}
+
+ set-function-name@2.0.2:
+ resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
+ engines: {node: '>= 0.4'}
+
+ set-proto@1.0.0:
+ resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
+ engines: {node: '>= 0.4'}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ stop-iteration-iterator@1.1.0:
+ resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.includes@2.0.1:
+ resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.matchall@4.0.12:
+ resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.repeat@1.0.0:
+ resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
+
+ string.prototype.trim@1.2.11:
+ resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimend@1.0.10:
+ resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimstart@1.0.8:
+ resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
+ engines: {node: '>= 0.4'}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ sucrase@3.35.1:
+ resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ hasBin: true
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ tailwind-merge@2.6.1:
+ resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
+
+ tailwindcss-animate@1.0.7:
+ resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
+ peerDependencies:
+ tailwindcss: '>=3.0.0 || insiders'
+
+ tailwindcss@3.4.19:
+ resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
+ thenify-all@1.6.0:
+ resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+ engines: {node: '>=0.8'}
+
+ thenify@3.3.1:
+ resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
+ engines: {node: '>=12.0.0'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ ts-interface-checker@0.1.13:
+ resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ typed-array-buffer@1.0.3:
+ resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-length@1.0.3:
+ resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-offset@1.0.4:
+ resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-length@1.0.8:
+ resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
+ engines: {node: '>= 0.4'}
+
+ typescript-eslint@8.61.1:
+ resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ unbox-primitive@1.1.0:
+ resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
+ engines: {node: '>= 0.4'}
+
+ undici-types@7.24.6:
+ resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ use-callback-ref@1.3.3:
+ resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-sidecar@1.1.3:
+ resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ vite@6.4.2:
+ resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ void-elements@3.1.0:
+ resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+ engines: {node: '>=0.10.0'}
+
+ which-boxed-primitive@1.1.1:
+ resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
+ engines: {node: '>= 0.4'}
+
+ which-builtin-type@1.2.1:
+ resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
+ engines: {node: '>= 0.4'}
+
+ which-collection@1.0.2:
+ resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
+ engines: {node: '>= 0.4'}
+
+ which-typed-array@1.1.22:
+ resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
+ engines: {node: '>= 0.4'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ zod-validation-error@4.0.2:
+ resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
+snapshots:
+
+ '@alloc/quick-lru@5.2.0': {}
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-compilation-targets': 7.29.7
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helpers': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.7':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.28.2
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.29.7': {}
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.7':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/traverse@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
+ dependencies:
+ eslint: 9.39.4(jiti@1.21.7)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.21.2':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3
+ minimatch: 3.1.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.5':
+ dependencies:
+ ajv: 6.15.0
+ debug: 4.4.3
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.2.0
+ minimatch: 3.1.5
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@10.0.1(eslint@9.39.4(jiti@1.21.7))':
+ optionalDependencies:
+ eslint: 9.39.4(jiti@1.21.7)
+
+ '@eslint/js@9.39.4': {}
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@floating-ui/core@1.7.5':
+ dependencies:
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/dom@1.7.6':
+ dependencies:
+ '@floating-ui/core': 1.7.5
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@floating-ui/utils@0.2.11': {}
+
+ '@humanfs/core@0.19.2':
+ dependencies:
+ '@humanfs/types': 0.15.0
+
+ '@humanfs/node@0.16.8':
+ dependencies:
+ '@humanfs/core': 0.19.2
+ '@humanfs/types': 0.15.0
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanfs/types@0.15.0': {}
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@nodelib/fs.scandir@2.1.5':
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+
+ '@nodelib/fs.stat@2.0.5': {}
+
+ '@nodelib/fs.walk@1.2.8':
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.20.1
+
+ '@radix-ui/number@1.1.1': {}
+
+ '@radix-ui/primitive@1.1.3': {}
+
+ '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-context@1.1.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ aria-hidden: 1.2.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-direction@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-id@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ aria-hidden: 1.2.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ aria-hidden: 1.2.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/rect': 1.1.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.4(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-slot@1.2.3(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-slot@1.2.4(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/rect': 1.1.1
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-size@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/rect@1.1.1': {}
+
+ '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+ '@rollup/rollup-android-arm-eabi@4.60.4':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.60.4':
+ optional: true
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/estree@1.0.8': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/node@25.9.1':
+ dependencies:
+ undici-types: 7.24.6
+
+ '@types/prop-types@15.7.15': {}
+
+ '@types/react-dom@18.3.7(@types/react@18.3.29)':
+ dependencies:
+ '@types/react': 18.3.29
+
+ '@types/react@18.3.29':
+ dependencies:
+ '@types/prop-types': 15.7.15
+ csstype: 3.2.3
+
+ '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.61.1
+ '@typescript-eslint/type-utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.61.1
+ eslint: 9.39.4(jiti@1.21.7)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.61.1
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.61.1
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.61.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.61.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.61.1':
+ dependencies:
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/visitor-keys': 8.61.1
+
+ '@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@1.21.7)
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.61.1': {}
+
+ '@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/visitor-keys': 8.61.1
+ debug: 4.4.3
+ minimatch: 10.2.5
+ semver: 7.8.4
+ tinyglobby: 0.2.16
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
+ '@typescript-eslint/scope-manager': 8.61.1
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
+ eslint: 9.39.4(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.61.1':
+ dependencies:
+ '@typescript-eslint/types': 8.61.1
+ eslint-visitor-keys: 5.0.1
+
+ '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7))':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
+ '@rolldown/pluginutils': 1.0.0-beta.27
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.17.0
+ vite: 6.4.2(@types/node@25.9.1)(jiti@1.21.7)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@wailsio/runtime@3.0.0-alpha.79': {}
+
+ acorn-jsx@5.3.2(acorn@8.17.0):
+ dependencies:
+ acorn: 8.17.0
+
+ acorn@8.17.0: {}
+
+ ajv@6.15.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ any-promise@1.3.0: {}
+
+ anymatch@3.1.3:
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.2
+
+ arg@5.0.2: {}
+
+ argparse@2.0.1: {}
+
+ aria-hidden@1.2.6:
+ dependencies:
+ tslib: 2.8.1
+
+ aria-query@5.3.2: {}
+
+ array-buffer-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ is-array-buffer: 3.0.5
+
+ array-includes@3.1.9:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ is-string: 1.1.1
+ math-intrinsics: 1.1.0
+
+ array.prototype.findlast@1.2.5:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flat@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flatmap@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.tosorted@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-shim-unscopables: 1.1.0
+
+ arraybuffer.prototype.slice@1.0.4:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ is-array-buffer: 3.0.5
+
+ ast-types-flow@0.0.8: {}
+
+ async-function@1.0.0: {}
+
+ autoprefixer@10.5.0(postcss@8.5.15):
+ dependencies:
+ browserslist: 4.28.2
+ caniuse-lite: 1.0.30001793
+ fraction.js: 5.3.4
+ picocolors: 1.1.1
+ postcss: 8.5.15
+ postcss-value-parser: 4.2.0
+
+ available-typed-arrays@1.0.7:
+ dependencies:
+ possible-typed-array-names: 1.1.0
+
+ axe-core@4.12.1: {}
+
+ axobject-query@4.1.0: {}
+
+ balanced-match@1.0.2: {}
+
+ balanced-match@4.0.4: {}
+
+ baseline-browser-mapping@2.10.32: {}
+
+ binary-extensions@2.3.0: {}
+
+ brace-expansion@1.1.15:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@5.0.6:
+ dependencies:
+ balanced-match: 4.0.4
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ browserslist@4.28.2:
+ dependencies:
+ baseline-browser-mapping: 2.10.32
+ caniuse-lite: 1.0.30001793
+ electron-to-chromium: 1.5.362
+ node-releases: 2.0.46
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bind@1.0.9:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ get-intrinsic: 1.3.0
+ set-function-length: 1.2.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ callsites@3.1.0: {}
+
+ camelcase-css@2.0.1: {}
+
+ caniuse-lite@1.0.30001793: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ chokidar@3.6.0:
+ dependencies:
+ anymatch: 3.1.3
+ braces: 3.0.3
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ class-variance-authority@0.7.1:
+ dependencies:
+ clsx: 2.1.1
+
+ clsx@2.1.1: {}
+
+ cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ commander@4.1.1: {}
+
+ concat-map@0.0.1: {}
+
+ convert-source-map@2.0.0: {}
+
+ cookie@1.1.1: {}
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ cssesc@3.0.0: {}
+
+ csstype@3.2.3: {}
+
+ damerau-levenshtein@1.0.8: {}
+
+ data-view-buffer@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-offset@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deep-is@0.1.4: {}
+
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ define-properties@1.2.1:
+ dependencies:
+ define-data-property: 1.1.4
+ has-property-descriptors: 1.0.2
+ object-keys: 1.1.1
+
+ detect-node-es@1.1.0: {}
+
+ didyoumean@1.2.2: {}
+
+ dlv@1.1.3: {}
+
+ doctrine@2.1.0:
+ dependencies:
+ esutils: 2.0.3
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ electron-to-chromium@1.5.362: {}
+
+ emoji-regex@9.2.2: {}
+
+ es-abstract-get@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ is-callable: 1.2.7
+ object-inspect: 1.13.4
+
+ es-abstract@1.24.2:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ arraybuffer.prototype.slice: 1.0.4
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ data-view-buffer: 1.0.2
+ data-view-byte-length: 1.0.2
+ data-view-byte-offset: 1.0.1
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-set-tostringtag: 2.1.0
+ es-to-primitive: 1.3.1
+ function.prototype.name: 1.2.0
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ get-symbol-description: 1.1.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.3
+ internal-slot: 1.1.0
+ is-array-buffer: 3.0.5
+ is-callable: 1.2.7
+ is-data-view: 1.0.2
+ is-negative-zero: 2.0.3
+ is-regex: 1.2.1
+ is-set: 2.0.3
+ is-shared-array-buffer: 1.0.4
+ is-string: 1.1.1
+ is-typed-array: 1.1.15
+ is-weakref: 1.1.1
+ math-intrinsics: 1.1.0
+ object-inspect: 1.13.4
+ object-keys: 1.1.1
+ object.assign: 4.1.7
+ own-keys: 1.0.1
+ regexp.prototype.flags: 1.5.4
+ safe-array-concat: 1.1.4
+ safe-push-apply: 1.0.0
+ safe-regex-test: 1.1.0
+ set-proto: 1.0.0
+ stop-iteration-iterator: 1.1.0
+ string.prototype.trim: 1.2.11
+ string.prototype.trimend: 1.0.10
+ string.prototype.trimstart: 1.0.8
+ typed-array-buffer: 1.0.3
+ typed-array-byte-length: 1.0.3
+ typed-array-byte-offset: 1.0.4
+ typed-array-length: 1.0.8
+ unbox-primitive: 1.1.0
+ which-typed-array: 1.1.22
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-iterator-helpers@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-set-tostringtag: 2.1.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ internal-slot: 1.1.0
+ iterator.prototype: 1.1.5
+ math-intrinsics: 1.1.0
+
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.3
+
+ es-shim-unscopables@1.1.0:
+ dependencies:
+ hasown: 2.0.3
+
+ es-to-primitive@1.3.1:
+ dependencies:
+ es-abstract-get: 1.0.0
+ es-errors: 1.3.0
+ is-callable: 1.2.7
+ is-date-object: 1.1.0
+ is-symbol: 1.1.1
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
+ escalade@3.2.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)):
+ dependencies:
+ aria-query: 5.3.2
+ array-includes: 3.1.9
+ array.prototype.flatmap: 1.3.3
+ ast-types-flow: 0.0.8
+ axe-core: 4.12.1
+ axobject-query: 4.1.0
+ damerau-levenshtein: 1.0.8
+ emoji-regex: 9.2.2
+ eslint: 9.39.4(jiti@1.21.7)
+ hasown: 2.0.3
+ jsx-ast-utils: 3.3.5
+ language-tags: 1.0.9
+ minimatch: 3.1.5
+ object.fromentries: 2.0.8
+ safe-regex-test: 1.1.0
+ string.prototype.includes: 2.0.1
+
+ eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@1.21.7)):
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/parser': 7.29.7
+ eslint: 9.39.4(jiti@1.21.7)
+ hermes-parser: 0.25.1
+ zod: 4.4.3
+ zod-validation-error: 4.0.2(zod@4.4.3)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-react-refresh@0.5.3(eslint@9.39.4(jiti@1.21.7)):
+ dependencies:
+ eslint: 9.39.4(jiti@1.21.7)
+
+ eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)):
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.findlast: 1.2.5
+ array.prototype.flatmap: 1.3.3
+ array.prototype.tosorted: 1.1.4
+ doctrine: 2.1.0
+ es-iterator-helpers: 1.3.3
+ eslint: 9.39.4(jiti@1.21.7)
+ estraverse: 5.3.0
+ hasown: 2.0.3
+ jsx-ast-utils: 3.3.5
+ minimatch: 3.1.5
+ object.entries: 1.1.9
+ object.fromentries: 2.0.8
+ object.values: 1.2.1
+ prop-types: 15.8.1
+ resolve: 2.0.0-next.7
+ semver: 6.3.1
+ string.prototype.matchall: 4.0.12
+ string.prototype.repeat: 1.0.0
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint-visitor-keys@5.0.1: {}
+
+ eslint@9.39.4(jiti@1.21.7):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.2
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.5
+ '@eslint/js': 9.39.4
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.8
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.15.0
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.5
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 1.21.7
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.17.0
+ acorn-jsx: 5.3.2(acorn@8.17.0)
+ eslint-visitor-keys: 4.2.1
+
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ esutils@2.0.3: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-glob@3.3.3:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fastq@1.20.1:
+ dependencies:
+ reusify: 1.1.0
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.4.2
+ keyv: 4.5.4
+
+ flatted@3.4.2: {}
+
+ for-each@0.3.5:
+ dependencies:
+ is-callable: 1.2.7
+
+ fraction.js@5.3.4: {}
+
+ framer-motion@12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ motion-dom: 12.40.0
+ motion-utils: 12.39.0
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ function.prototype.name@1.2.0:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+ hasown: 2.0.4
+ is-callable: 1.2.7
+ is-document.all: 1.0.0
+
+ functions-have-names@1.2.3: {}
+
+ generator-function@2.0.1: {}
+
+ gensync@1.0.0-beta.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.3
+ math-intrinsics: 1.1.0
+
+ get-nonce@1.0.1: {}
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
+ get-symbol-description@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+
+ glob-parent@5.1.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@14.0.0: {}
+
+ globals@17.6.0: {}
+
+ globalthis@1.0.4:
+ dependencies:
+ define-properties: 1.2.1
+ gopd: 1.2.0
+
+ gopd@1.2.0: {}
+
+ has-bigints@1.1.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
+
+ has-proto@1.2.0:
+ dependencies:
+ dunder-proto: 1.0.1
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.3:
+ dependencies:
+ function-bind: 1.1.2
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
+ html-parse-stringify@3.0.1:
+ dependencies:
+ void-elements: 3.1.0
+
+ i18next@26.3.0(typescript@5.9.3):
+ optionalDependencies:
+ typescript: 5.9.3
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ internal-slot@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ hasown: 2.0.3
+ side-channel: 1.1.1
+
+ is-array-buffer@3.0.5:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ is-async-function@2.1.1:
+ dependencies:
+ async-function: 1.0.0
+ call-bound: 1.0.4
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-bigint@1.1.0:
+ dependencies:
+ has-bigints: 1.1.0
+
+ is-binary-path@2.1.0:
+ dependencies:
+ binary-extensions: 2.3.0
+
+ is-boolean-object@1.2.2:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-callable@1.2.7: {}
+
+ is-core-module@2.16.2:
+ dependencies:
+ hasown: 2.0.3
+
+ is-data-view@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ is-typed-array: 1.1.15
+
+ is-date-object@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-document.all@1.0.0:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-extglob@2.1.1: {}
+
+ is-finalizationregistry@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-generator-function@1.1.2:
+ dependencies:
+ call-bound: 1.0.4
+ generator-function: 2.0.1
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-map@2.0.3: {}
+
+ is-negative-zero@2.0.3: {}
+
+ is-number-object@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-number@7.0.0: {}
+
+ is-regex@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.3
+
+ is-set@2.0.3: {}
+
+ is-shared-array-buffer@1.0.4:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-string@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-symbol@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-symbols: 1.1.0
+ safe-regex-test: 1.1.0
+
+ is-typed-array@1.1.15:
+ dependencies:
+ which-typed-array: 1.1.22
+
+ is-weakmap@2.0.2: {}
+
+ is-weakref@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-weakset@2.0.4:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ isarray@2.0.5: {}
+
+ isexe@2.0.0: {}
+
+ iterator.prototype@1.1.5:
+ dependencies:
+ define-data-property: 1.1.4
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ has-symbols: 1.1.0
+ set-function-name: 2.0.2
+
+ jiti@1.21.7: {}
+
+ js-tokens@4.0.0: {}
+
+ js-yaml@4.2.0:
+ dependencies:
+ argparse: 2.0.1
+
+ jsesc@3.1.0: {}
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@2.2.3: {}
+
+ jsx-ast-utils@3.3.5:
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.flat: 1.3.3
+ object.assign: 4.1.7
+ object.values: 1.2.1
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ language-subtag-registry@0.3.23: {}
+
+ language-tags@1.0.9:
+ dependencies:
+ language-subtag-registry: 0.3.23
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ lilconfig@3.1.3: {}
+
+ lines-and-columns@1.2.4: {}
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ lucide-react@0.566.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
+ math-intrinsics@1.1.0: {}
+
+ merge2@1.4.1: {}
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.2
+
+ minimatch@10.2.5:
+ dependencies:
+ brace-expansion: 5.0.6
+
+ minimatch@3.1.5:
+ dependencies:
+ brace-expansion: 1.1.15
+
+ motion-dom@12.40.0:
+ dependencies:
+ motion-utils: 12.39.0
+
+ motion-utils@12.39.0: {}
+
+ ms@2.1.3: {}
+
+ mz@2.7.0:
+ dependencies:
+ any-promise: 1.3.0
+ object-assign: 4.1.1
+ thenify-all: 1.6.0
+
+ nanoid@3.3.12: {}
+
+ natural-compare@1.4.0: {}
+
+ node-exports-info@1.6.0:
+ dependencies:
+ array.prototype.flatmap: 1.3.3
+ es-errors: 1.3.0
+ object.entries: 1.1.9
+ semver: 6.3.1
+
+ node-releases@2.0.46: {}
+
+ normalize-path@3.0.0: {}
+
+ object-assign@4.1.1: {}
+
+ object-hash@3.0.0: {}
+
+ object-inspect@1.13.4: {}
+
+ object-keys@1.1.1: {}
+
+ object.assign@4.1.7:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+ has-symbols: 1.1.0
+ object-keys: 1.1.1
+
+ object.entries@1.1.9:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ object.fromentries@2.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+
+ object.values@1.2.1:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ own-keys@1.0.1:
+ dependencies:
+ get-intrinsic: 1.3.0
+ object-keys: 1.1.1
+ safe-push-apply: 1.0.0
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ path-parse@1.0.7: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.2: {}
+
+ picomatch@4.0.4: {}
+
+ pify@2.3.0: {}
+
+ pirates@4.0.7: {}
+
+ possible-typed-array-names@1.1.0: {}
+
+ postcss-import@15.1.0(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-value-parser: 4.2.0
+ read-cache: 1.0.0
+ resolve: 1.22.12
+
+ postcss-js@4.1.0(postcss@8.5.15):
+ dependencies:
+ camelcase-css: 2.0.1
+ postcss: 8.5.15
+
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15):
+ dependencies:
+ lilconfig: 3.1.3
+ optionalDependencies:
+ jiti: 1.21.7
+ postcss: 8.5.15
+
+ postcss-nested@6.2.0(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-selector-parser: 6.1.2
+
+ postcss-selector-parser@6.1.2:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss-value-parser@4.2.0: {}
+
+ postcss@8.5.15:
+ dependencies:
+ nanoid: 3.3.12
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prelude-ls@1.2.1: {}
+
+ prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3):
+ dependencies:
+ prettier: 3.8.3
+
+ prettier@3.8.3: {}
+
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
+ punycode@2.3.1: {}
+
+ queue-microtask@1.2.3: {}
+
+ react-dom@18.3.1(react@18.3.1):
+ dependencies:
+ loose-envify: 1.4.0
+ react: 18.3.1
+ scheduler: 0.23.2
+
+ react-i18next@17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ html-parse-stringify: 3.0.1
+ i18next: 26.3.0(typescript@5.9.3)
+ react: 18.3.1
+ use-sync-external-store: 1.6.0(react@18.3.1)
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
+ typescript: 5.9.3
+
+ react-is@16.13.1: {}
+
+ react-loading-skeleton@3.5.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
+ react-refresh@0.17.0: {}
+
+ react-remove-scroll-bar@2.3.8(@types/react@18.3.29)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1)
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ react-remove-scroll@2.7.2(@types/react@18.3.29)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-remove-scroll-bar: 2.3.8(@types/react@18.3.29)(react@18.3.1)
+ react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1)
+ tslib: 2.8.1
+ use-callback-ref: 1.3.3(@types/react@18.3.29)(react@18.3.1)
+ use-sidecar: 1.1.3(@types/react@18.3.29)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ react-router-dom@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-router: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+
+ react-router@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ cookie: 1.1.1
+ react: 18.3.1
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
+
+ react-style-singleton@2.2.3(@types/react@18.3.29)(react@18.3.1):
+ dependencies:
+ get-nonce: 1.0.1
+ react: 18.3.1
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ react@18.3.1:
+ dependencies:
+ loose-envify: 1.4.0
+
+ read-cache@1.0.0:
+ dependencies:
+ pify: 2.3.0
+
+ readdirp@3.6.0:
+ dependencies:
+ picomatch: 2.3.2
+
+ reflect.getprototypeof@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ which-builtin-type: 1.2.1
+
+ regexp.prototype.flags@1.5.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-errors: 1.3.0
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ set-function-name: 2.0.2
+
+ resolve-from@4.0.0: {}
+
+ resolve@1.22.12:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ resolve@2.0.0-next.7:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ node-exports-info: 1.6.0
+ object-keys: 1.1.1
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ reusify@1.1.0: {}
+
+ rollup@4.60.4:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.60.4
+ '@rollup/rollup-android-arm64': 4.60.4
+ '@rollup/rollup-darwin-arm64': 4.60.4
+ '@rollup/rollup-darwin-x64': 4.60.4
+ '@rollup/rollup-freebsd-arm64': 4.60.4
+ '@rollup/rollup-freebsd-x64': 4.60.4
+ '@rollup/rollup-linux-arm-gnueabihf': 4.60.4
+ '@rollup/rollup-linux-arm-musleabihf': 4.60.4
+ '@rollup/rollup-linux-arm64-gnu': 4.60.4
+ '@rollup/rollup-linux-arm64-musl': 4.60.4
+ '@rollup/rollup-linux-loong64-gnu': 4.60.4
+ '@rollup/rollup-linux-loong64-musl': 4.60.4
+ '@rollup/rollup-linux-ppc64-gnu': 4.60.4
+ '@rollup/rollup-linux-ppc64-musl': 4.60.4
+ '@rollup/rollup-linux-riscv64-gnu': 4.60.4
+ '@rollup/rollup-linux-riscv64-musl': 4.60.4
+ '@rollup/rollup-linux-s390x-gnu': 4.60.4
+ '@rollup/rollup-linux-x64-gnu': 4.60.4
+ '@rollup/rollup-linux-x64-musl': 4.60.4
+ '@rollup/rollup-openbsd-x64': 4.60.4
+ '@rollup/rollup-openharmony-arm64': 4.60.4
+ '@rollup/rollup-win32-arm64-msvc': 4.60.4
+ '@rollup/rollup-win32-ia32-msvc': 4.60.4
+ '@rollup/rollup-win32-x64-gnu': 4.60.4
+ '@rollup/rollup-win32-x64-msvc': 4.60.4
+ fsevents: 2.3.3
+
+ run-parallel@1.2.0:
+ dependencies:
+ queue-microtask: 1.2.3
+
+ safe-array-concat@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ has-symbols: 1.1.0
+ isarray: 2.0.5
+
+ safe-push-apply@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ isarray: 2.0.5
+
+ safe-regex-test@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-regex: 1.2.1
+
+ scheduler@0.23.2:
+ dependencies:
+ loose-envify: 1.4.0
+
+ semver@6.3.1: {}
+
+ semver@7.8.4: {}
+
+ set-cookie-parser@2.7.2: {}
+
+ set-function-length@1.2.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+
+ set-function-name@2.0.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+
+ set-proto@1.0.0:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ side-channel-list@1.0.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
+ source-map-js@1.2.1: {}
+
+ stop-iteration-iterator@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ internal-slot: 1.1.0
+
+ string.prototype.includes@2.0.1:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ string.prototype.matchall@4.0.12:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ internal-slot: 1.1.0
+ regexp.prototype.flags: 1.5.4
+ set-function-name: 2.0.2
+ side-channel: 1.1.1
+
+ string.prototype.repeat@1.0.0:
+ dependencies:
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ string.prototype.trim@1.2.11:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-data-property: 1.1.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ has-property-descriptors: 1.0.2
+ safe-regex-test: 1.1.0
+
+ string.prototype.trimend@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ string.prototype.trimstart@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ strip-json-comments@3.1.1: {}
+
+ sucrase@3.35.1:
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ commander: 4.1.1
+ lines-and-columns: 1.2.4
+ mz: 2.7.0
+ pirates: 4.0.7
+ tinyglobby: 0.2.16
+ ts-interface-checker: 0.1.13
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ tailwind-merge@2.6.1: {}
+
+ tailwindcss-animate@1.0.7(tailwindcss@3.4.19):
+ dependencies:
+ tailwindcss: 3.4.19
+
+ tailwindcss@3.4.19:
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ arg: 5.0.2
+ chokidar: 3.6.0
+ didyoumean: 1.2.2
+ dlv: 1.1.3
+ fast-glob: 3.3.3
+ glob-parent: 6.0.2
+ is-glob: 4.0.3
+ jiti: 1.21.7
+ lilconfig: 3.1.3
+ micromatch: 4.0.8
+ normalize-path: 3.0.0
+ object-hash: 3.0.0
+ picocolors: 1.1.1
+ postcss: 8.5.15
+ postcss-import: 15.1.0(postcss@8.5.15)
+ postcss-js: 4.1.0(postcss@8.5.15)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)
+ postcss-nested: 6.2.0(postcss@8.5.15)
+ postcss-selector-parser: 6.1.2
+ resolve: 1.22.12
+ sucrase: 3.35.1
+ transitivePeerDependencies:
+ - tsx
+ - yaml
+
+ thenify-all@1.6.0:
+ dependencies:
+ thenify: 3.3.1
+
+ thenify@3.3.1:
+ dependencies:
+ any-promise: 1.3.0
+
+ tinyglobby@0.2.16:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
+ ts-api-utils@2.5.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ ts-interface-checker@0.1.13: {}
+
+ tslib@2.8.1: {}
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ typed-array-buffer@1.0.3:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-length@1.0.3:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-offset@1.0.4:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+ reflect.getprototypeof: 1.0.10
+
+ typed-array-length@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ is-typed-array: 1.1.15
+ possible-typed-array-names: 1.1.0
+ reflect.getprototypeof: 1.0.10
+
+ typescript-eslint@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ typescript@5.9.3: {}
+
+ unbox-primitive@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-bigints: 1.1.0
+ has-symbols: 1.1.0
+ which-boxed-primitive: 1.1.1
+
+ undici-types@7.24.6: {}
+
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
+ dependencies:
+ browserslist: 4.28.2
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ use-callback-ref@1.3.3(@types/react@18.3.29)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ use-sidecar@1.1.3(@types/react@18.3.29)(react@18.3.1):
+ dependencies:
+ detect-node-es: 1.1.0
+ react: 18.3.1
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ use-sync-external-store@1.6.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
+ util-deprecate@1.0.2: {}
+
+ vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.15
+ rollup: 4.60.4
+ tinyglobby: 0.2.16
+ optionalDependencies:
+ '@types/node': 25.9.1
+ fsevents: 2.3.3
+ jiti: 1.21.7
+
+ void-elements@3.1.0: {}
+
+ which-boxed-primitive@1.1.1:
+ dependencies:
+ is-bigint: 1.1.0
+ is-boolean-object: 1.2.2
+ is-number-object: 1.1.1
+ is-string: 1.1.1
+ is-symbol: 1.1.1
+
+ which-builtin-type@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ function.prototype.name: 1.2.0
+ has-tostringtag: 1.0.2
+ is-async-function: 2.1.1
+ is-date-object: 1.1.0
+ is-finalizationregistry: 1.1.1
+ is-generator-function: 1.1.2
+ is-regex: 1.2.1
+ is-weakref: 1.1.1
+ isarray: 2.0.5
+ which-boxed-primitive: 1.1.1
+ which-collection: 1.0.2
+ which-typed-array: 1.1.22
+
+ which-collection@1.0.2:
+ dependencies:
+ is-map: 2.0.3
+ is-set: 2.0.3
+ is-weakmap: 2.0.2
+ is-weakset: 2.0.4
+
+ which-typed-array@1.1.22:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ for-each: 0.3.5
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ word-wrap@1.2.5: {}
+
+ yallist@3.1.1: {}
+
+ yocto-queue@0.1.0: {}
+
+ zod-validation-error@4.0.2(zod@4.4.3):
+ dependencies:
+ zod: 4.4.3
+
+ zod@4.4.3: {}
diff --git a/client/ui/frontend/pnpm-workspace.yaml b/client/ui/frontend/pnpm-workspace.yaml
new file mode 100644
index 000000000..5ed0b5af0
--- /dev/null
+++ b/client/ui/frontend/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+allowBuilds:
+ esbuild: true
diff --git a/client/ui/frontend/postcss.config.js b/client/ui/frontend/postcss.config.js
new file mode 100644
index 000000000..2aa7205d4
--- /dev/null
+++ b/client/ui/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx
new file mode 100644
index 000000000..7f1359510
--- /dev/null
+++ b/client/ui/frontend/src/app.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import "./globals.css";
+import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
+import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx";
+import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
+import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx";
+import ErrorDialog from "@/modules/error/ErrorDialog.tsx";
+import { AppLayout } from "@/layouts/AppLayout.tsx";
+import { MainPage } from "@/modules/main/MainPage.tsx";
+import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
+import { SkeletonTheme } from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { welcome } from "@/lib/welcome";
+import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx";
+import { initI18n } from "@/lib/i18n";
+import { initPlatform } from "@/lib/platform";
+import { initLogForwarding } from "@/lib/logs";
+import { initStallWatch } from "@/lib/stallwatch";
+
+// Must run first so even init-time logs reach the Go log pipeline.
+initLogForwarding();
+
+initStallWatch();
+
+welcome();
+
+Promise.all([
+ initI18n().catch((e) => {
+ console.error("i18n init failed:", e);
+ }),
+ initPlatform().catch((e) => {
+ console.error("platform init failed:", e);
+ }),
+]).finally(() => {
+ ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+ }
+ />
+ } />
+ }
+ />
+ } />
+ } />
+
+ }>
+ } />
+ } />
+ } />
+
+
+
+
+ ,
+ );
+});
diff --git a/client/ui/frontend/src/assets/fonts/inter-variable.ttf b/client/ui/frontend/src/assets/fonts/inter-variable.ttf
new file mode 100644
index 000000000..4ab79e010
Binary files /dev/null and b/client/ui/frontend/src/assets/fonts/inter-variable.ttf differ
diff --git a/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf b/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf
new file mode 100644
index 000000000..b60e77f5d
Binary files /dev/null and b/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf differ
diff --git a/client/ui/frontend/src/assets/img/tray-darwin.png b/client/ui/frontend/src/assets/img/tray-darwin.png
new file mode 100644
index 000000000..75df803d8
Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-darwin.png differ
diff --git a/client/ui/frontend/src/assets/img/tray-linux.png b/client/ui/frontend/src/assets/img/tray-linux.png
new file mode 100644
index 000000000..08cea81af
Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-linux.png differ
diff --git a/client/ui/frontend/src/assets/img/tray-windows.png b/client/ui/frontend/src/assets/img/tray-windows.png
new file mode 100644
index 000000000..cebbf6153
Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-windows.png differ
diff --git a/client/ui/frontend/src/assets/logos/netbird-full.svg b/client/ui/frontend/src/assets/logos/netbird-full.svg
new file mode 100644
index 000000000..f925d5761
--- /dev/null
+++ b/client/ui/frontend/src/assets/logos/netbird-full.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/ui/frontend/src/assets/logos/netbird.svg b/client/ui/frontend/src/assets/logos/netbird.svg
new file mode 100644
index 000000000..6254931c6
--- /dev/null
+++ b/client/ui/frontend/src/assets/logos/netbird.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx
new file mode 100644
index 000000000..c5e2b5f22
--- /dev/null
+++ b/client/ui/frontend/src/components/Badge.tsx
@@ -0,0 +1,43 @@
+import { forwardRef, type ComponentType, type HTMLAttributes } from "react";
+import type { LucideProps } from "lucide-react";
+import { cn } from "@/lib/cn";
+
+export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger";
+
+type Props = HTMLAttributes & {
+ variant?: BadgeVariant;
+ icon?: ComponentType;
+ iconSize?: number;
+};
+
+const VARIANT_CLASSES: Record = {
+ info: "bg-sky-900 border border-sky-700 text-sky-200",
+ neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200",
+ brand: "bg-netbird/15 border border-netbird/30 text-netbird",
+ success: "bg-green-900 border border-green-700 text-green-200",
+ warning: "bg-yellow-900 border border-yellow-700 text-yellow-200",
+ danger: "bg-red-900 border border-red-700 text-red-200",
+};
+
+export const Badge = forwardRef(function Badge(
+ { variant = "info", icon: Icon, iconSize = 10, className, children, ...rest },
+ ref,
+) {
+ return (
+
+ {Icon && }
+ {children}
+
+ );
+});
+
+export default Badge;
diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx
new file mode 100644
index 000000000..3cf681a1c
--- /dev/null
+++ b/client/ui/frontend/src/components/CopyToClipboard.tsx
@@ -0,0 +1,131 @@
+import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { Check, Copy } from "lucide-react";
+import { cn } from "@/lib/cn";
+
+const VARIANT_HOVER = {
+ default: "group-hover/copy:[&_*]:text-nb-gray-300",
+ bright: "group-hover/copy:[&_*]:text-nb-gray-200",
+} as const;
+
+type CopyToClipboardVariant = keyof typeof VARIANT_HOVER;
+
+type CopyToClipboardProps = {
+ children: ReactNode;
+ message?: string;
+ size?: number;
+ iconAlignment?: "left" | "right";
+ className?: string;
+ iconClassName?: string;
+ alwaysShowIcon?: boolean;
+ // wrap lets long content (a shell command, a path) break across lines
+ // instead of being truncated to one line.
+ wrap?: boolean;
+ variant?: CopyToClipboardVariant;
+ "aria-label"?: string;
+ tabIndex?: number;
+ onKeyDown?: (e: KeyboardEvent) => void;
+};
+
+export const CopyToClipboard = ({
+ children,
+ message,
+ size = 10,
+ iconAlignment = "right",
+ className,
+ iconClassName,
+ alwaysShowIcon = false,
+ wrap = false,
+ variant = "default",
+ "aria-label": ariaLabel,
+ tabIndex = 0,
+ onKeyDown,
+}: CopyToClipboardProps) => {
+ const { t } = useTranslation();
+ const wrapperRef = useRef(null);
+ const [copied, setCopied] = useState(false);
+ const copyTimer = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (copyTimer.current) clearTimeout(copyTimer.current);
+ },
+ [],
+ );
+
+ const handleClick = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ const text = message ?? wrapperRef.current?.innerText ?? "";
+ if (!text) return;
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ if (copyTimer.current) clearTimeout(copyTimer.current);
+ copyTimer.current = setTimeout(() => setCopied(false), 500);
+ } catch (e) {
+ console.warn("copy to clipboard failed", e);
+ }
+ };
+
+ const resolvedLabel =
+ ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy"));
+
+ return (
+
+
+ {children}
+
+
+
+
+
+
+
+ );
+};
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.label")}
+ {t("settings.general.language.help")}
+
+
+
+
+
+
+
+ {current ? labelFor(current) : "—"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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 (
+ {
+ if (stopPropagation) e.stopPropagation();
+ if (copy !== undefined) {
+ void navigator.clipboard
+ .writeText(copy)
+ .then(() => {
+ setCopied(true);
+ if (copyTimer.current) clearTimeout(copyTimer.current);
+ copyTimer.current = setTimeout(() => setCopied(false), 1500);
+ })
+ .catch((e: unknown) => console.warn("copy to clipboard failed", e));
+ }
+ onClick?.(e);
+ }}
+ {...props}
+ >
+ {loading && (
+
+
+
+ )}
+
+ {copy !== undefined &&
+ (copied ? (
+
+ ) : (
+
+ ))}
+ {children}
+
+
+ );
+});
+
+export default Button;
diff --git a/client/ui/frontend/src/components/buttons/IconButton.tsx b/client/ui/frontend/src/components/buttons/IconButton.tsx
new file mode 100644
index 000000000..3d36bc111
--- /dev/null
+++ b/client/ui/frontend/src/components/buttons/IconButton.tsx
@@ -0,0 +1,36 @@
+import { type ButtonHTMLAttributes, type ComponentType, forwardRef } from "react";
+import { type LucideProps } from "lucide-react";
+import { useFocusVisible } from "@/hooks/useFocusVisible";
+import { cn } from "@/lib/cn";
+
+type Props = ButtonHTMLAttributes & {
+ icon: ComponentType;
+ iconSize?: number;
+ iconClassName?: string;
+};
+
+export const IconButton = forwardRef(function IconButton(
+ { icon: Icon, iconSize = 17, iconClassName, className, type = "button", disabled, ...props },
+ ref,
+) {
+ const isFocusVisible = useFocusVisible();
+ return (
+
+
+
+ );
+});
diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx
new file mode 100644
index 000000000..caf98c2c8
--- /dev/null
+++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx
@@ -0,0 +1,35 @@
+import { type ReactNode, forwardRef } from "react";
+import { cn } from "@/lib/cn.ts";
+import { isMacOS } from "@/lib/platform.ts";
+
+type ConfirmDialogProps = {
+ children: ReactNode;
+ "aria-label"?: string;
+ "aria-labelledby"?: string;
+};
+
+export const ConfirmDialog = forwardRef(function ConfirmDialog(
+ { children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy },
+ ref,
+) {
+ return (
+
+
+ {children}
+
+
+ );
+});
diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx
new file mode 100644
index 000000000..241a69ec9
--- /dev/null
+++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx
@@ -0,0 +1,84 @@
+import { type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import * as Dialog from "@/components/dialog/Dialog";
+import { Button } from "@/components/buttons/Button";
+import { DialogHeading } from "@/components/dialog/DialogHeading";
+import { DialogDescription } from "@/components/dialog/DialogDescription";
+import { DialogActions } from "@/components/dialog/DialogActions";
+
+type ConfirmModalProps = {
+ open: boolean;
+ title: ReactNode;
+ description: ReactNode;
+ confirmLabel: string;
+ cancelLabel?: string;
+ danger?: boolean;
+ busy?: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+};
+
+export const ConfirmModal = ({
+ open,
+ title,
+ description,
+ confirmLabel,
+ cancelLabel,
+ danger = false,
+ busy = false,
+ onConfirm,
+ onCancel,
+}: ConfirmModalProps) => {
+ const { t } = useTranslation();
+ const resolvedCancel = cancelLabel ?? t("common.cancel");
+
+ const srTitle = typeof title === "string" ? title : undefined;
+ const srDescription = typeof description === "string" ? description : undefined;
+
+ return (
+ {
+ if (!next && !busy) onCancel();
+ }}
+ >
+ e.preventDefault()}
+ >
+
+
+ {title}
+
+ {description}
+
+
+
+
+
+ {resolvedCancel}
+
+
+ {confirmLabel}
+
+
+
+
+
+ );
+};
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 })
+ )}
+
+
+
+
+ openUrl(downloadUrl)}>
+
+ {t("daemon.outdated.download")}
+
+
+
+
+ );
+};
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")}
+
+
+
+
+ openUrl(DOCS_URL)}>
+
+ {t("daemon.unavailable.docsLink")}
+
+
+
+
+ );
+};
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 (
+
+ onStep(1)}
+ className={
+ "flex w-9 flex-1 cursor-default items-center justify-center text-nb-gray-300 transition-colors hover:bg-nb-gray-800"
+ }
+ >
+
+
+ onStep(-1)}
+ className={cn(
+ "flex w-9 flex-1 cursor-default items-center justify-center text-nb-gray-300 transition-colors hover:bg-nb-gray-800",
+ "border-t border-neutral-200 dark:border-nb-gray-700",
+ )}
+ >
+
+
+
+ );
+}
+
+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 ? (
+ setShowPassword((s) => !s)}
+ className={"pointer-events-auto transition-all hover:text-white"}
+ aria-label={t("common.togglePasswordVisibility")}
+ aria-pressed={showPassword}
+ >
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+ ) : 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 ? (
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ ) : 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 &&
{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 (
+
+
+
+
+ {label}
+
+
+
+ {helpText}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {label}
+
+
+ {helpText}
+
+
+
+
+
+
+ {children && value ?
{children}
: null}
+
+ );
+}
diff --git a/client/ui/frontend/src/components/switches/SwitchItem.tsx b/client/ui/frontend/src/components/switches/SwitchItem.tsx
new file mode 100644
index 000000000..e23c73fe3
--- /dev/null
+++ b/client/ui/frontend/src/components/switches/SwitchItem.tsx
@@ -0,0 +1,42 @@
+import * as RadioGroup from "@radix-ui/react-radio-group";
+import { motion } from "framer-motion";
+import { type ReactNode } from "react";
+import { cn } from "@/lib/cn";
+import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup";
+
+type Props = {
+ value: string;
+ children: ReactNode;
+ className?: string;
+};
+
+export const SwitchItem = ({ value, children, className }: Props) => {
+ const { value: activeValue, layoutId } = useSwitchItemGroup();
+ const active = activeValue === value;
+
+ return (
+
+ {active && (
+
+ )}
+
+ {children}
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx
new file mode 100644
index 000000000..b4361d530
--- /dev/null
+++ b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx
@@ -0,0 +1,60 @@
+import * as RadioGroup from "@radix-ui/react-radio-group";
+import { createContext, type ReactNode, useContext, useId, useMemo } from "react";
+import { cn } from "@/lib/cn";
+
+type SwitchItemGroupContextValue = {
+ value: string;
+ layoutId: string;
+};
+
+const SwitchItemGroupContext = createContext(null);
+
+export const useSwitchItemGroup = () => {
+ const ctx = useContext(SwitchItemGroupContext);
+ if (!ctx) {
+ throw new Error("SwitchItem must be used inside a SwitchItemGroup");
+ }
+ return ctx;
+};
+
+type Props = {
+ value: string;
+ onChange: (value: string) => void;
+ children: ReactNode;
+ className?: string;
+ disabled?: boolean;
+ "aria-label"?: string;
+ "aria-labelledby"?: string;
+};
+
+export const SwitchItemGroup = ({
+ value,
+ onChange,
+ children,
+ className,
+ disabled = false,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledBy,
+}: Props) => {
+ const layoutId = useId();
+ const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/switches/ToggleSwitch.tsx b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx
new file mode 100644
index 000000000..2d9f597e6
--- /dev/null
+++ b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+import { cn } from "@/lib/cn";
+
+type SwitchVariants = VariantProps;
+
+const switchVariants = cva("", {
+ variants: {
+ size: {
+ default: "h-[24px] w-[44px]",
+ small: "h-[18px] w-[36px]",
+ large: "h-[36px] w-[66px]",
+ },
+ variant: {
+ default: [
+ "dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
+ "dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
+ "data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
+ "data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300",
+ ],
+ "red-green": [
+ "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
+ "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
+ "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
+ "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300",
+ ],
+ red: [
+ "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
+ "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
+ "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
+ "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300",
+ ],
+ },
+ "thumb-size": {
+ default:
+ "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
+ small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0",
+ large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]",
+ },
+ },
+});
+
+const ToggleSwitch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ SwitchVariants & { dataCy?: string }
+>(({ className, size = "default", variant = "default", dataCy, disabled, ...props }, ref) => (
+ {
+ e.stopPropagation();
+ props.onClick?.(e);
+ }}
+ ref={ref}
+ >
+
+
+));
+ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
+
+export { ToggleSwitch };
diff --git a/client/ui/frontend/src/components/typography/HelpText.tsx b/client/ui/frontend/src/components/typography/HelpText.tsx
new file mode 100644
index 000000000..8c52ff714
--- /dev/null
+++ b/client/ui/frontend/src/components/typography/HelpText.tsx
@@ -0,0 +1,24 @@
+import { type ReactNode } from "react";
+import { cn } from "@/lib/cn";
+
+type Props = {
+ children?: ReactNode;
+ margin?: boolean;
+ className?: string;
+ disabled?: boolean;
+};
+
+export const HelpText = ({ children, margin = true, className, disabled = false }: Props) => (
+
+ {children}
+
+);
+
+export default HelpText;
diff --git a/client/ui/frontend/src/components/typography/Label.tsx b/client/ui/frontend/src/components/typography/Label.tsx
new file mode 100644
index 000000000..a8e1a446f
--- /dev/null
+++ b/client/ui/frontend/src/components/typography/Label.tsx
@@ -0,0 +1,42 @@
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { cva, type VariantProps } from "class-variance-authority";
+import { type ComponentPropsWithoutRef, forwardRef, type Ref } from "react";
+import { cn } from "@/lib/cn";
+
+const labelVariants = cva(
+ "mb-1.5 inline-block flex items-center gap-2 text-sm font-medium leading-none tracking-wider peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-nb-gray-100",
+);
+
+type LabelProps = ComponentPropsWithoutRef &
+ VariantProps & {
+ as?: "label" | "div";
+ disabled?: boolean;
+ };
+
+export const Label = forwardRef(function Label(
+ { className, as = "label", disabled = false, children, ...props },
+ ref,
+) {
+ const classes = cn(
+ labelVariants(),
+ className,
+ "select-none transition-all duration-300",
+ disabled && "pointer-events-none opacity-30",
+ );
+
+ if (as === "div") {
+ return (
+ } className={classes}>
+ {children}
+
+ );
+ }
+
+ return (
+ } className={classes} {...props}>
+ {children}
+
+ );
+});
+
+export default Label;
diff --git a/client/ui/frontend/src/contexts/ClientVersionContext.tsx b/client/ui/frontend/src/contexts/ClientVersionContext.tsx
new file mode 100644
index 000000000..c0699a9ee
--- /dev/null
+++ b/client/ui/frontend/src/contexts/ClientVersionContext.tsx
@@ -0,0 +1,114 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Events } from "@wailsio/runtime";
+
+import { Update as UpdateSvc, WindowManager } from "@bindings/services";
+import type { State as UpdateState } from "@bindings/updater/models.js";
+import i18next from "@/lib/i18n";
+import { errorDialog, formatErrorMessage } from "@/lib/errors";
+
+const isDaemonUnavailable = (e: unknown): boolean => {
+ const msg = e instanceof Error ? e.message : String(e);
+ return msg.includes("code = Unavailable");
+};
+
+type ClientVersionContextValue = {
+ updateAvailable: boolean;
+ updateVersion: string | null;
+ enforced: boolean;
+ installing: boolean;
+ triggerUpdate: () => void;
+ updating: boolean;
+};
+
+const EVENT_UPDATE_STATE = "netbird:update:state";
+
+const emptyState: UpdateState = {
+ available: false,
+ version: "",
+ enforced: false,
+ installing: false,
+};
+
+const ClientVersionContext = createContext(null);
+
+export const useClientVersion = () => {
+ const ctx = useContext(ClientVersionContext);
+ if (!ctx) {
+ throw new Error("useClientVersion must be used inside ClientVersionProvider");
+ }
+ return ctx;
+};
+
+export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
+ const [state, setState] = useState(emptyState);
+ const [updating, setUpdating] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ UpdateSvc.GetState()
+ .then((s) => {
+ if (cancelled || !s) return;
+ setState(s);
+ })
+ .catch((e) => {
+ if (cancelled || isDaemonUnavailable(e)) return;
+ void errorDialog({
+ Title: i18next.t("update.error.loadStateTitle"),
+ Message: formatErrorMessage(e),
+ });
+ });
+ const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => {
+ if (ev?.data) setState(ev.data);
+ });
+ return () => {
+ cancelled = true;
+ off?.();
+ };
+ }, []);
+
+ const prevInstallingRef = useRef(false);
+ useEffect(() => {
+ if (state.installing && !prevInstallingRef.current) {
+ WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
+ }
+ prevInstallingRef.current = state.installing;
+ }, [state.installing, state.version]);
+
+ const triggerUpdate = useCallback(() => {
+ setUpdating(true);
+ WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
+ UpdateSvc.Trigger()
+ .catch(async (e) => {
+ if (isDaemonUnavailable(e)) return;
+ WindowManager.CloseInstallProgress().catch(console.error);
+ await errorDialog({
+ Title: i18next.t("update.error.triggerTitle"),
+ Message: formatErrorMessage(e),
+ });
+ })
+ .finally(() => setUpdating(false));
+ }, [state.version]);
+
+ const value = useMemo(
+ () => ({
+ updateAvailable: state.available,
+ updateVersion: state.version || null,
+ enforced: state.enforced,
+ installing: state.installing,
+ triggerUpdate,
+ updating,
+ }),
+ [state, triggerUpdate, updating],
+ );
+
+ return {children} ;
+};
diff --git a/client/ui/frontend/src/contexts/DebugBundleContext.tsx b/client/ui/frontend/src/contexts/DebugBundleContext.tsx
new file mode 100644
index 000000000..5f2ed9041
--- /dev/null
+++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx
@@ -0,0 +1,319 @@
+import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
+import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
+import type { DebugBundleResult } from "@bindings/services/models.js";
+import i18next from "@/lib/i18n";
+import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
+import { startConnection } from "@/lib/connection.ts";
+
+const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
+const TRACE_LOG_FILE_COUNT = 5;
+const PLAIN_LOG_FILE_COUNT = 1;
+const TRACE_LOG_LEVEL = "trace";
+const DEFAULT_LOG_LEVEL = "info";
+
+export type DebugStage =
+ | { kind: "idle" }
+ | { kind: "preparing-trace" }
+ | { kind: "reconnecting" }
+ | { kind: "capturing"; remainingSec: number; totalSec: number }
+ | { kind: "restoring-level" }
+ | { kind: "bundling" }
+ | { kind: "uploading" }
+ | { kind: "cancelling" }
+ | { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean };
+
+const sleep = (ms: number, signal: AbortSignal) =>
+ new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new DOMException("aborted", "AbortError"));
+ return;
+ }
+ const onAbort = () => {
+ clearTimeout(id);
+ reject(new DOMException("aborted", "AbortError"));
+ };
+ const id = setTimeout(() => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+ signal.addEventListener("abort", onAbort);
+ });
+
+const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
+
+const throwIfAborted = (signal: AbortSignal) => {
+ if (signal.aborted) throw new DOMException("aborted", "AbortError");
+};
+
+const setLogLevelBestEffort = async (level: string) => {
+ try {
+ await DebugSvc.SetLogLevel({ level });
+ } catch (e) {
+ console.warn("[DebugBundle] best-effort set log level failed", e);
+ }
+};
+
+const stopCaptureBestEffort = async () => {
+ try {
+ await DebugSvc.StopBundleCapture();
+ } catch (e) {
+ console.warn("[DebugBundle] best-effort stop packet capture failed", e);
+ }
+};
+
+type LevelState = { original: string; raised: boolean };
+type CaptureState = { started: boolean };
+
+type BundleOptions = {
+ trace: boolean;
+ capture: boolean;
+ capturePackets: boolean;
+ hasWindow: boolean;
+ totalSec: number;
+ uploadUrl: string;
+ anonymizeLevel: AnonymizeLevel;
+ systemInfo: boolean;
+};
+
+export type AnonymizeLevel = "none" | "default" | "strict";
+
+const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
+ try {
+ // Mirror the CLI's safety margin: window + 30s, server caps at 10m.
+ await DebugSvc.StartBundleCapture(totalSec + 30);
+ pcap.started = true;
+ } catch (e) {
+ console.warn("[DebugBundle] start packet capture failed", e);
+ }
+};
+
+const cleanupBestEffort = async (pcap: CaptureState, level: LevelState, restoreLevel: boolean) => {
+ if (pcap.started) {
+ await stopCaptureBestEffort();
+ pcap.started = false;
+ }
+ if (restoreLevel && level.raised) {
+ await setLogLevelBestEffort(level.original);
+ }
+};
+
+const raiseToTrace = async (
+ signal: AbortSignal,
+ level: LevelState,
+ setStage: (s: DebugStage) => void,
+) => {
+ setStage({ kind: "preparing-trace" });
+ try {
+ const cur = await DebugSvc.GetLogLevel();
+ if (cur?.level) level.original = cur.level;
+ } catch (e) {
+ console.warn("[DebugBundle] read current log level failed", e);
+ }
+ throwIfAborted(signal);
+ await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL });
+ level.raised = true;
+};
+
+const cycleConnection = async (signal: AbortSignal, setStage: (s: DebugStage) => void) => {
+ throwIfAborted(signal);
+ setStage({ kind: "reconnecting" });
+ try {
+ await ConnectionSvc.Down();
+ } catch (e) {
+ console.warn("[DebugBundle] disconnect before capture failed", e);
+ }
+ throwIfAborted(signal);
+ await startConnection(undefined, signal);
+};
+
+const restoreLogLevel = async (level: LevelState, setStage: (s: DebugStage) => void) => {
+ setStage({ kind: "restoring-level" });
+ try {
+ await DebugSvc.SetLogLevel({ level: level.original });
+ level.raised = false;
+ } catch (e) {
+ console.warn("[DebugBundle] restore log level failed", e);
+ }
+};
+
+const waitCaptureWindow = async (
+ signal: AbortSignal,
+ setStage: (s: DebugStage) => void,
+ totalSec: number,
+) => {
+ for (let remaining = totalSec; remaining > 0; remaining--) {
+ setStage({ kind: "capturing", remainingSec: remaining, totalSec });
+ await sleep(1000, signal);
+ }
+};
+
+const runBundleFlow = async (
+ signal: AbortSignal,
+ opts: BundleOptions,
+ level: LevelState,
+ pcap: CaptureState,
+ setStage: (s: DebugStage) => void,
+ setLastBundlePath: (p: string) => void,
+) => {
+ if (opts.trace) {
+ await raiseToTrace(signal, level, setStage);
+ }
+ throwIfAborted(signal);
+
+ if (opts.capture) {
+ await cycleConnection(signal, setStage);
+ }
+ throwIfAborted(signal);
+
+ if (opts.hasWindow && opts.capturePackets) {
+ await startCaptureBestEffort(opts.totalSec, pcap);
+ }
+ throwIfAborted(signal);
+
+ if (opts.hasWindow) {
+ await waitCaptureWindow(signal, setStage, opts.totalSec);
+ }
+
+ if (pcap.started) {
+ await stopCaptureBestEffort();
+ pcap.started = false;
+ }
+
+ if (level.raised) {
+ await restoreLogLevel(level, setStage);
+ }
+
+ throwIfAborted(signal);
+ setStage({ kind: "bundling" });
+ const logFileCount = opts.trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT;
+
+ if (opts.uploadUrl) setStage({ kind: "uploading" });
+ const result = await DebugSvc.Bundle({
+ anonymize: opts.anonymizeLevel !== "none",
+ // The daemon only knows "default" and "strict"; "none" is expressed
+ // through the anonymize flag being off.
+ anonymizeLevel: opts.anonymizeLevel === "strict" ? "strict" : "default",
+ systemInfo: opts.systemInfo,
+ uploadUrl: opts.uploadUrl,
+ logFileCount,
+ });
+ throwIfAborted(signal);
+ if (result.path) setLastBundlePath(result.path);
+ setStage({ kind: "done", result, uploadAttempted: Boolean(opts.uploadUrl) });
+};
+
+const useDebugBundle = () => {
+ const [anonymizeLevel, setAnonymizeLevel] = useState("none");
+ const [systemInfo, setSystemInfo] = useState(true);
+ const [upload, setUpload] = useState(true);
+ const [trace, setTrace] = useState(true);
+ const [capture, setCapture] = useState(false);
+ const [traceMinutes, setTraceMinutes] = useState(1);
+ const [capturePackets, setCapturePackets] = useState(true);
+ const [stage, setStage] = useState({ kind: "idle" });
+ const [lastBundlePath, setLastBundlePath] = useState("");
+ const abortRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ abortRef.current?.abort();
+ };
+ }, []);
+
+ const isRunning = stage.kind !== "idle" && stage.kind !== "done";
+
+ const reset = () => setStage({ kind: "idle" });
+
+ const cancel = () => {
+ if (!abortRef.current || abortRef.current.signal.aborted) return;
+ abortRef.current.abort();
+ setStage({ kind: "cancelling" });
+ };
+
+ const run = async () => {
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+ const signal = ctrl.signal;
+
+ const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
+ const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false };
+ const pcap: CaptureState = { started: false };
+ const opts: BundleOptions = {
+ trace,
+ capture,
+ capturePackets,
+ hasWindow: capture && totalSec > 0,
+ totalSec,
+ uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
+ anonymizeLevel,
+ systemInfo,
+ };
+
+ try {
+ await runBundleFlow(signal, opts, level, pcap, setStage, setLastBundlePath);
+ } catch (e) {
+ if (isAbort(e)) {
+ setStage({ kind: "cancelling" });
+ await cleanupBestEffort(pcap, level, true);
+ setStage({ kind: "idle" });
+ return;
+ }
+ await cleanupBestEffort(pcap, level, false);
+ setStage({ kind: "idle" });
+ await errorDialog({
+ Title: i18next.t("settings.error.debugBundleTitle"),
+ Message: formatErrorMessage(e),
+ });
+ } finally {
+ if (abortRef.current === ctrl) abortRef.current = null;
+ }
+ };
+
+ const openBundleDir = () => {
+ if (!lastBundlePath) return;
+ DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) =>
+ console.error("[DebugBundleContext] reveal failed", err),
+ );
+ };
+
+ return {
+ anonymizeLevel,
+ setAnonymizeLevel,
+ systemInfo,
+ setSystemInfo,
+ upload,
+ setUpload,
+ trace,
+ setTrace,
+ capture,
+ setCapture,
+ traceMinutes,
+ setTraceMinutes,
+ capturePackets,
+ setCapturePackets,
+ stage,
+ isRunning,
+ lastBundlePath,
+ run,
+ cancel,
+ reset,
+ openBundleDir,
+ };
+};
+
+export type DebugBundleContextValue = ReturnType;
+
+const DebugBundleContext = createContext(null);
+
+export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
+ const value = useDebugBundle();
+ return {children} ;
+};
+
+export const useDebugBundleContext = () => {
+ const ctx = useContext(DebugBundleContext);
+ if (!ctx) {
+ throw new Error("useDebugBundleContext must be used inside DebugBundleProvider");
+ }
+ return ctx;
+};
diff --git a/client/ui/frontend/src/contexts/DialogContext.tsx b/client/ui/frontend/src/contexts/DialogContext.tsx
new file mode 100644
index 000000000..8a52e0dd0
--- /dev/null
+++ b/client/ui/frontend/src/contexts/DialogContext.tsx
@@ -0,0 +1,68 @@
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { ConfirmModal } from "@/components/dialog/ConfirmModal";
+
+export type ConfirmOptions = {
+ title: ReactNode;
+ description: ReactNode;
+ confirmLabel: string;
+ cancelLabel?: string;
+ danger?: boolean;
+};
+
+type DialogContextValue = {
+ confirm: (options: ConfirmOptions) => Promise;
+};
+
+const DialogContext = createContext(null);
+
+export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
+ const [open, setOpen] = useState(false);
+ const [options, setOptions] = useState(null);
+ const resolverRef = useRef<((result: boolean) => void) | null>(null);
+
+ const confirm = useCallback((opts: ConfirmOptions) => {
+ setOptions(opts);
+ setOpen(true);
+ return new Promise((resolve) => {
+ resolverRef.current = resolve;
+ });
+ }, []);
+
+ const settle = (result: boolean) => {
+ resolverRef.current?.(result);
+ resolverRef.current = null;
+ setOpen(false);
+ };
+
+ const value = useMemo(() => ({ confirm }), [confirm]);
+
+ return (
+
+ {children}
+ settle(true)}
+ onCancel={() => settle(false)}
+ />
+
+ );
+}
+
+export const useConfirm = () => {
+ const ctx = useContext(DialogContext);
+ if (!ctx) throw new Error("useConfirm must be used within a DialogProvider");
+ return ctx.confirm;
+};
diff --git a/client/ui/frontend/src/contexts/NavSectionContext.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx
new file mode 100644
index 000000000..c08a3c824
--- /dev/null
+++ b/client/ui/frontend/src/contexts/NavSectionContext.tsx
@@ -0,0 +1,24 @@
+import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
+
+export type NavSection = "peers" | "networks";
+
+type NavSectionContextValue = {
+ section: NavSection;
+ setSection: (s: NavSection) => void;
+};
+
+const NavSectionContext = createContext(null);
+
+export const useNavSection = (): NavSectionContextValue => {
+ const ctx = useContext(NavSectionContext);
+ if (!ctx) {
+ throw new Error("useNavSection must be used inside NavSectionProvider");
+ }
+ return ctx;
+};
+
+export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
+ const [section, setSection] = useState("peers");
+ const value = useMemo(() => ({ section, setSection }), [section]);
+ return {children} ;
+};
diff --git a/client/ui/frontend/src/contexts/NetworksContext.tsx b/client/ui/frontend/src/contexts/NetworksContext.tsx
new file mode 100644
index 000000000..ef7231700
--- /dev/null
+++ b/client/ui/frontend/src/contexts/NetworksContext.tsx
@@ -0,0 +1,222 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Networks as NetworksSvc } from "@bindings/services";
+import type { Network } from "@bindings/services/models.js";
+import { useStatus } from "@/contexts/StatusContext";
+
+// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node.
+// The daemon may merge a v4+v6 pair into a single comma-joined range string.
+export const isExitNode = (range: string): boolean =>
+ range.split(",").some((part) => {
+ const trimmed = part.trim();
+ return trimmed === "0.0.0.0/0" || trimmed === "::/0";
+ });
+
+type NetworksContextValue = {
+ routes: Network[];
+ networkRoutes: Network[];
+ exitNodes: Network[];
+ activeExitNode: Network | null;
+ refresh: () => Promise;
+ toggleNetwork: (id: string, selected: boolean) => Promise;
+ toggleExitNode: (id: string, selected: boolean) => Promise;
+ setNetworksSelected: (ids: string[], selected: boolean) => Promise;
+};
+
+const NetworksContext = createContext(null);
+
+export const useNetworks = () => {
+ const ctx = useContext(NetworksContext);
+ if (!ctx) {
+ throw new Error("useNetworks must be used inside NetworksProvider");
+ }
+ return ctx;
+};
+
+export const NetworksProvider = ({ children }: { children: ReactNode }) => {
+ const { status } = useStatus();
+ const [routes, setRoutes] = useState([]);
+ const [pending, setPending] = useState>(new Map());
+ const pendingRef = useRef(pending);
+ useEffect(() => {
+ pendingRef.current = pending;
+ }, [pending]);
+
+ // Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever.
+ const STUCK_OVERRIDE_MS = 4000;
+ const timersRef = useRef>>(new Map());
+
+ const clearTimer = useCallback((id: string) => {
+ const tid = timersRef.current.get(id);
+ if (tid !== undefined) {
+ clearTimeout(tid);
+ timersRef.current.delete(id);
+ }
+ }, []);
+
+ const clearPendingFor = useCallback(
+ (ids: string[]) => {
+ for (const id of ids) clearTimer(id);
+ setPending((prev) => {
+ if (ids.every((id) => !prev.has(id))) return prev;
+ const next = new Map(prev);
+ for (const id of ids) next.delete(id);
+ return next;
+ });
+ },
+ [clearTimer],
+ );
+
+ const setPendingFor = useCallback(
+ (updates: Array<[string, boolean]>) => {
+ setPending((prev) => {
+ const next = new Map(prev);
+ for (const [id, sel] of updates) next.set(id, sel);
+ return next;
+ });
+ for (const [id] of updates) {
+ clearTimer(id);
+ timersRef.current.set(
+ id,
+ setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS),
+ );
+ }
+ },
+ [clearTimer, clearPendingFor],
+ );
+
+ useEffect(() => {
+ const timers = timersRef.current;
+ return () => {
+ for (const tid of timers.values()) clearTimeout(tid);
+ timers.clear();
+ };
+ }, []);
+
+ const refresh = useCallback(async () => {
+ try {
+ const list = await NetworksSvc.List();
+ setRoutes(list);
+ } catch (e) {
+ console.error("[NetworksContext] refresh failed", e);
+ }
+ }, []);
+
+ const networksRevision = status?.networksRevision;
+ useEffect(() => {
+ refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err));
+ }, [refresh, networksRevision]);
+
+ useEffect(() => {
+ if (pendingRef.current.size === 0) return;
+ const confirmed: string[] = [];
+ for (const r of routes) {
+ const expected = pendingRef.current.get(r.id);
+ if (expected !== undefined && r.selected === expected) {
+ confirmed.push(r.id);
+ }
+ }
+ if (confirmed.length > 0) clearPendingFor(confirmed);
+ }, [routes, clearPendingFor]);
+
+ const mutate = useCallback(
+ async (ids: string[], selected: boolean, rollback: Array<[string, boolean]>) => {
+ try {
+ if (selected) {
+ await NetworksSvc.Select({ networkIds: ids, append: true, all: false });
+ } else {
+ await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false });
+ }
+ // Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back.
+ await refresh();
+ } catch (e) {
+ console.error(e);
+ setPending((prev) => {
+ const next = new Map(prev);
+ for (const [id] of rollback) next.delete(id);
+ return next;
+ });
+ throw e;
+ }
+ },
+ [refresh],
+ );
+
+ const toggleNetwork = useCallback(
+ async (id: string, selected: boolean) => {
+ const target = !selected;
+ setPendingFor([[id, target]]);
+ await mutate([id], target, [[id, selected]]).catch(() => {});
+ },
+ [mutate, setPendingFor],
+ );
+
+ const setNetworksSelected = useCallback(
+ async (ids: string[], selected: boolean) => {
+ if (ids.length === 0) return;
+ const prevById = new Map(routes.map((r) => [r.id, r.selected]));
+ const rollback: Array<[string, boolean]> = ids.map((id) => [
+ id,
+ prevById.get(id) ?? !selected,
+ ]);
+ setPendingFor(ids.map((id) => [id, selected]));
+ await mutate(ids, selected, rollback).catch(() => {});
+ },
+ [mutate, setPendingFor, routes],
+ );
+
+ // Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches.
+ const toggleExitNode = useCallback(
+ async (id: string, selected: boolean) => {
+ const target = !selected;
+ const updates: Array<[string, boolean]> = [[id, target]];
+ const rollback: Array<[string, boolean]> = [[id, selected]];
+ if (target) {
+ for (const r of routes) {
+ if (r.id !== id && isExitNode(r.range) && r.selected) {
+ updates.push([r.id, false]);
+ rollback.push([r.id, true]);
+ }
+ }
+ }
+ setPendingFor(updates);
+ await mutate([id], target, rollback).catch(() => {});
+ },
+ [mutate, setPendingFor, routes],
+ );
+
+ const value = useMemo(() => {
+ const effective =
+ pending.size === 0
+ ? routes
+ : routes.map((r) => {
+ const override = pending.get(r.id);
+ return override === undefined || override === r.selected
+ ? r
+ : { ...r, selected: override };
+ });
+ const networkRoutes = effective.filter((r) => !isExitNode(r.range));
+ const exitNodes = effective.filter((r) => isExitNode(r.range));
+ const activeExitNode = exitNodes.find((r) => r.selected) ?? null;
+ return {
+ routes: effective,
+ networkRoutes,
+ exitNodes,
+ activeExitNode,
+ refresh,
+ toggleNetwork,
+ toggleExitNode,
+ setNetworksSelected,
+ };
+ }, [routes, pending, refresh, toggleNetwork, toggleExitNode, setNetworksSelected]);
+
+ return {children} ;
+};
diff --git a/client/ui/frontend/src/contexts/PeerDetailContext.tsx b/client/ui/frontend/src/contexts/PeerDetailContext.tsx
new file mode 100644
index 000000000..3ab20891f
--- /dev/null
+++ b/client/ui/frontend/src/contexts/PeerDetailContext.tsx
@@ -0,0 +1,50 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import type { PeerStatus } from "@bindings/services/models.js";
+
+type PeerDetailContextValue = {
+ selected: PeerStatus | null;
+ setSelected: (p: PeerStatus | null) => void;
+};
+
+const PeerDetailContext = createContext(null);
+
+export const usePeerDetail = (): PeerDetailContextValue => {
+ const ctx = useContext(PeerDetailContext);
+ if (!ctx) {
+ throw new Error("usePeerDetail must be used inside PeerDetailProvider");
+ }
+ return ctx;
+};
+
+export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
+ const [selected, setSelected] = useState(null);
+ const openerRef = useRef(null);
+
+ const select = useCallback((p: PeerStatus | null) => {
+ if (p) {
+ const active = document.activeElement;
+ openerRef.current = active instanceof HTMLElement ? active : null;
+ } else {
+ const opener = openerRef.current;
+ openerRef.current = null;
+ if (opener?.isConnected) {
+ queueMicrotask(() => opener.focus());
+ }
+ }
+ setSelected(p);
+ }, []);
+
+ const value = useMemo(
+ () => ({ selected, setSelected: select }),
+ [selected, select],
+ );
+ return {children} ;
+};
diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx
new file mode 100644
index 000000000..62377f1bc
--- /dev/null
+++ b/client/ui/frontend/src/contexts/ProfileContext.tsx
@@ -0,0 +1,195 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Events } from "@wailsio/runtime";
+import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services";
+import type { Profile } from "@bindings/services/models.js";
+import i18next from "@/lib/i18n";
+import { errorDialog, formatErrorMessage } from "@/lib/errors";
+
+const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
+
+type ProfileContextValue = {
+ username: string;
+ // activeProfile is the display NAME of the active profile (for rendering
+ // and the "default" check). activeProfileId is its stable on-disk ID, used
+ // as the handle for daemon requests and for active-profile comparisons,
+ // since display names can collide.
+ activeProfile: string;
+ activeProfileId: string;
+ profiles: Profile[];
+ loaded: boolean;
+ refresh: () => Promise;
+ switchProfile: (id: string) => Promise;
+ switchProfileNoConnect: (id: string) => Promise;
+ addProfile: (name: string) => Promise;
+ removeProfile: (id: string) => Promise;
+ renameProfile: (id: string, newName: string) => Promise;
+ logoutProfile: (id: string) => Promise;
+};
+
+const ProfileContext = createContext(null);
+
+export const useProfile = () => {
+ const ctx = useContext(ProfileContext);
+ if (!ctx) {
+ throw new Error("useProfile must be used inside ProfileProvider");
+ }
+ return ctx;
+};
+
+export const ProfileProvider = ({ children }: { children: ReactNode }) => {
+ const [username, setUsername] = useState("");
+ const [activeProfile, setActiveProfile] = useState("");
+ const [activeProfileId, setActiveProfileId] = useState("");
+ const [profiles, setProfiles] = useState([]);
+ const [loaded, setLoaded] = useState(false);
+ const retryRef = useRef | null>(null);
+
+ const refresh = useCallback(async () => {
+ if (retryRef.current) {
+ clearTimeout(retryRef.current);
+ retryRef.current = null;
+ }
+ try {
+ const u = await ProfilesSvc.Username();
+ const [active, list] = await Promise.all([
+ ProfilesSvc.GetActive(),
+ ProfilesSvc.List(u),
+ ]);
+ setUsername(u);
+ setActiveProfile(active.profileName || "default");
+ setActiveProfileId(active.id || "default");
+ setProfiles(list);
+ setLoaded(true);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ if (msg.includes("code = Unavailable")) {
+ retryRef.current = setTimeout(() => {
+ void refresh();
+ }, 1000);
+ return;
+ }
+ setLoaded(true);
+ await errorDialog({
+ Title: i18next.t("profile.error.loadTitle"),
+ Message: formatErrorMessage(e),
+ });
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err));
+ return () => {
+ if (retryRef.current) clearTimeout(retryRef.current);
+ };
+ }, [refresh]);
+
+ useEffect(() => {
+ const off = Events.On(EVENT_PROFILE_CHANGED, () => {
+ refresh().catch((err: unknown) =>
+ console.error("[ProfileContext] refresh failed", err),
+ );
+ });
+ return () => {
+ off();
+ };
+ }, [refresh]);
+
+ // id is a handle: the daemon resolves an exact ID, ID prefix, or unique
+ // display name. The UI passes the profile's ID for precision.
+ const switchProfile = useCallback(
+ async (id: string) => {
+ await ProfileSwitcher.SwitchActive({ profileName: id, username });
+ await refresh();
+ },
+ [username, refresh],
+ );
+
+ // Manage-profiles variant: switches without connecting, so the user can
+ // still adjust the management URL before bringing the connection up.
+ const switchProfileNoConnect = useCallback(
+ async (id: string) => {
+ await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username });
+ await refresh();
+ },
+ [username, refresh],
+ );
+
+ // addProfile creates a profile by display name and returns the
+ // daemon-generated ID, so the caller can immediately address it by ID.
+ const addProfile = useCallback(
+ async (name: string) => {
+ const id = await ProfilesSvc.Add({ profileName: name, username });
+ await refresh();
+ return id;
+ },
+ [username, refresh],
+ );
+
+ const removeProfile = useCallback(
+ async (id: string) => {
+ await ProfilesSvc.Remove({ profileName: id, username });
+ await refresh();
+ },
+ [username, refresh],
+ );
+
+ // The daemon resolves the handle (exact ID, ID prefix, or unique display
+ // name) — passing the ID is precise and avoids collisions on rename.
+ const renameProfile = useCallback(
+ async (id: string, newName: string) => {
+ await ProfilesSvc.Rename({ handle: id, newName, username });
+ await refresh();
+ },
+ [username, refresh],
+ );
+
+ const logoutProfile = useCallback(
+ async (id: string) => {
+ await Connection.Logout({ profileName: id, username });
+ await refresh();
+ },
+ [username, refresh],
+ );
+
+ const value = useMemo(
+ () => ({
+ username,
+ activeProfile,
+ activeProfileId,
+ profiles,
+ loaded,
+ refresh,
+ switchProfile,
+ switchProfileNoConnect,
+ addProfile,
+ removeProfile,
+ renameProfile,
+ logoutProfile,
+ }),
+ [
+ username,
+ activeProfile,
+ activeProfileId,
+ profiles,
+ loaded,
+ refresh,
+ switchProfile,
+ switchProfileNoConnect,
+ addProfile,
+ removeProfile,
+ renameProfile,
+ logoutProfile,
+ ],
+ );
+
+ return {children} ;
+};
diff --git a/client/ui/frontend/src/contexts/RestrictionsContext.tsx b/client/ui/frontend/src/contexts/RestrictionsContext.tsx
new file mode 100644
index 000000000..572bf17ec
--- /dev/null
+++ b/client/ui/frontend/src/contexts/RestrictionsContext.tsx
@@ -0,0 +1,65 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Events } from "@wailsio/runtime";
+import { Settings as SettingsSvc } from "@bindings/services";
+import { Restrictions } from "@bindings/services/models.js";
+import { useStatus } from "@/contexts/StatusContext.tsx";
+
+const EVENT_SYSTEM = "netbird:event";
+const EMPTY = new Restrictions();
+
+const RestrictionsContext = createContext(EMPTY);
+
+export const useRestrictions = () => useContext(RestrictionsContext);
+
+export const RestrictionsProvider = ({ children }: { children: ReactNode }) => {
+ const [restrictions, setRestrictions] = useState(EMPTY);
+ const mounted = useRef(true);
+ const { status } = useStatus();
+
+ const refresh = useCallback(async () => {
+ try {
+ const r = await SettingsSvc.GetRestrictions();
+ if (mounted.current) setRestrictions(r);
+ } catch (e) {
+ console.error("[RestrictionsContext] refresh failed", e);
+ }
+ }, []);
+
+ useEffect(() => {
+ mounted.current = true;
+
+ const off = Events.On(
+ EVENT_SYSTEM,
+ (e: { data?: { metadata?: { [k: string]: string | undefined } } }) => {
+ if (e.data?.metadata?.type === "config_changed") refresh();
+ },
+ );
+
+ const onVisible = () => {
+ if (document.visibilityState === "visible") refresh();
+ };
+ document.addEventListener("visibilitychange", onVisible);
+
+ return () => {
+ mounted.current = false;
+ off();
+ document.removeEventListener("visibilitychange", onVisible);
+ };
+ }, [refresh]);
+
+ useEffect(() => {
+ if (status?.status) refresh();
+ }, [status?.status, refresh]);
+
+ return (
+ {children}
+ );
+};
diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx
new file mode 100644
index 000000000..3f4b2d0d2
--- /dev/null
+++ b/client/ui/frontend/src/contexts/SettingsContext.tsx
@@ -0,0 +1,289 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Events } from "@wailsio/runtime";
+import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services";
+import type { Config } from "@bindings/services/models.js";
+import i18next from "@/lib/i18n";
+import { useProfile } from "@/contexts/ProfileContext.tsx";
+import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
+import { errorCommand, errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
+
+const SAVE_DEBOUNCE_MS = 400;
+
+const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err);
+
+export type AutostartState = { supported: boolean; enabled: boolean };
+
+type SettingsContextValue = {
+ config: Config;
+ guiVersion: string;
+ setField: (k: K, v: Config[K]) => void;
+ saveField: (k: K, v: Config[K]) => Promise;
+ saveFields: (partial: Partial, opts?: { preSharedKey?: string }) => Promise;
+ saveNow: () => Promise;
+};
+
+type AutostartContextValue = {
+ autostart: AutostartState | null;
+ setAutostartEnabled: (enabled: boolean) => Promise;
+};
+
+const SettingsContext = createContext(null);
+const AutostartContext = createContext(null);
+
+export const useSettings = () => {
+ const ctx = useContext(SettingsContext);
+ if (!ctx) {
+ throw new Error("useSettings must be used inside SettingsProvider");
+ }
+ return ctx;
+};
+
+export const useAutostartSetting = () => {
+ const ctx = useContext(AutostartContext);
+ if (!ctx) {
+ throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider");
+ }
+ return ctx;
+};
+
+type LoadedConfig = { profileName: string; data: Config };
+
+const useSettingsState = () => {
+ const { username, activeProfileId, loaded: profileLoaded } = useProfile();
+ const [loaded, setLoaded] = useState(null);
+ const [guiVersion, setGuiVersion] = useState("—");
+ const saveTimer = useRef | null>(null);
+ const loadedRef = useRef(null);
+
+ useEffect(() => {
+ loadedRef.current = loaded;
+ }, [loaded]);
+
+ // reload re-reads the daemon's config, which is authoritative. Used on
+ // mount, on the daemon's config_changed event, and to undo an optimistic
+ // update the daemon then rejected.
+ const reload = useCallback(
+ async (profileName: string) => {
+ try {
+ const data = await SettingsSvc.GetConfig({ profileName, username });
+ setLoaded({ profileName, data });
+ } catch (e) {
+ console.warn("[SettingsContext] reload after rejected save failed", e);
+ }
+ },
+ [username],
+ );
+
+ useEffect(() => {
+ if (!profileLoaded || !activeProfileId) return;
+ let cancelled = false;
+
+ const load = async (showError: boolean) => {
+ try {
+ const data = await SettingsSvc.GetConfig({
+ profileName: activeProfileId,
+ username,
+ });
+ if (cancelled) return;
+ if (saveTimer.current) return;
+ setLoaded({ profileName: activeProfileId, data });
+ } catch (e) {
+ if (cancelled || !showError) return;
+ await errorDialog({
+ Title: i18next.t("settings.error.loadTitle"),
+ Message: errorMessage(e),
+ });
+ }
+ };
+
+ load(true);
+
+ const off = Events.On(
+ "netbird:event",
+ (e: { data?: { metadata?: { [k: string]: string | undefined } } }) => {
+ if (e.data?.metadata?.type === "config_changed") load(false);
+ },
+ );
+
+ return () => {
+ cancelled = true;
+ off();
+ };
+ }, [profileLoaded, activeProfileId, username]);
+
+ useEffect(() => {
+ let cancelled = false;
+ Version.GUI().then((v) => {
+ if (!cancelled) setGuiVersion(v);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(
+ () => () => {
+ if (saveTimer.current) clearTimeout(saveTimer.current);
+ },
+ [],
+ );
+
+ const save = useCallback(
+ async (profileName: string, next: Config, preSharedKey?: string) => {
+ const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
+ try {
+ await SettingsSvc.SetConfig({
+ ...next,
+ ...preSharedKeyWrite,
+ profileName,
+ username,
+ });
+ } catch (e) {
+ // The optimistic update is wrong now: the daemon refused it
+ // (a change that needs elevated privileges, an MDM-managed
+ // field, ...). Snap the controls back to what it actually
+ // holds before reporting, so the UI never shows a value the
+ // daemon does not have.
+ await reload(profileName);
+ await errorDialog({
+ Title: i18next.t("settings.error.saveTitle"),
+ Message: errorMessage(e),
+ Command: errorCommand(e),
+ });
+ }
+ },
+ [username, reload],
+ );
+
+ const setField = useCallback(
+ (k: K, v: Config[K]) => {
+ const cur = loadedRef.current;
+ if (!cur) return;
+ const next: LoadedConfig = {
+ profileName: cur.profileName,
+ data: { ...cur.data, [k]: v },
+ };
+ loadedRef.current = next;
+ setLoaded(next);
+ if (saveTimer.current) clearTimeout(saveTimer.current);
+ saveTimer.current = setTimeout(() => {
+ saveTimer.current = null;
+ save(next.profileName, next.data).catch(logSaveError);
+ }, SAVE_DEBOUNCE_MS);
+ },
+ [save],
+ );
+
+ const saveNow = useCallback(async () => {
+ if (!loaded) return;
+ if (saveTimer.current) {
+ clearTimeout(saveTimer.current);
+ saveTimer.current = null;
+ }
+ await save(loaded.profileName, loaded.data);
+ }, [loaded, save]);
+
+ const saveField = useCallback(
+ async (k: K, v: Config[K]) => {
+ if (!loaded) return;
+ if (saveTimer.current) {
+ clearTimeout(saveTimer.current);
+ saveTimer.current = null;
+ }
+ const next = { ...loaded.data, [k]: v };
+ setLoaded({ profileName: loaded.profileName, data: next });
+ await save(loaded.profileName, next);
+ },
+ [loaded, save],
+ );
+
+ const saveFields = useCallback(
+ async (partial: Partial, opts?: { preSharedKey?: string }) => {
+ if (!loaded) return;
+ if (saveTimer.current) {
+ clearTimeout(saveTimer.current);
+ saveTimer.current = null;
+ }
+
+ const merged: Config = { ...loaded.data, ...partial };
+ const next: Config =
+ opts?.preSharedKey === undefined
+ ? merged
+ : { ...merged, preSharedKeySet: opts.preSharedKey !== "" };
+ setLoaded({ profileName: loaded.profileName, data: next });
+ await save(loaded.profileName, next, opts?.preSharedKey);
+ },
+ [loaded, save],
+ );
+
+ return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
+};
+
+export const SettingsProvider = ({ children }: { children: ReactNode }) => {
+ const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
+
+ const value = useMemo(
+ () => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
+ [config, guiVersion, setField, saveField, saveFields, saveNow],
+ );
+
+ if (!value) {
+ return (
+
+
+
+ );
+ }
+
+ return {children} ;
+};
+
+export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) => {
+ const [autostart, setAutostart] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ const supported = await Autostart.Supported();
+ const enabled = supported ? await Autostart.IsEnabled() : false;
+ if (cancelled) return;
+ setAutostart({ supported, enabled });
+ })().catch((err: unknown) => {
+ if (cancelled) return;
+ console.warn("[SettingsContext] load autostart state failed", err);
+ setAutostart({ supported: false, enabled: false });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const setAutostartEnabled = useCallback(async (enabled: boolean) => {
+ setAutostart((s) => (s ? { ...s, enabled } : s));
+ try {
+ await Autostart.SetEnabled(enabled);
+ } catch (e) {
+ setAutostart((s) => (s ? { ...s, enabled: !enabled } : s));
+ await errorDialog({
+ Title: i18next.t("settings.general.autostart.errorTitle"),
+ Message: errorMessage(e),
+ });
+ }
+ }, []);
+
+ const value = useMemo(
+ () => ({ autostart, setAutostartEnabled }),
+ [autostart, setAutostartEnabled],
+ );
+
+ return {children} ;
+};
diff --git a/client/ui/frontend/src/contexts/StatusContext.tsx b/client/ui/frontend/src/contexts/StatusContext.tsx
new file mode 100644
index 000000000..0ad9c7875
--- /dev/null
+++ b/client/ui/frontend/src/contexts/StatusContext.tsx
@@ -0,0 +1,106 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+import { Events } from "@wailsio/runtime";
+import { DaemonFeed } from "@bindings/services";
+import { Status } from "@bindings/services/models.js";
+import { DaemonOutdatedOverlay } from "@/components/empty-state/DaemonOutdatedOverlay.tsx";
+import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx";
+import { isDaemonCompatible } from "@/lib/compat";
+
+const EVENT_STATUS = "netbird:status";
+
+type StatusContextValue = {
+ status: Status | null;
+ error: string | null;
+ refresh: () => Promise;
+ isReady: boolean;
+ isDaemonUnavailable: boolean;
+ isDaemonAvailable: boolean;
+ isDaemonOutdated: boolean;
+};
+
+const StatusContext = createContext(null);
+
+export const useStatus = () => {
+ const ctx = useContext(StatusContext);
+ if (!ctx) {
+ throw new Error("useStatus must be used inside StatusProvider");
+ }
+ return ctx;
+};
+
+export const StatusProvider = ({ children }: { children: ReactNode }) => {
+ const [status, setStatus] = useState(null);
+ const [error, setError] = useState(null);
+ const [isDaemonOutdated, setIsDaemonOutdated] = useState(false);
+
+ const refresh = useCallback(async () => {
+ try {
+ const s = await DaemonFeed.Get();
+ setStatus(s);
+ setError(null);
+ } catch (e) {
+ // Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise).
+ setStatus(Status.createFrom({ status: "DaemonUnavailable" }));
+ setError(String(e));
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err));
+ const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
+ setStatus(ev.data);
+ setError(null);
+ });
+ return () => {
+ off();
+ };
+ }, [refresh]);
+
+ const isReady = status !== null;
+ const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable";
+ const isDaemonAvailable = isReady && !isDaemonUnavailable;
+
+ useEffect(() => {
+ if (!isDaemonAvailable) return;
+ let cancelled = false;
+ isDaemonCompatible()
+ .then((ok) => {
+ if (!cancelled) setIsDaemonOutdated(!ok);
+ })
+ .catch((err) => {
+ console.error("[StatusContext] daemon compatible error", err);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [isDaemonAvailable]);
+
+ const value = useMemo(
+ () => ({
+ status,
+ error,
+ refresh,
+ isReady,
+ isDaemonUnavailable,
+ isDaemonAvailable,
+ isDaemonOutdated,
+ }),
+ [status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable, isDaemonOutdated],
+ );
+
+ return (
+
+ {isDaemonAvailable && !isDaemonOutdated && children}
+
+
+
+ );
+};
diff --git a/client/ui/frontend/src/contexts/ViewModeContext.tsx b/client/ui/frontend/src/contexts/ViewModeContext.tsx
new file mode 100644
index 000000000..7db3abae0
--- /dev/null
+++ b/client/ui/frontend/src/contexts/ViewModeContext.tsx
@@ -0,0 +1,89 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Window } from "@wailsio/runtime";
+import { Preferences } from "@bindings/services";
+import { ViewMode as ViewModePref } from "@bindings/preferences/models.js";
+
+export type ViewMode = "default" | "advanced";
+
+// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px
+// title bar) while creation is content, so re-asserting a constant chops the content on first switch.
+export const VIEW_WIDTH: Record = {
+ default: 380,
+ advanced: 900,
+};
+
+type ViewModeContextValue = {
+ viewMode: ViewMode;
+ setViewMode: (mode: ViewMode) => void;
+};
+
+const ViewModeContext = createContext(null);
+
+export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
+ const [mode, setMode] = useState("default");
+ const modeRef = useRef("default");
+
+ useEffect(() => {
+ let cancelled = false;
+ Preferences.Get()
+ .then((prefs) => {
+ if (cancelled) return;
+ const saved = prefs?.viewMode as ViewMode | undefined;
+ if (saved === "default" || saved === "advanced") {
+ modeRef.current = saved;
+ setMode(saved);
+ }
+ })
+ .catch((err: unknown) =>
+ console.warn("[ViewModeContext] load preferences failed", err),
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Resize before flipping React state, else the layout paints into a window that hasn't grown yet.
+ const setViewMode = useCallback((mode: ViewMode) => {
+ if (modeRef.current === mode) return;
+ modeRef.current = mode;
+ (async () => {
+ const size = await Window.Size().catch((err: unknown) => {
+ console.warn("[ViewModeContext] read window size failed", err);
+ return null;
+ });
+ const width = VIEW_WIDTH[mode];
+ const height = size?.height ?? 640;
+ await Window.SetSize(width, height).catch((err: unknown) =>
+ console.warn("[ViewModeContext] set window size failed", err),
+ );
+ setMode(mode);
+ const pref =
+ mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault;
+ Preferences.SetViewMode(pref).catch((err: unknown) =>
+ console.error("[ViewModeContext] SetViewMode failed", err),
+ );
+ })().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err));
+ }, []);
+
+ const value = useMemo(
+ () => ({ viewMode: mode, setViewMode }),
+ [mode, setViewMode],
+ );
+
+ return {children} ;
+};
+
+export const useViewMode = () => {
+ const ctx = useContext(ViewModeContext);
+ if (!ctx) throw new Error("useViewMode must be used inside ViewModeProvider");
+ return ctx;
+};
diff --git a/client/ui/frontend/src/globals.css b/client/ui/frontend/src/globals.css
new file mode 100644
index 000000000..84ddfdcfe
--- /dev/null
+++ b/client/ui/frontend/src/globals.css
@@ -0,0 +1,45 @@
+@font-face {
+ font-family: "Inter Variable";
+ font-style: normal;
+ font-weight: 100 900;
+ src: url("./assets/fonts/inter-variable.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "JetBrains Mono Variable";
+ font-style: normal;
+ font-weight: 100 800;
+ src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype");
+}
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html,
+body,
+#root {
+ height: 100%;
+ overflow: hidden;
+}
+
+/*
+ * Body bg is fully opaque on purpose. The main window uses
+ * MacBackdropTranslucent (main.go) and TitleBarHiddenInset, which on macOS
+ * lets the desktop wallpaper bleed through any non-opaque pixel. A 90%
+ * body alpha meant two machines with different wallpapers saw different
+ * effective backgrounds. Matching Wails' BackgroundColour (#181A1D / nb-gray
+ * DEFAULT) here keeps things consistent regardless of the OS backdrop.
+ */
+body {
+ @apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
+}
+
+.wails-draggable {
+ --wails-draggable: drag;
+ cursor: default;
+}
+
+.wails-no-draggable {
+ --wails-draggable: no-drag;
+}
diff --git a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts
new file mode 100644
index 000000000..d4f4d80b2
--- /dev/null
+++ b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts
@@ -0,0 +1,60 @@
+import { useLayoutEffect, useRef } from "react";
+import { Window } from "@wailsio/runtime";
+import i18next from "@/lib/i18n";
+import { isLinux } from "@/lib/platform";
+
+// Sizes the current Wails window to the measured content height (keeping `width`),
+// then shows it. Re-applies on content resize and language change.
+export function useAutoSizeWindow(width: number, ready: boolean = true) {
+ const ref = useRef(null);
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ let shown = false;
+ let raf1 = 0;
+ let raf2 = 0;
+ const showOnce = () => {
+ if (shown) return;
+ shown = true;
+ Window.Show().catch(() => {});
+ Window.Focus().catch(() => {});
+ };
+ const apply = async () => {
+ if (!ready) return;
+ const h = Math.ceil(el.getBoundingClientRect().height);
+ if (h <= 0) return;
+ try {
+ // Window.SetSize takes the frame size, so add the OS title-bar height or content clips.
+ const frame = await Window.Size();
+ const targetH = h + Math.max(0, frame.height - window.innerHeight);
+ // Linux: SetSize no-ops on a mapped non-resizable window (X11), so pin via min/max instead.
+ if (isLinux()) {
+ await Window.SetMinSize(width, targetH);
+ await Window.SetMaxSize(width, targetH);
+ }
+ await Window.SetSize(width, targetH);
+ showOnce();
+ } catch {
+ // window gone / not ready — ignore
+ }
+ };
+ const scheduleApply = () => {
+ cancelAnimationFrame(raf1);
+ cancelAnimationFrame(raf2);
+ raf1 = requestAnimationFrame(() => {
+ raf2 = requestAnimationFrame(apply);
+ });
+ };
+ apply();
+ const ro = new ResizeObserver(apply);
+ ro.observe(el);
+ i18next.on("languageChanged", scheduleApply);
+ return () => {
+ ro.disconnect();
+ cancelAnimationFrame(raf1);
+ cancelAnimationFrame(raf2);
+ i18next.off("languageChanged", scheduleApply);
+ };
+ }, [width, ready]);
+ return ref;
+}
diff --git a/client/ui/frontend/src/hooks/useFocusVisible.ts b/client/ui/frontend/src/hooks/useFocusVisible.ts
new file mode 100644
index 000000000..6061beb9c
--- /dev/null
+++ b/client/ui/frontend/src/hooks/useFocusVisible.ts
@@ -0,0 +1,49 @@
+import { useEffect, useState } from "react";
+
+// Tracks the user's current input modality (keyboard vs pointer) at module
+// scope, mirroring what @react-aria/interactions does. Radix programmatically
+// focuses elements like Tabs triggers and Select triggers, which makes the
+// browser's :focus-visible heuristic light up on mouse-driven interactions too.
+// Gating focus styles on this hook lets us only paint a focus ring when the
+// user is actually navigating with the keyboard.
+// See react-aria's useFocusVisible for context.
+
+type Modality = "keyboard" | "pointer";
+
+let currentModality: Modality = "pointer";
+const subscribers = new Set<(m: Modality) => void>();
+
+const setModality = (m: Modality) => {
+ if (m === currentModality) return;
+ currentModality = m;
+ subscribers.forEach((cb) => cb(m));
+};
+
+const isKeyboardEvent = (e: KeyboardEvent) => {
+ if (e.metaKey || e.ctrlKey || e.altKey) return false;
+ return e.key === "Tab" || e.key === "Escape" || e.key.startsWith("Arrow");
+};
+
+if (globalThis.window !== undefined) {
+ globalThis.addEventListener(
+ "keydown",
+ (e) => {
+ if (isKeyboardEvent(e)) setModality("keyboard");
+ },
+ true,
+ );
+ globalThis.addEventListener("pointerdown", () => setModality("pointer"), true);
+}
+
+export const useFocusVisible = (): boolean => {
+ const [visible, setVisible] = useState(currentModality === "keyboard");
+ useEffect(() => {
+ setVisible(currentModality === "keyboard");
+ const cb = (m: Modality) => setVisible(m === "keyboard");
+ subscribers.add(cb);
+ return () => {
+ subscribers.delete(cb);
+ };
+ }, []);
+ return visible;
+};
diff --git a/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts b/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts
new file mode 100644
index 000000000..eb68dd997
--- /dev/null
+++ b/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts
@@ -0,0 +1,35 @@
+import { useCallback, useEffect, useState } from "react";
+import { Preferences } from "@bindings/services";
+
+export const useKeepConnectedOnQuit = () => {
+ const [keepConnected, setKeepConnected] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ Preferences.Get()
+ .then((prefs) => {
+ if (cancelled) return;
+ setKeepConnected(prefs?.keepConnectedOnQuit ?? false);
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return;
+ console.warn("[useKeepConnectedOnQuit] load preferences failed", err);
+ setKeepConnected(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const setKeepConnectedOnQuit = useCallback(async (keep: boolean) => {
+ setKeepConnected(keep);
+ try {
+ await Preferences.SetKeepConnectedOnQuit(keep);
+ } catch (err: unknown) {
+ setKeepConnected(!keep);
+ console.error("[useKeepConnectedOnQuit] SetKeepConnectedOnQuit failed", err);
+ }
+ }, []);
+
+ return { keepConnected, setKeepConnectedOnQuit };
+};
diff --git a/client/ui/frontend/src/hooks/useKeyboardShortcut.ts b/client/ui/frontend/src/hooks/useKeyboardShortcut.ts
new file mode 100644
index 000000000..ea67d8375
--- /dev/null
+++ b/client/ui/frontend/src/hooks/useKeyboardShortcut.ts
@@ -0,0 +1,46 @@
+import { useEffect } from "react";
+import { isMacOS } from "@/lib/platform";
+
+export type Shortcut = {
+ key: string;
+ cmd?: boolean;
+ shift?: boolean;
+ alt?: boolean;
+ preventDefault?: boolean;
+};
+
+export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, enabled = true) => {
+ useEffect(() => {
+ if (!enabled) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key.toLowerCase() !== shortcut.key.toLowerCase()) return;
+ const mod = e.metaKey || e.ctrlKey;
+ if (!!shortcut.cmd !== mod) return;
+ if (!!shortcut.shift !== e.shiftKey) return;
+ if (!!shortcut.alt !== e.altKey) return;
+ if (shortcut.preventDefault !== false) e.preventDefault();
+ callback();
+ };
+ globalThis.addEventListener("keydown", onKey);
+ return () => globalThis.removeEventListener("keydown", onKey);
+ }, [
+ shortcut.key,
+ shortcut.cmd,
+ shortcut.shift,
+ shortcut.alt,
+ shortcut.preventDefault,
+ callback,
+ enabled,
+ ]);
+};
+
+export const formatShortcut = (shortcut: Shortcut): string => {
+ // navigator.platform is empty on some WebView2 builds → misrenders ⌘ as Ctrl on Mac.
+ const mac = isMacOS();
+ const parts: string[] = [];
+ if (shortcut.cmd) parts.push(mac ? "⌘" : "Ctrl");
+ if (shortcut.shift) parts.push(mac ? "⇧" : "Shift");
+ if (shortcut.alt) parts.push(mac ? "⌥" : "Alt");
+ parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key);
+ return parts.join(mac ? "" : "+");
+};
diff --git a/client/ui/frontend/src/hooks/useManagementUrl.ts b/client/ui/frontend/src/hooks/useManagementUrl.ts
new file mode 100644
index 000000000..6a0ef1b81
--- /dev/null
+++ b/client/ui/frontend/src/hooks/useManagementUrl.ts
@@ -0,0 +1,143 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useSettings } from "@/contexts/SettingsContext.tsx";
+import { useConfirm } from "@/contexts/DialogContext.tsx";
+
+export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
+const CLOUD_MANAGEMENT_URLS = new Set([
+ CLOUD_MANAGEMENT_URL,
+ "https://api.wiretrustee.com:443", // legacy cloud endpoint
+]);
+
+export function isNetbirdCloud(url: string): boolean {
+ if (!url || url.trim() === "") return true;
+ return CLOUD_MANAGEMENT_URLS.has(url);
+}
+
+// Matches http(s)://host[:port][/path][?query][#fragment]; host = domain, localhost, or IPv4.
+// Syntactic validation only — reachability is checked via checkManagementUrlReachable.
+export const URL_PATTERN = new RegExp(
+ String.raw`^(https?:\/\/)?` +
+ String.raw`((([a-z\d]([a-z\d-]*[a-z\d])?)\.)+[a-z]{2,}|localhost|` +
+ String.raw`((\d{1,3}\.){3}\d{1,3}))` +
+ String.raw`(\:\d+)?(\/[-a-z\d%_.~+]*)*` +
+ String.raw`(\?[;&a-z\d%_.~+=-]*)?` +
+ String.raw`(\#[-a-z\d_]*)?$`,
+ "i",
+);
+
+export function normalizeManagementUrl(input: string): string {
+ const trimmed = input.trim();
+ if (!trimmed) return "";
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
+ return `https://${trimmed}`;
+}
+
+export function isValidManagementUrl(input: string): boolean {
+ const trimmed = input.trim();
+ if (!trimmed) return false;
+ return URL_PATTERN.test(trimmed);
+}
+
+// Can false-negative for self-hosted behind internal DNS / self-signed certs — treat as a soft warning, not a hard block.
+export async function checkManagementUrlReachable(
+ url: string,
+ timeoutMs: number = 5000,
+): Promise {
+ const target = normalizeManagementUrl(url);
+ if (!target) return false;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ await fetch(target, { method: "GET", mode: "no-cors", signal: controller.signal });
+ return true;
+ } catch {
+ return false;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+export enum ManagementMode {
+ Cloud = "cloud",
+ SelfHosted = "selfhosted",
+}
+
+function modeFromUrl(url: string): ManagementMode {
+ return isNetbirdCloud(url) ? ManagementMode.Cloud : ManagementMode.SelfHosted;
+}
+
+export function useManagementUrl() {
+ const { t } = useTranslation();
+ const confirm = useConfirm();
+ const { config, saveField } = useSettings();
+ const [modeState, setModeState] = useState(modeFromUrl(config.managementUrl));
+ const [url, setUrl] = useState(
+ isNetbirdCloud(config.managementUrl) ? "" : config.managementUrl,
+ );
+ const [checking, setChecking] = useState(false);
+ const [unreachable, setUnreachable] = useState(false);
+
+ useEffect(() => {
+ setModeState(modeFromUrl(config.managementUrl));
+ if (!isNetbirdCloud(config.managementUrl)) {
+ setUrl(config.managementUrl);
+ }
+ }, [config.managementUrl]);
+
+ useEffect(() => {
+ setUnreachable(false);
+ }, [url, modeState]);
+
+ const setMode = async (next: ManagementMode) => {
+ if (next === ManagementMode.Cloud && !isNetbirdCloud(config.managementUrl)) {
+ const ok = await confirm({
+ title: t("settings.general.management.switchCloudTitle"),
+ description: t("settings.general.management.switchCloudMessage"),
+ confirmLabel: t("settings.general.management.switchCloudConfirm"),
+ });
+ if (!ok) return;
+ setModeState(ManagementMode.Cloud);
+ saveField("managementUrl", CLOUD_MANAGEMENT_URL).catch((err: unknown) =>
+ console.error("save managementUrl failed", err),
+ );
+ return;
+ }
+ setModeState(next);
+ };
+
+ const normalizedUrl = normalizeManagementUrl(url);
+ const urlValid = isValidManagementUrl(url);
+ const targetUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
+ const dirty = targetUrl !== config.managementUrl;
+ const showError = modeState === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
+ const canSave = dirty && (modeState === ManagementMode.Cloud || urlValid);
+ const displayUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
+
+ const save = async () => {
+ if (modeState === ManagementMode.SelfHosted && !unreachable) {
+ setChecking(true);
+ const reachable = await checkManagementUrlReachable(targetUrl);
+ setChecking(false);
+ if (!reachable) {
+ setUnreachable(true);
+ return;
+ }
+ }
+ await saveField("managementUrl", targetUrl);
+ setUnreachable(false);
+ };
+
+ return {
+ mode: modeState,
+ setMode,
+ url,
+ setUrl,
+ displayUrl,
+ showError,
+ canSave,
+ save,
+ checking,
+ unreachable,
+ };
+}
diff --git a/client/ui/frontend/src/hooks/usePrivilege.ts b/client/ui/frontend/src/hooks/usePrivilege.ts
new file mode 100644
index 000000000..05e9a7ce0
--- /dev/null
+++ b/client/ui/frontend/src/hooks/usePrivilege.ts
@@ -0,0 +1,32 @@
+import { useEffect, useState } from "react";
+import { Settings as SettingsSvc } from "@bindings/services";
+import { Privilege } from "@bindings/services/models.js";
+
+// usePrivilege reports whether this UI process may perform the changes the daemon
+// restricts to root/administrator. It is answered in-process from our own token
+// with the daemon's own rule, so there is no round-trip and it works while the
+// daemon is down.
+//
+// null means "not known yet", which includes the read having failed. Callers must
+// treat that as "do not restrict": the daemon enforces this regardless, so the
+// only thing a wrong guess here costs is a control that looks unavailable when it
+// is not, or a save that fails with the daemon's own guidance.
+export const usePrivilege = (): Privilege | null => {
+ const [privilege, setPrivilege] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ SettingsSvc.Privilege()
+ .then((p) => {
+ if (!cancelled) setPrivilege(p);
+ })
+ .catch((e: unknown) => {
+ console.warn("[usePrivilege] read failed, not restricting controls", e);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return privilege;
+};
diff --git a/client/ui/frontend/src/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx
new file mode 100644
index 000000000..1588d9d08
--- /dev/null
+++ b/client/ui/frontend/src/layouts/AppLayout.tsx
@@ -0,0 +1,27 @@
+import { Outlet } from "react-router-dom";
+import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx";
+import { StatusProvider } from "@/contexts/StatusContext.tsx";
+import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
+import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
+import { DialogProvider } from "@/contexts/DialogContext.tsx";
+import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx";
+
+export const AppLayout = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/ui/frontend/src/layouts/AppRightPanel.tsx b/client/ui/frontend/src/layouts/AppRightPanel.tsx
new file mode 100644
index 000000000..8474bd71f
--- /dev/null
+++ b/client/ui/frontend/src/layouts/AppRightPanel.tsx
@@ -0,0 +1,38 @@
+import { type ReactNode } from "react";
+import { motion } from "framer-motion";
+import { cn } from "@/lib/cn.ts";
+
+type Props = {
+ children: ReactNode;
+ overlay?: ReactNode;
+ overlayOpen?: boolean;
+ className?: string;
+};
+
+const PANEL_TRANSITION = {
+ duration: 0.32,
+ ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
+};
+
+export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
+ return (
+
+
+ {children}
+
+ {overlay}
+
+ );
+};
diff --git a/client/ui/frontend/src/lib/cn.ts b/client/ui/frontend/src/lib/cn.ts
new file mode 100644
index 000000000..e6a8be071
--- /dev/null
+++ b/client/ui/frontend/src/lib/cn.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/client/ui/frontend/src/lib/compat.ts b/client/ui/frontend/src/lib/compat.ts
new file mode 100644
index 000000000..b2981da90
--- /dev/null
+++ b/client/ui/frontend/src/lib/compat.ts
@@ -0,0 +1,19 @@
+import { Compat } from "@bindings/services";
+
+let cached: boolean | null = null;
+
+/**
+ * isDaemonCompatible probes whether the running daemon implements the WailsUIReady
+ * RPC. A false result means the daemon predates this UI (Unimplemented) and is too
+ * old to drive it. The Go side returns an error instead when the daemon is simply
+ * unreachable, so a throw here is NOT an outdated daemon — treat it as "unknown"
+ * and let the normal connection flow report it.
+ *
+ * The result is cached for the session: daemon identity does not change without a
+ * UI restart, and a freshly started daemon is reachable again under the same socket.
+ */
+export async function isDaemonCompatible(): Promise {
+ if (cached !== null) return cached;
+ cached = await Compat.DaemonReady();
+ return cached;
+}
diff --git a/client/ui/frontend/src/lib/connection.ts b/client/ui/frontend/src/lib/connection.ts
new file mode 100644
index 000000000..cc0e67cb3
--- /dev/null
+++ b/client/ui/frontend/src/lib/connection.ts
@@ -0,0 +1,133 @@
+import { Events } from "@wailsio/runtime";
+import { Connection, WindowManager } from "@bindings/services";
+import i18next from "@/lib/i18n";
+import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
+
+export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
+export const EVENT_TRIGGER_LOGIN = "trigger-login";
+
+let connectionInFlight = false;
+
+type SsoState = {
+ cancelled: boolean;
+ offCancel?: () => void;
+ offSignal?: () => void;
+};
+
+async function openBrowserLoginUri(uri: string): Promise {
+ try {
+ await WindowManager.OpenBrowserLogin(uri);
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise {
+ return new Promise((resolve) => {
+ state.offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => {
+ state.cancelled = true;
+ resolve();
+ });
+ if (!signal) return;
+ const onAbort = () => {
+ state.cancelled = true;
+ resolve();
+ };
+ if (signal.aborted) {
+ onAbort();
+ return;
+ }
+ signal.addEventListener("abort", onAbort);
+ state.offSignal = () => signal.removeEventListener("abort", onAbort);
+ });
+}
+
+async function runSsoLogin(
+ result: {
+ verificationUri: string;
+ verificationUriComplete: string;
+ userCode: string;
+ profileId: string;
+ },
+ state: SsoState,
+ signal?: AbortSignal,
+): Promise {
+ const uri = result.verificationUriComplete || result.verificationUri;
+ if (uri) await openBrowserLoginUri(uri);
+
+ const cancelPromise = buildSsoCancelPromise(state, signal);
+ // Combine wait + up in Go so the connection comes up the moment SSO
+ // completes. During SSO the tray window is hidden and the webview is
+ // suspended, so a frontend-driven Up (a promise continuation) would not
+ // fire until the user woke the window (e.g. hovering the tray icon).
+ const waitPromise = Connection.WaitSSOLoginAndUp(
+ { userCode: result.userCode, hostname: "", profileId: result.profileId },
+ { profileName: "", username: "" },
+ );
+
+ try {
+ await Promise.race([waitPromise, cancelPromise]);
+ } finally {
+ WindowManager.CloseBrowserLogin().catch(console.error);
+ }
+
+ if (state.cancelled) {
+ waitPromise.cancel?.();
+ waitPromise.catch(() => {});
+ }
+}
+
+export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise {
+ if (connectionInFlight || signal?.aborted) {
+ onSettled?.();
+ return;
+ }
+ connectionInFlight = true;
+
+ const state: SsoState = { cancelled: false };
+ let connectError: unknown;
+
+ try {
+ const result = await Connection.Login({
+ profileName: "",
+ username: "",
+ managementUrl: "",
+ setupKey: "",
+ preSharedKey: "",
+ hostname: "",
+ hint: "",
+ });
+
+ if (signal?.aborted) state.cancelled = true;
+
+ if (!state.cancelled && result.needsSsoLogin) {
+ // runSsoLogin brings the connection up in Go once SSO completes.
+ await runSsoLogin(result, state, signal);
+ } else {
+ if (!state.cancelled && signal?.aborted) state.cancelled = true;
+ if (!state.cancelled) {
+ await Connection.Up({ profileName: "", username: "" });
+ }
+ }
+ } catch (e) {
+ WindowManager.CloseBrowserLogin().catch(console.error);
+ if (!state.cancelled) connectError = e;
+ } finally {
+ state.offCancel?.();
+ state.offSignal?.();
+ connectionInFlight = false;
+ onSettled?.();
+ }
+
+ if (connectError !== undefined) {
+ await errorDialog({
+ Title: i18next.t("connect.error.loginTitle"),
+ Message: formatErrorMessage(connectError),
+ });
+ return;
+ }
+
+ if (state.cancelled && signal) {
+ throw new DOMException("aborted", "AbortError");
+ }
+}
diff --git a/client/ui/frontend/src/lib/errors.ts b/client/ui/frontend/src/lib/errors.ts
new file mode 100644
index 000000000..b4dee2717
--- /dev/null
+++ b/client/ui/frontend/src/lib/errors.ts
@@ -0,0 +1,73 @@
+import { WindowManager } from "@bindings/services";
+
+type ClassifiedError = { short: string; long: string; command: string };
+
+const asObject = (v: unknown): Record | null =>
+ v && typeof v === "object" ? (v as Record) : null;
+
+const parseJsonObject = (s: unknown): Record | null => {
+ if (typeof s !== "string") return null;
+ const t = s.trim();
+ if (!t.startsWith("{") || !t.endsWith("}")) return null;
+ try {
+ return asObject(JSON.parse(t));
+ } catch {
+ return null;
+ }
+};
+
+const toWailsEnvelope = (e: unknown): Record | null => {
+ const obj = asObject(e);
+ if (!obj) return null;
+ return asObject(obj.cause) ?? parseJsonObject(obj.message);
+};
+
+// Read { short, long, command } from wherever the classified error sits in the envelope
+const toClassifiedError = (v: unknown): ClassifiedError | null => {
+ const o = asObject(v);
+ if (!o) return null;
+ const short = typeof o.short === "string" ? o.short : "";
+ const long = typeof o.long === "string" ? o.long : "";
+ const command = typeof o.command === "string" ? o.command : "";
+ return short || long ? { short, long, command } : null;
+};
+
+const classify = (e: unknown): ClassifiedError | null => {
+ const envelope = toWailsEnvelope(e);
+ return toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
+};
+
+export const formatErrorMessage = (e: unknown): string => {
+ // Prefer the structured { short, long } the daemon classifier produced.
+ const classified = classify(e);
+ if (classified) {
+ const { short, long } = classified;
+ if (short && long && long !== short) return `${short} Details: ${long}`;
+ if (short) return short;
+ if (long) return long;
+ }
+
+ // Unclassified (a service returned the raw daemon error)
+ const envelope = toWailsEnvelope(e);
+ const message = envelope?.message;
+ if (typeof message === "string" && message) return message;
+ if (e instanceof Error) return e.message;
+ return String(e);
+};
+
+// errorCommand returns a command the user can run to complete an operation the
+// daemon refused, when the error carries one (a change that needs elevated
+// privileges). Empty for every other error.
+export const errorCommand = (e: unknown): string => classify(e)?.command ?? "";
+
+export type ErrorDialogOptions = {
+ Title: string;
+ Message: string;
+ // Command is shown for copying below the message. Defaults to the one the
+ // error carries, so callers only pass it to override.
+ Command?: string;
+};
+
+export function errorDialog(options: ErrorDialogOptions): Promise {
+ return WindowManager.OpenError(options.Title, options.Message, options.Command ?? "");
+}
diff --git a/client/ui/frontend/src/lib/formatters.ts b/client/ui/frontend/src/lib/formatters.ts
new file mode 100644
index 000000000..231967a38
--- /dev/null
+++ b/client/ui/frontend/src/lib/formatters.ts
@@ -0,0 +1,44 @@
+export const formatBytes = (bytes: number, decimals: number = 2): string => {
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
+
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
+
+ return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
+};
+
+export const latencyColor = (ms: number): string => {
+ if (ms <= 0) return "text-nb-gray-400";
+ if (ms < 100) return "text-green-400";
+ return "text-yellow-400";
+};
+
+export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => {
+ if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null;
+ const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds));
+ if (diff < 60) return `${diff}s ago`;
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+ return `${Math.floor(diff / 86400)}d ago`;
+};
+
+// Base domain is operator-configurable, so cut at the first dot rather than match a known suffix.
+export const shortenDns = (fqdn: string | undefined | null): string => {
+ if (!fqdn) return "";
+ const dot = fqdn.indexOf(".");
+ return dot === -1 ? fqdn : fqdn.slice(0, dot);
+};
+
+// Countdown clock: mm:ss, widening to hh:mm:ss / dd:hh:mm:ss as the duration grows.
+export const formatRemaining = (seconds: number): string => {
+ const s = Math.max(0, Math.trunc(seconds));
+ const days = Math.floor(s / 86400);
+ const hours = Math.floor((s % 86400) / 3600);
+ const minutes = Math.floor((s % 3600) / 60);
+ const secs = s % 60;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
+ if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
+ return `${pad(minutes)}:${pad(secs)}`;
+};
diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts
new file mode 100644
index 000000000..dc7fc7902
--- /dev/null
+++ b/client/ui/frontend/src/lib/i18n.ts
@@ -0,0 +1,103 @@
+import i18next from "i18next";
+import { initReactI18next } from "react-i18next";
+import { Events } from "@wailsio/runtime";
+
+import { Preferences, I18n } from "@bindings/services";
+import { type LanguageCode } from "@bindings/i18n/models.js";
+
+// Relative path on purpose — alias globs (`@/…`) silently match nothing in some Vite dev setups.
+type BundleEntry = { message: string; description?: string };
+const bundleModules = import.meta.glob>(
+ "../../../i18n/locales/*/common.json",
+ { eager: true, import: "default" },
+);
+
+const resources: Record }> = {};
+for (const path in bundleModules) {
+ const match = /locales\/([^/]+)\/common\.json$/.exec(path);
+ if (match) {
+ const entries = bundleModules[path];
+ const messages: Record = {};
+ for (const key in entries) {
+ messages[key] = entries[key].message;
+ }
+ resources[match[1]] = { common: messages };
+ }
+}
+
+function detectBrowserLanguage(available: string[]): string | null {
+ const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
+ (tag): tag is string => typeof tag === "string" && tag.length > 0,
+ );
+ const byLower = new Map(available.map((code) => [code.toLowerCase(), code]));
+ for (const tag of tags) {
+ const lower = tag.toLowerCase();
+ const exact = byLower.get(lower);
+ if (exact) return exact;
+ const base = byLower.get(lower.split("-")[0]);
+ if (base) return base;
+ }
+ return null;
+}
+
+// An empty persisted language code is the Go-side signal for first run.
+export async function initI18n(): Promise {
+ const available = Object.keys(resources);
+ let language = "en";
+ let firstRun = false;
+ try {
+ const prefs = await Preferences.Get();
+ if (prefs?.language) {
+ language = prefs.language;
+ } else {
+ firstRun = true;
+ language = detectBrowserLanguage(available) ?? "en";
+ }
+ } catch (e) {
+ console.warn("read preferences for language failed, defaulting to en", e);
+ }
+
+ if (firstRun) {
+ Preferences.SetLanguage(language as LanguageCode).catch((err: unknown) =>
+ console.warn("persist detected language failed", err),
+ );
+ }
+
+ await i18next.use(initReactI18next).init({
+ lng: language,
+ fallbackLng: "en",
+ defaultNS: "common",
+ ns: ["common"],
+ resources,
+ interpolation: {
+ prefix: "{",
+ suffix: "}",
+ escapeValue: false,
+ },
+ returnNull: false,
+ });
+
+ syncDocumentLang();
+ i18next.on("languageChanged", syncDocumentLang);
+
+ Events.On("netbird:preferences:changed", (e) => {
+ const next = e.data?.language;
+ if (next && next !== i18next.language) {
+ i18next.changeLanguage(next).catch((err: unknown) => {
+ console.error("changeLanguage failed", err);
+ });
+ }
+ });
+}
+
+function syncDocumentLang() {
+ if (typeof document !== "undefined") {
+ document.documentElement.lang = i18next.language;
+ }
+}
+
+export async function loadLanguages() {
+ return I18n.Languages();
+}
+
+export default i18next;
diff --git a/client/ui/frontend/src/lib/logs.ts b/client/ui/frontend/src/lib/logs.ts
new file mode 100644
index 000000000..499023fa3
--- /dev/null
+++ b/client/ui/frontend/src/lib/logs.ts
@@ -0,0 +1,139 @@
+import { UILog } from "@bindings/services";
+
+type Level = "trace" | "debug" | "info" | "warn" | "error";
+
+const METHOD_LEVELS: Record = {
+ trace: "trace",
+ debug: "debug",
+ log: "info",
+ info: "info",
+ warn: "warn",
+ error: "error",
+};
+
+const IGNORED_SOURCES = new Set(["welcome.ts"]);
+
+const RATE_LIMIT = 50;
+const RATE_WINDOW_MS = 1000;
+
+let installed = false;
+let inForward = false;
+let windowStart = 0;
+let windowCount = 0;
+
+function describeCause(rawCause: unknown): string {
+ if (rawCause instanceof Error) return `${rawCause.name}: ${rawCause.message}`;
+ if (typeof rawCause === "object" && rawCause !== null) {
+ try {
+ return JSON.stringify(rawCause);
+ } catch {
+ // Circular ref — fall through to a tag instead of "[object Object]".
+ return `<${rawCause.constructor?.name ?? "object"}>`;
+ }
+ }
+ return String(rawCause);
+}
+
+function formatCause(rawCause: unknown): string {
+ if (rawCause === undefined) return "";
+ return `\ncaused by ${describeCause(rawCause)}`;
+}
+
+// WebKit (macOS WKWebView) omits the "Name: message" header from Error.stack,
+// so a bare stack hides the real cause. Prepend name+message, then the stack.
+function formatError(e: Error): string {
+ const head = `${e.name}: ${e.message}`;
+ const cause = formatCause((e as { cause?: unknown }).cause);
+ if (!e.stack) return `${head}${cause}`;
+ if (e.stack.startsWith(head)) return `${head}${cause}`;
+ return `${head}${cause}\n${e.stack}`;
+}
+
+function format(args: unknown[]): string {
+ return args
+ .map((a) => {
+ if (typeof a === "string") return a;
+ if (a instanceof Error) return formatError(a);
+ try {
+ return JSON.stringify(a);
+ } catch {
+ return String(a);
+ }
+ })
+ .join(" ");
+}
+
+function parseStackLine(line: string): string {
+ // Find the file:line:col tail at the end of the path.
+ const colonCol = line.lastIndexOf(":");
+ if (colonCol <= 0) return "";
+ const colonLine = line.lastIndexOf(":", colonCol - 1);
+ if (colonLine <= 0) return "";
+ const col = line.slice(colonCol + 1);
+ const lineNo = line.slice(colonLine + 1, colonCol);
+ if (!/^\d+$/.test(col) || !/^\d+$/.test(lineNo)) return "";
+ const before = line.slice(0, colonLine);
+ const sep = Math.max(
+ before.lastIndexOf("/"),
+ before.lastIndexOf("\\"),
+ before.lastIndexOf("("),
+ before.lastIndexOf(" "),
+ );
+ const file = before.slice(sep + 1);
+ if (!file.includes(".")) return "";
+ return `${file}:${lineNo}`;
+}
+
+function callerSource(): string {
+ const stack = new Error().stack;
+ if (!stack) return "";
+ for (const line of stack.split("\n").slice(1)) {
+ if (line.includes("/logs.ts")) continue;
+ const parsed = parseStackLine(line);
+ if (parsed) return parsed;
+ }
+ return "";
+}
+
+function forward(level: Level, args: unknown[]) {
+ if (inForward) return;
+ inForward = true;
+ try {
+ const now = Date.now();
+ if (now - windowStart >= RATE_WINDOW_MS) {
+ windowStart = now;
+ windowCount = 0;
+ }
+ if (++windowCount > RATE_LIMIT) return;
+
+ const source = callerSource();
+ if (IGNORED_SOURCES.has(source.split(":")[0])) return;
+ // Don't touch console here — it would recurse back into forward().
+ UILog.Log(level, source, format(args)).catch(() => {});
+ } catch {
+ // Swallow — log forwarding must never throw back into the caller.
+ } finally {
+ inForward = false;
+ }
+}
+
+export function initLogForwarding() {
+ if (installed) return;
+ installed = true;
+
+ const c = console as unknown as Record void>;
+ for (const [method, level] of Object.entries(METHOD_LEVELS)) {
+ const original = c[method]?.bind(console);
+ c[method] = (...args: unknown[]) => {
+ original?.(...args);
+ forward(level, args);
+ };
+ }
+
+ globalThis.addEventListener("error", (e) => {
+ forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]);
+ });
+ globalThis.addEventListener("unhandledrejection", (e) => {
+ forward("error", ["unhandled promise rejection:", e.reason]);
+ });
+}
diff --git a/client/ui/frontend/src/lib/platform.ts b/client/ui/frontend/src/lib/platform.ts
new file mode 100644
index 000000000..c256dde23
--- /dev/null
+++ b/client/ui/frontend/src/lib/platform.ts
@@ -0,0 +1,39 @@
+import { System } from "@wailsio/runtime";
+
+export type Platform = {
+ isWindows: boolean;
+ isMacOS: boolean;
+};
+
+let cached: Platform | null = null;
+
+export async function initPlatform(): Promise {
+ if (cached) return;
+
+ const syncIsMac = System.IsMac();
+ const syncIsWindows = System.IsWindows();
+
+ let env: Awaited> | null = null;
+ try {
+ env = await System.Environment();
+ } catch (e) {
+ console.error("[platform] System.Environment() threw:", e);
+ }
+
+ const os = (env?.OS ?? "").toLowerCase();
+ cached = {
+ isWindows: os ? os === "windows" : syncIsWindows,
+ isMacOS: os ? os === "darwin" : syncIsMac,
+ };
+}
+
+function get(): Platform {
+ if (!cached) {
+ throw new Error("platform: initPlatform() must complete before sync getters are used");
+ }
+ return cached;
+}
+
+export const isWindows = (): boolean => get().isWindows;
+export const isMacOS = (): boolean => get().isMacOS;
+export const isLinux = (): boolean => !get().isWindows && !get().isMacOS;
diff --git a/client/ui/frontend/src/lib/sorting.ts b/client/ui/frontend/src/lib/sorting.ts
new file mode 100644
index 000000000..70622f705
--- /dev/null
+++ b/client/ui/frontend/src/lib/sorting.ts
@@ -0,0 +1,27 @@
+// Stable, order-preserving reconciliation for lists that re-fetch from the daemon
+// on every status push (peers, networks, profiles). Re-sorting on each refresh would
+// make rows jump around under the user, so instead:
+// - items already on screen keep their existing order (from `prev`),
+// - items that vanished are dropped,
+// - newly-arrived items are sorted among themselves (`compareFresh`) and appended.
+// Net effect: the only visible movement is new rows landing at the bottom.
+//
+// Must stay pure and idempotent: callers write the returned `order` into a ref
+// during render (useMemo), so a rerun must reproduce the first pass — never
+// branch on run count or read external mutable state.
+export function reconcileOrder(
+ prev: string[],
+ items: T[],
+ keyOf: (item: T) => string,
+ compareFresh: (a: T, b: T) => number,
+): { order: string[]; items: T[] } {
+ const byKey = new Map(items.map((i) => [keyOf(i), i]));
+ const kept = prev.filter((k) => byKey.has(k));
+ const known = new Set(kept);
+ const fresh = items
+ .filter((i) => !known.has(keyOf(i)))
+ .sort(compareFresh)
+ .map(keyOf);
+ const order = [...kept, ...fresh];
+ return { order, items: order.map((k) => byKey.get(k)!) };
+}
diff --git a/client/ui/frontend/src/lib/stallwatch.ts b/client/ui/frontend/src/lib/stallwatch.ts
new file mode 100644
index 000000000..aca7d75bb
--- /dev/null
+++ b/client/ui/frontend/src/lib/stallwatch.ts
@@ -0,0 +1,31 @@
+// Detects webview suspension (macOS App Nap / hidden-window timer throttling).
+// While the webview is suspended no JS runs at all, so detection happens on
+// resume: a 1s interval measures wall-clock drift and reports how long timers
+// were frozen. Silent unless a stall actually occurred; a stalled webview is
+// what delays promise continuations such as the WaitSSOLogin → Up handoff.
+
+const INTERVAL_MS = 1000;
+const STALL_THRESHOLD_MS = 5000;
+const REPORT_COOLDOWN_MS = 60_000;
+
+let started = false;
+
+export function initStallWatch() {
+ if (started) return;
+ started = true;
+
+ let last = Date.now();
+ let lastReport = 0;
+ setInterval(() => {
+ const now = Date.now();
+ const stall = now - last - INTERVAL_MS;
+ last = now;
+ if (stall < STALL_THRESHOLD_MS) return;
+ if (now - lastReport < REPORT_COOLDOWN_MS) return;
+ lastReport = now;
+ console.warn(
+ `webview timers were suspended for ${(stall / 1000).toFixed(1)}s ` +
+ `(App Nap / hidden-window throttling); pending UI work ran late`,
+ );
+ }, INTERVAL_MS);
+}
diff --git a/client/ui/frontend/src/lib/welcome.ts b/client/ui/frontend/src/lib/welcome.ts
new file mode 100644
index 000000000..d9df3fc8d
--- /dev/null
+++ b/client/ui/frontend/src/lib/welcome.ts
@@ -0,0 +1,25 @@
+const ART = `
+ _ __ __ ____ _ __ ______ __ __ __
+ / | / /__ / /_/ __ )(_)________/ / / ____/___ ___ / /_ / / / /
+ / |/ / _ \\/ __/ __ / / ___/ __ / / / __/ __ \`__ \\/ __ \\/ /_/ /
+ / /| / __/ /_/ /_/ / / / / /_/ / / /_/ / / / / / / /_/ / __ /
+/_/ |_/\\___/\\__/_____/_/_/ \\__,_/ \\____/_/ /_/ /_/_.___/_/ /_/
+`;
+
+export function welcome() {
+ const message = `%c${ART}%c
+NetBird — The Only Secure Access Platform You'll Ever Need.
+
+WEBSITE: https://netbird.io/
+WE'RE HIRING: https://netbird.io/careers
+OPEN SOURCE: https://github.com/netbirdio/netbird
+`;
+
+ // Intentional NetBird ASCII banner in the devtools console.
+ // eslint-disable-next-line no-console
+ console.log(
+ message,
+ "color: #f68330; font-family: monospace; font-weight: normal; line-height: 1;",
+ "color: #f5f5f5; font-family: monospace; font-weight: normal; line-height: 1.4;",
+ );
+}
diff --git a/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx
new file mode 100644
index 000000000..3345e0a9f
--- /dev/null
+++ b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx
@@ -0,0 +1,27 @@
+import { forwardRef, type HTMLAttributes } from "react";
+import { ArrowUpCircleIcon } from "lucide-react";
+import { cn } from "@/lib/cn";
+
+type Props = HTMLAttributes