diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..85ed5cd3b
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,18 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+language: en-US
+reviews:
+ profile: chill
+ request_changes_workflow: false
+ high_level_summary: true
+ poem: false
+ review_status: true
+ auto_review:
+ enabled: true
+ drafts: false
+ path_filters:
+ - "!**/*.tsx"
+ - "!**/*.ts"
+ - "!**/*.js"
+ - "!**/*.svg"
+chat:
+ auto_reply: true
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 80809e667..0661e0c71 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -6,7 +6,6 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
iptables=1.8.9-2 \
libgl1-mesa-dev=22.3.6-1+deb12u1 \
xorg-dev=1:7.7+23 \
- libayatana-appindicator3-dev=0.5.92-1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& go install -v golang.org/x/tools/gopls@latest
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
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..88b98293d
--- /dev/null
+++ b/.github/workflows/agent-network-e2e.yml
@@ -0,0 +1,80 @@
+name: Agent Network E2E
+
+on:
+ # Nightly at 03:00 UTC, plus on demand from the Actions tab.
+ schedule:
+ - cron: "0 3 * * *"
+ workflow_dispatch:
+ inputs:
+ bedrock_model:
+ description: >-
+ Bedrock inference-profile id to drive the matrix with, exactly as
+ AWS issues it. Leave empty for the Sonnet 4.6 default.
+ required: false
+ default: ""
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ e2e:
+ name: Agent Network E2E
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Install Go
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ with:
+ go-version-file: "go.mod"
+
+ # Container-driver builder so the harness can build the combined/proxy/
+ # client images from source with a local layer cache.
+ - name: Set up Buildx
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+
+ # Persist the Docker layer cache across runs. This caches the base, apt,
+ # and go-mod-download layers; the Go compile still re-runs, as BuildKit
+ # mount caches cannot be exported to the GitHub cache.
+ - name: Cache Docker layers
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: /tmp/.buildx-cache
+ key: ${{ runner.os }}-anet-e2e-buildx-${{ hashFiles('go.sum', 'combined/Dockerfile.multistage', 'proxy/Dockerfile.multistage', 'e2e/harness/Dockerfile.client') }}
+ restore-keys: |
+ ${{ runner.os }}-anet-e2e-buildx-
+
+ - name: Run agent-network e2e
+ env:
+ # Build the images from source (this branch's code) with the shared
+ # local layer cache.
+ NB_E2E_BUILDX_CACHE: /tmp/.buildx-cache
+ # Provider credentials. Each provider scenario skips if its
+ # token (and URL, for gateways) is unset, so partial coverage is fine.
+ OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }}
+ ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }}
+ # Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire
+ # shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api.
+ KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }}
+ VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }}
+ VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }}
+ OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }}
+ OPENROUTER_TOKEN: ${{ secrets.E2E_OPENROUTER_TOKEN }}
+ CLOUDFLARE_URL: ${{ secrets.E2E_CLOUDFLARE_URL }}
+ CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }}
+ AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }}
+ AWS_REGION: ${{ secrets.E2E_AWS_REGION }}
+ # Bedrock model override: dispatch input wins, then the repo variable, else the test default.
+ AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }}
+ # Vertex (Anthropic-on-Vertex): SA + project required; region defaults
+ # to "global", model to a pinned claude snapshot.
+ GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }}
+ GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }}
+ GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }}
+ GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }}
+ run: go test -tags e2e -timeout 40m -v ./e2e/...
diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml
index 50510368b..17c9fdc8d 100644
--- a/.github/workflows/check-license-dependencies.yml
+++ b/.github/workflows/check-license-dependencies.yml
@@ -64,7 +64,7 @@ jobs:
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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..552ccef29
--- /dev/null
+++ b/.github/workflows/frontend-ui.yml
@@ -0,0 +1,98 @@
+name: UI Frontend
+
+on:
+ pull_request:
+ paths:
+ - "client/ui/frontend/**"
+ - "client/ui/i18n/**"
+ - "client/ui/**/*.go"
+ - ".github/workflows/frontend-ui.yml"
+ push:
+ branches:
+ - main
+ paths:
+ - "client/ui/frontend/**"
+ - "client/ui/i18n/**"
+ - "client/ui/**/*.go"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
+ cancel-in-progress: true
+
+jobs:
+ lint-and-build:
+ name: Lint & Build
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ defaults:
+ run:
+ working-directory: client/ui/frontend
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+
+ - name: Set up pnpm
+ uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
+ with:
+ version: 11
+
+ # Bindings are generated by wails3 from the Go service definitions and
+ # are not checked in (see client/ui/frontend/bindings/). Without them,
+ # typecheck/build fail on missing module imports.
+ - name: Set up Go
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: "go.mod"
+ cache: false
+
+ # wails3 CLI links against GTK4 / WebKitGTK 6.0 via its internal/operatingsystem
+ # package, so the dev libraries must be present before `go install`.
+ - name: Install Wails Linux system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ pkg-config \
+ libgtk-4-dev \
+ libwebkitgtk-6.0-dev
+
+ - name: Install wails3 CLI
+ # Version derived from go.mod so the binding generator always matches
+ # the wails runtime the daemon links against.
+ working-directory: ${{ github.workspace }}
+ run: |
+ WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
+ go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
+
+ - name: Get pnpm store directory
+ id: pnpm-store
+ run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
+
+ - name: Cache pnpm store
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.pnpm-store.outputs.path }}
+ key: ${{ runner.os }}-pnpm-${{ hashFiles('client/ui/frontend/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile --ignore-scripts
+
+ - name: Generate Wails bindings
+ run: pnpm run bindings
+
+ - name: Lint, typecheck, format
+ run: pnpm check
+
+ - name: Build
+ run: pnpm build
diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index 7ecec0e92..420749a0e 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -21,13 +21,13 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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,7 +45,15 @@ 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@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml
index 4243613b1..9c795e783 100644
--- a/.github/workflows/golang-test-freebsd.yml
+++ b/.github/workflows/golang-test-freebsd.yml
@@ -48,14 +48,14 @@ jobs:
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
time go build -o netbird client/main.go
# check all component except management, since we do not support management server on freebsd
- time go test -timeout 1m -failfast ./base62/...
+ time go test -tags privileged -timeout 1m -failfast ./base62/...
# NOTE: without -p1 `client/internal/dns` will fail because of `listen udp4 :33100: bind: address already in use`
- time go test -timeout 8m -failfast -v -p 1 ./client/...
- time go test -timeout 1m -failfast ./dns/...
- time go test -timeout 1m -failfast ./encryption/...
- time go test -timeout 1m -failfast ./formatter/...
- time go test -timeout 1m -failfast ./client/iface/...
- time go test -timeout 1m -failfast ./route/...
- time go test -timeout 1m -failfast ./sharedsock/...
- time go test -timeout 1m -failfast ./util/...
- time go test -timeout 1m -failfast ./version/...
+ time go test -tags privileged -timeout 8m -failfast -v -p 1 ./client/...
+ time go test -tags privileged -timeout 1m -failfast ./dns/...
+ time go test -tags privileged -timeout 1m -failfast ./encryption/...
+ time go test -tags privileged -timeout 1m -failfast ./formatter/...
+ time go test -tags privileged -timeout 1m -failfast ./client/iface/...
+ time go test -tags privileged -timeout 1m -failfast ./route/...
+ time go test -tags privileged -timeout 1m -failfast ./sharedsock/...
+ time go test -tags privileged -timeout 1m -failfast ./util/...
+ time go test -tags privileged -timeout 1m -failfast ./version/...
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index cd34d1696..0af506bba 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -30,7 +30,7 @@ jobs:
- 'management/**'
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -41,7 +41,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 +53,7 @@ jobs:
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
- run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
+ run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
- name: Install 32-bit libpcap
if: steps.cache.outputs.cache-hit != 'true'
@@ -124,7 +124,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -135,7 +135,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 +145,7 @@ jobs:
${{ runner.os }}-gotest-cache-
- name: Install dependencies
- run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
+ run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
- name: Install 32-bit libpcap
if: matrix.arch == '386'
@@ -158,7 +158,15 @@ 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'
@@ -168,7 +176,6 @@ jobs:
slug: netbirdio/netbird
flags: unit,client
-
test_client_on_docker:
name: "Client (Docker) / Unit"
needs: [build-cache]
@@ -180,7 +187,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -192,7 +199,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 +236,7 @@ jobs:
sh -c ' \
apk update; apk add --no-cache \
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
- go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server)
+ go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged)
'
test_relay:
@@ -251,7 +258,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -266,7 +273,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 }}
@@ -311,7 +318,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -325,7 +332,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 }}
@@ -368,7 +375,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -383,7 +390,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 }}
@@ -429,7 +436,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -440,7 +447,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 }}
@@ -534,7 +541,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -545,7 +552,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 +586,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)"
@@ -628,7 +636,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -639,7 +647,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 +681,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"
@@ -697,7 +706,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -708,7 +717,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 }}
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index a6064d574..50a5ba4d6 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -23,7 +23,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
id: go
with:
go-version-file: "go.mod"
@@ -35,7 +35,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 +65,17 @@ jobs:
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
- name: Generate test script
+ # Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
+ # which fails to compile until the frontend has been built. The Wails UI
+ # has no Go-side unit tests, and its release pipeline runs `pnpm build`
+ # before goreleaser.
+ # `go list -e` lets the listing succeed even though the embed fails to
+ # resolve; the Where-Object pipeline then drops the broken package by
+ # path. Without -e, go list aborts with empty stdout.
run: |
- $packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
+ $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' }
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
- $cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
+ $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
- name: test
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 66882ac05..586e1235b 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -21,8 +21,16 @@ jobs:
- 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,7 +45,7 @@ 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -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@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml
index 778462a21..44e912c73 100644
--- a/.github/workflows/mobile-build-validation.yml
+++ b/.github/workflows/mobile-build-validation.yml
@@ -20,7 +20,7 @@ jobs:
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Setup Android SDK
@@ -28,13 +28,13 @@ jobs:
with:
cmdline-tools-version: 8512546
- name: Setup Java
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287
+ 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
@@ -58,7 +58,7 @@ jobs:
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: install gomobile
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4e533687b..76a5b36ff 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,7 @@ on:
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"
@@ -166,12 +166,12 @@ jobs:
fi
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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
@@ -216,9 +216,9 @@ 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@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
@@ -293,8 +293,11 @@ jobs:
${{ steps.goreleaser.outputs.artifacts }}
JSON
+ # dockers_v2 artifacts have no top-level goarch field, so match the
+ # per-platform -amd64 tag suffix instead; it works for both the old
+ # dockers and the new dockers_v2 image naming.
mapfile -t src_images < <(
- jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name | select(startswith("ghcr.io/"))' /tmp/goreleaser-artifacts.json
+ jq -r '.[] | select(.type == "Docker Image") | .name | select(startswith("ghcr.io/") and endswith("-amd64"))' /tmp/goreleaser-artifacts.json
)
for src in "${src_images[@]}"; do
@@ -374,12 +377,12 @@ jobs:
fi
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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 +397,18 @@ jobs:
- name: check git status
run: git --no-pager diff --exit-code
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Set up pnpm
+ uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
+ with:
+ version: 11
+
- name: Install dependencies
- run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
+ run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc-mingw-w64-x86-64
- name: Decode GPG signing key
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
@@ -414,10 +427,16 @@ 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@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
@@ -469,12 +488,12 @@ jobs:
fetch-depth: 0 # It is required for GoReleaser to work properly
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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,6 +505,20 @@ 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@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
@@ -573,23 +606,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 +628,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:
diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml
index 1d7753177..965d8aa5d 100644
--- a/.github/workflows/test-infrastructure-files.yml
+++ b/.github/workflows/test-infrastructure-files.yml
@@ -73,12 +73,12 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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') }}
@@ -249,78 +249,35 @@ 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@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 Dex retirement notice
run: |
- set -x
- test -f turnserver.conf
- grep external-ip turnserver.conf
+ 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: 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 Zitadel 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
-
- - 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
- 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/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml
index a5ae59720..e8a12cdaf 100644
--- a/.github/workflows/wasm-build-validation.yml
+++ b/.github/workflows/wasm-build-validation.yml
@@ -23,11 +23,11 @@ jobs:
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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:
@@ -48,7 +48,7 @@ jobs:
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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 a2640dc8e..8dd05a192 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
diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml
index 6f9b7c059..1157e6379 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,9 +88,9 @@ 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
@@ -81,6 +98,8 @@ nfpms:
- 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 +109,13 @@ 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
+
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml
index 0a0082075..47b991344 100644
--- a/.goreleaser_ui_darwin.yaml
+++ b/.goreleaser_ui_darwin.yaml
@@ -1,6 +1,15 @@
version: 2
project_name: netbird-ui
+
+before:
+ hooks:
+ # Bindings are gitignored; regenerate before the frontend build so
+ # the @wailsio/runtime Vite plugin can resolve them (vite refuses to
+ # build without them).
+ - sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts'
+ - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
+
builds:
- id: netbird-ui-darwin
dir: client/ui
@@ -21,7 +30,7 @@ builds:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- - load_wgnt_from_rsrc
+ - production
universal_binaries:
- id: netbird-ui-darwin
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..4ac006795
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,514 @@
+# 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
+
+- [NetBird Agent Guidelines](#netbird-agent-guidelines)
+ - [Contents](#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)
+ - [Repo-wide principles](#repo-wide-principles)
+ - [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/` |
+
+## Repo-wide principles
+
+1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
+2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings 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 adds
+ shared state. Guard maps and slices with a mutex, keep critical sections
+ short, and run `go test -race` on the touched packages.
+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.
+
+## 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.
+
+## 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
+
+- **90 characters per line.** Wrap the comment, do not run past it.
+- **250 characters per comment**, roughly three wrapped lines. Doc comments on
+ exported identifiers may exceed it when the API genuinely needs the
+ explanation; inline comments inside a function body may not.
+
+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.
+
+```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.
+
+- **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.
+
+- **User-facing changes need a docs PR** in
+ [netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
+ description.
+
+## After you push: CI and review bots
+
+Opening the PR is not the end of the task. Watch the run, read what the bots
+say, and drive the PR to green before you report the work as done.
+
+```bash
+gh pr checks --watch # all checks, live
+gh run view --log-failed # only the failing steps
+gh pr view --comments # bot and human review comments
+```
+
+**Never report a change as finished while checks are pending or red**, and never
+describe a red PR as passing. If you ran out of turn before CI finished, say
+which checks were still running.
+
+### The checks
+
+- **Go tests** — `golang-test-{linux,darwin,windows,freebsd}.yml`, sharded per
+ component. A failure in a component you did not touch is usually a real
+ interaction, not noise; read the log before assuming flake.
+- **golangci-lint** — `golangci-lint.yml` runs the full repository, while
+ `make lint` only checks your diff. A clean local lint does not guarantee green
+ CI on a large change.
+- **PR Title Check** — `pr-title-check.yml`, see above.
+- **Codecov** — uploaded from the Linux test workflow with per-component flags
+ (`unit,client`, `unit,management`, `unit,relay`, `unit,proxy`, `unit,signal`,
+ `integration,management`). Coverage on new code should not go backwards. Add
+ tests for the paths you introduced; do not adjust thresholds or exclude files
+ to clear the report.
+- **CodeRabbit** — configured in [`.coderabbit.yaml`](.coderabbit.yaml): `chill`
+ profile, auto-review on every non-draft PR, TypeScript/JavaScript/SVG paths
+ filtered out. Chat auto-reply is on, so `@coderabbitai` in a comment reaches
+ it.
+- **SonarCloud** — project `netbirdio_netbird`, quality gate on new code (bugs,
+ vulnerabilities, code smells, duplication, coverage).
+- **Snyk** — dependency and code scanning.
+
+Sonar and Snyk report as GitHub App checks rather than workflows in this
+repository, so their detail lives on the PR check, not in the Actions logs.
+
+### Handling bot findings
+
+- **Read every comment and act on it.** Either fix it, or reply with the reason
+ it does not apply. Do not bulk-resolve threads to clear the count, and do not
+ silently ignore a finding because the check is advisory.
+- **Bots are frequently wrong here.** NetBird has privileged, platform-specific,
+ and concurrency-heavy code that static analysis reads poorly. A confident
+ CodeRabbit or Sonar comment can still be nonsense. Verify the claim against
+ the code before you change anything — never edit correct code just to silence
+ a bot.
+- **Security findings get the opposite default.** For a Snyk or Sonar
+ vulnerability, or a CodeRabbit comment about authentication, authorization,
+ certificate verification, or key handling, assume it is real until you have
+ disproved it. Surface it to the user rather than dismissing it yourself.
+- **A new vulnerable dependency is a stop.** Bumping or replacing dependencies
+ needs the user's decision, as above.
+- **Never change a workflow, threshold, lint exclusion, or bot config to make a
+ check pass.** If a check is genuinely wrong, say so and let the user decide.
+- **Do not paper over flakes with blind re-runs.** Identify the failure first. If
+ it is a known flake, name it; if you cannot tell, report it as unresolved
+ rather than re-running until it goes green.
+
+## Discussion and support
+
+- Discussions:
+- Slack:
+- Docs:
+- Security: — never in public
+- Contribution process: [CONTRIBUTING.md](CONTRIBUTING.md)
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..764f406be
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cd1c087bb..db5097a48 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Contributing to NetBird
-Thanks for your interest in contributing to NetBird.
+Thanks for your interest in contributing to NetBird.
There are many ways that you can contribute:
- Reporting issues
@@ -10,12 +10,99 @@ There are many ways that you can contribute:
If you haven't already, join our slack workspace [here](https://docs.netbird.io/slack-url), we would love to discuss topics that need community contribution and enhancements to existing features.
+## Ticket first, PR second
+
+**Open a ticket and wait for feedback before you open a pull request.** Every PR
+that changes behavior must link to an issue the NetBird team has agreed on. A PR
+that arrives without one may be closed and redirected to a discussion, no matter
+how good the code is.
+
+Issues in this repository are maintainer-curated work items, so the flow starts
+in [Discussions](https://github.com/netbirdio/netbird/discussions):
+
+1. **Open a discussion.** Use
+ [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage)
+ for a bug, regression, or unexpected behavior, and
+ [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests)
+ for a feature, enhancement, or integration idea. Setup and usage questions
+ belong in
+ [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support).
+ Never report a security vulnerability in public — follow the
+ [security policy](https://github.com/netbirdio/netbird/security/policy)
+ instead.
+2. **Wait for feedback.** DevRel validates and reproduces the report, and a
+ maintainer confirms the direction. We may ask for more detail or propose a
+ different approach. Validated discussions become issues.
+3. **Then write the code**, following the approach agreed in the issue, and open
+ the PR linking that issue.
+
+Trivial fixes — a typo, a broken link, a documentation correction, or a one-line
+fix that already has an issue — can go straight to a PR. Everything else starts
+with a ticket. When in doubt, ask in the discussion or on
+[Slack](https://docs.netbird.io/slack-url); an hour of conversation up front
+regularly saves a week of rework.
+
+### High-risk areas
+
+These always need the design discussed and agreed in the issue **before** you
+write code:
+
+- **Public API** — REST / management API, OpenAPI schema, dashboard-facing contracts
+- **gRPC protocols** — management, signal, relay, and client daemon protos
+- **Functionality behavior** — anything existing deployments would experience differently after an upgrade
+- **Peer connectivity** — ICE and NAT traversal, relay selection, WireGuard® and Rosenpass key handling
+- **Client system integration** — routing, firewall, DNS, and interface management
+- **Authentication and authorization** — IdP integration, tokens, permissions, cryptography
+- **CLI / service flags**, configuration file format, and daemon IPC
+- **Store and database schema** — models and migrations
+- **New features**
+
+These surfaces are NetBird's contract with operators, self-hosters, and
+downstream integrators, and changes to them have compatibility, security, and
+release-planning implications. Agreeing on the direction early lets the PR
+review focus on implementation rather than design.
+
+Typical bug fixes, internal refactors, documentation updates, and tests do not
+need a design discussion, but should still be tied to an issue so the work is
+visible and nobody duplicates it.
+
+### Using AI coding agents
+
+We have no policy for or against using an AI agent to write NetBird code. That
+choice is yours, and we are not going to interrogate anyone about their tools.
+
+What we do have is a lot of incoming contributions that were plainly drafted with
+one, and enough experience reviewing them to see the same avoidable problems
+again and again: no ticket behind the change, a diff far too large to review, a
+description longer than the code it describes, an approach that was never going
+to be accepted, and an author who cannot answer questions about their own PR.
+None of that is caused by the tooling — it is what happens when a tool is pointed
+at a repository whose expectations it has never been told.
+
+So rather than a rule, there is a guide. [AGENTS.md](AGENTS.md) restates the
+expectations from this document in the form agents read automatically
+(`CLAUDE.md` points to it), so pointing your tool at the repository is usually
+enough. Among other things it tells the agent to ask you for the
+discussion or issue before drafting a PR, to keep the change small and
+single-purpose, to run the tests locally, to use this repository's PR template
+and title tags, and to write a description a reviewer can get through.
+
+The guardrails are the point, and they are the same ones we apply to everyone: an
+agreed ticket, a change you have actually run, a diff small enough to review with
+care, and an author who can explain it. Whatever wrote the diff, you are its
+author — you own every line you submit and the consequences of opening a PR with it.
+
+We may assess whether a contribution is maintainable and whether its merged code
+aligns with our security standards and design expectations.
+
## Contents
- [Contributing to NetBird](#contributing-to-netbird)
+ - [Ticket first, PR second](#ticket-first-pr-second)
+ - [High-risk areas](#high-risk-areas)
+ - [Using AI coding agents](#using-ai-coding-agents)
- [Contents](#contents)
- [Code of conduct](#code-of-conduct)
- - [Discuss changes with the NetBird team first](#discuss-changes-with-the-netbird-team-first)
- [Directory structure](#directory-structure)
- [Development setup](#development-setup)
- [Requirements](#requirements)
@@ -24,6 +111,7 @@ If you haven't already, join our slack workspace [here](https://docs.netbird.io/
- [Build and start](#build-and-start)
- [Test suite](#test-suite)
- [Checklist before submitting a PR](#checklist-before-submitting-a-pr)
+ - [When we close a PR](#when-we-close-a-pr)
- [Other project repositories](#other-project-repositories)
- [Contributor License Agreement](#contributor-license-agreement)
@@ -34,42 +122,66 @@ Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report
unacceptable behavior to community@netbird.io.
-## Discuss changes with the NetBird team first
-
-Changes to the **public API**, **gRPC protocols**, **functionality behavior**, **CLI / service flags**, or **new features** should be discussed with the NetBird team before you start the work. These surfaces are part of NetBird's contract with operators, self-hosters, and downstream integrators, and changes to them have compatibility, security, and release-planning implications that benefit from an early conversation.
-
-Open an issue or reach out on [Slack](https://docs.netbird.io/slack-url) to talk through what you have in mind. We'll help shape the change, flag any constraints we know about, and confirm the direction so the PR review can focus on implementation rather than design.
-
-Typical bug fixes, internal refactors, documentation updates, and tests do not need pre-discussion — open the PR directly.
-
## Directory structure
-The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages.
+The NetBird project monorepo keeps most of each component's code within its own
+directory, except for a few auxiliary or shared packages. Protocol definitions
+and the client-side service clients live under [/shared](/shared), because both
+the agent and the services import them.
-The most important directories are:
+**Agent**
-- [/.github](/.github) - Github actions workflow files and issue templates
- [/client](/client) - NetBird agent code
-- [/client/cmd](/client/cmd) - NetBird agent cli code
+- [/client/cmd](/client/cmd) - NetBird agent CLI code
- [/client/internal](/client/internal) - NetBird agent business logic code
-- [/client/proto](/client/proto) - NetBird agent daemon GRPC proto files
- [/client/server](/client/server) - NetBird agent daemon code for background execution
-- [/client/ui](/client/ui) - NetBird agent UI code
-- [/encryption](/encryption) - Contain main encryption code for agent communication
-- [/iface](/iface) - Wireguard® interface code
-- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts
+- [/client/proto](/client/proto) - NetBird agent daemon gRPC proto files
+- [/client/iface](/client/iface) - WireGuard® interface code
+- [/client/firewall](/client/firewall) - Platform firewall backends (nftables, iptables, pf, WFP, userspace)
+- [/client/ssh](/client/ssh) - Built-in SSH server and client
+- [/client/ui](/client/ui) - NetBird agent UI code (Wails v3 + React)
+- [/client/android](/client/android), [/client/ios](/client/ios) - Mobile platform bindings
+- [/client/wasm](/client/wasm) - WebAssembly build of the agent
+- [/client/mdm](/client/mdm) - MDM-delivered policy handling
+- [/client/system](/client/system) - Host and system information collection
+
+**Control plane services**
+
- [/management](/management) - Management service code
-- [/management/client](/management/client) - Management service client code which is imported by the agent code
-- [/management/proto](/management/proto) - Management service GRPC proto files
- [/management/server](/management/server) - Management service server code
- [/management/server/http](/management/server/http) - Management service REST API code
+- [/management/server/store](/management/server/store) - Persistence layer and migrations
- [/management/server/idp](/management/server/idp) - Management service IDP management code
-- [/release_files](/release_files) - Files that goes into release packages
+- [/management/server/peer](/management/server/peer), [/management/server/groups](/management/server/groups), [/management/server/networks](/management/server/networks), [/management/server/posture](/management/server/posture), [/management/server/permissions](/management/server/permissions) - Core domain packages
- [/signal](/signal) - Signal service code
-- [/signal/client](/signal/client) - Signal service client code which is imported by the agent code
- [/signal/peer](/signal/peer) - Signal service peer message logic
-- [/signal/proto](/signal/proto) - Signal service GRPC proto files
- [/signal/server](/signal/server) - Signal service server code
+- [/relay](/relay) - Relay service code
+- [/relay/protocol](/relay/protocol) - Relay wire protocol
+- [/proxy](/proxy) - Identity-aware proxy used by Agent Network (LLM routing, ACME, access logs)
+- [/agent-network](/agent-network) - Agent Network overview and documentation
+- [/upload-server](/upload-server) - Debug bundle upload service
+
+**Shared code**
+
+- [/shared/management/proto](/shared/management/proto) - Management service gRPC proto files
+- [/shared/management/client](/shared/management/client) - Management service client code which is imported by the agent code
+- [/shared/management/http/api](/shared/management/http/api) - OpenAPI specification and generated REST API types
+- [/shared/signal/proto](/shared/signal/proto) - Signal service gRPC proto files
+- [/shared/signal/client](/shared/signal/client) - Signal service client code which is imported by the agent code
+- [/shared/relay](/shared/relay) - Relay client and shared relay types
+- [/shared/auth](/shared/auth), [/shared/sshauth](/shared/sshauth) - Shared authentication primitives
+- [/encryption](/encryption) - Contain main encryption code for agent communication
+- [/dns](/dns), [/route](/route), [/stun](/stun), [/sharedsock](/sharedsock), [/util](/util) - Shared networking and utility primitives
+- [/flow](/flow) - Flow event protocol shared by the agent and Management
+
+**Build, test, and packaging**
+
+- [/.github](/.github) - Github actions workflow files, issue templates, and the pull request template
+- [/e2e](/e2e) - End-to-end test suites and harness
+- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts
+- [/release_files](/release_files) - Files that goes into release packages
+- [/tools](/tools) - Development and maintenance tooling
## Development setup
@@ -79,13 +191,21 @@ dependencies are installed. Here is a short guide on how that can be done.
### Requirements
-#### Go 1.21
+#### Go 1.25
Follow the installation guide from https://go.dev/
-#### UI client - Fyne toolkit
+#### UI client - Wails v3 + React
-We use the fyne toolkit in our UI client. You can follow its requirement guide to have all its dependencies installed: https://developer.fyne.io/started/#prerequisites
+The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need:
+
+- Go ≥ 1.25
+- Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`)
+- The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
+- The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest`
+- Linux only: `libwebkitgtk-6.0-dev`, `libgtk-4-dev`, `libsoup-3.0-dev`
+
+All UI build, dev-loop, and cross-compile commands are described in the [UI client](#ui-client) section below.
#### gRPC
You can follow the instructions from the quickstarter guide https://grpc.io/docs/languages/go/quickstart/#prerequisites and then run the `generate.sh` files located in each `proto` directory to generate changes.
@@ -214,6 +334,49 @@ To start NetBird the client in the foreground:
sudo ./client up --log-level debug --log-file console
```
> On Windows use a powershell with administrator privileges
+
+#### UI client
+
+The desktop UI lives in `client/ui` and is built with Wails v3 (see [Requirements](#ui-client---wails-v3--react)). All commands run from `client/ui`.
+
+Live-reload development (Vite + Go binary + `*.go` watcher):
+
+```
+cd client/ui
+task dev
+```
+
+Pass daemon flags after `--`, pointing the UI at the socket the daemon serves:
+
+```
+task dev -- --daemon-addr=unix:///var/run/netbird.sock # Linux, macOS
+task dev -- --daemon-addr=npipe://netbird # Windows
+```
+
+On Windows the daemon serves a named pipe (`npipe://netbird`). Which path that
+ends up being depends on what the daemon may create: as a service or elevated it
+serves `\\.\pipe\ProtectedPrefix\Administrators\netbird`, which no unprivileged
+process can take from it, and otherwise it falls back to `\\.\pipe\netbird`.
+Clients try both and check who owns the pipe before using the plain one. Avoid
+`tcp://127.0.0.1:41731`: loopback TCP carries no caller identity, so the daemon
+refuses the operations that require an administrator and you will not exercise
+those paths.
+
+Production build (frontend assets embedded into the binary, output in `client/ui/bin/`):
+
+```
+cd client/ui
+task build
+```
+
+Cross-compile the Windows binary from Linux (requires the mingw-w64 toolchain, e.g. `sudo apt install gcc-mingw-w64-x86-64`):
+
+```
+CGO_ENABLED=1 task windows:build
+```
+
+> macOS cross-compile from Linux is not supported (signing and notarization need a real Mac).
+
#### Signal service
To start NetBird's signal, execute:
@@ -251,10 +414,10 @@ Create dist directory
mkdir -p dist/netbird_windows_amd64
```
-UI client
+UI client (built with Wails v3 — see the [UI client](#ui-client) section above)
```shell
-CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -o netbird-ui.exe -ldflags "-s -w -H windowsgui" ./client/ui
-mv netbird-ui.exe ./dist/netbird_windows_amd64/
+(cd client/ui && CGO_ENABLED=1 task windows:build)
+mv client/ui/bin/netbird-ui.exe ./dist/netbird_windows_amd64/
```
Client
@@ -283,25 +446,172 @@ The installer `netbird-installer.exe` will be created in root directory.
### Test suite
-The tests can be started via:
+The host-safe unit tests run as a normal user and leave host networking
+untouched:
```
-cd netbird
-go test -exec sudo ./...
+make test-unit
```
+
+Tests that need root and mutate host networking (firewall, routing, interface
+management) carry the `privileged` build tag and run inside a
+`--privileged --cap-add=NET_ADMIN` Docker container:
+
+```
+make test-privileged
+```
+
+Narrow a privileged run with environment variables:
+
+```
+PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
+```
+
+Single packages can be run directly, adding `-race` when the change touches
+shared state:
+
+```
+go test -race ./client/internal/dns/...
+```
+
> On Windows use a powershell with administrator privileges
-> Non-GTK environments will need the `libayatana-appindicator3-dev` (debian/ubuntu) package installed
-
## Checklist before submitting a PR
-As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
+
+As a critical network service and open-source project, we must enforce a few
+things before submitting a pull request. The
+[pull request template](/.github/pull_request_template.md) mirrors this list —
+fill it in rather than deleting it.
+
+### Link the issue
+
+The PR description must link the agreed issue (or the validated discussion it
+came from). See [Ticket first, PR second](#ticket-first-pr-second).
+
+### Run it locally
+
+**If you can't run it, you can't submit it.** Build the affected components and
+exercise the change on a real setup — see [Build and start](#build-and-start).
+"CI will tell me" is not acceptable for a VPN agent that runs as root on other
+people's machines.
+
+### Green CI, and answer the bots
+
+We do not start reviewing while CI is red. Get the pipeline green first — a
+failing build, lint, or test means the PR is not ready for review.
+
+Alongside the test workflows, your PR is reviewed by CodeRabbit and scanned by
+SonarCloud, Snyk, and Codecov. Read what they report and either fix it or reply
+with why it does not apply; please do not resolve the threads without a
+response. They are not always right — this codebase has privileged,
+platform-specific, and concurrency-heavy paths that static analysis reads poorly
+— so push back when a finding is wrong rather than changing correct code to
+silence it. Security and dependency findings are the exception: treat those as
+real until shown otherwise. Do not edit workflows, thresholds, or scanner
+configuration to make a check pass.
+
+### One PR, one purpose
+
+Bug fix, refactor, feature: separate PRs. Mixed PRs are slow to review, hard to
+revert, and may be closed with a request to split them.
+
+### Keep it small
+
+Size is the strongest predictor of how long a PR waits for review. Aim for under
+roughly 400 changed lines across under 20 files. Past about 1000 lines or 50
+files, expect to be asked to split the change — and large PRs from outside the
+core team may be blocked until the scope has been agreed in a ticket. This is
+not only about reviewer time: NetBird's agent runs as root on other people's
+machines, and a sprawling diff cannot be reviewed with the care that deserves.
+
+Measure by hand-written code, excluding generated output, `go.sum`, and
+fixtures. If a change genuinely cannot be small — a protocol migration, a
+cross-component rename — agree the split in the issue before you start, and land
+it as a series of PRs that each build and make sense on their own.
+
+### Avoid force-pushing during review
+
+Once a PR is open, push new commits instead of rewriting history. A force-push
+detaches existing review comments from their lines, throws away the
+"changes since your last review" diff, and loses the CI history that showed
+which commit broke what. Since we squash on merge, there is nothing to gain from
+a tidy branch history.
+
+Force-pushing is sometimes unavoidable — rebasing to clear a real conflict, or
+removing a secret or large binary committed by mistake. When that happens, leave
+a comment on the PR so reviewers know their anchors moved.
+
+### Quality checks
+
+Run these from the repository root before pushing:
+
+```shell
+go fmt ./...
+make lint # golangci-lint on files changed against origin/main
+make lint-all # full-repository lint, matches CI
+make test-unit # host-safe unit tests
+```
+
+`make setup-hooks` wires `make lint` into a pre-push hook so the fast lint runs
+automatically. If your change touches privileged paths (firewall, routing,
+interface management), also run `make test-privileged`, which executes the
+`privileged`-tagged suite inside a Docker container with `NET_ADMIN`.
+
+### Code standards
+
- Keep functions as simple as possible, with a single purpose
- Use private functions and constants where possible
- Comment on any new public functions
- Add unit tests for any new public function
+- Comment the **why**, not the **what** — explain non-obvious decisions, invariants, and constraints, not the line below
+- Keep comments within 90 characters per line and roughly 250 characters per comment; when a block needs more explanation than that, extract a named function instead of writing a longer comment (see [AGENTS.md](AGENTS.md#length-budget))
+
+### PR title and commits
+
+PR titles must start with a bracketed tag, enforced by
+[pr-title-check.yml](/.github/workflows/pr-title-check.yml):
+
+```text
+[client] Authorize daemon IPC callers by their local identity
+[management,client] Add MDM policy support
+```
+
+Use a comma-separated list inside a single pair of brackets when a change spans
+components. The `allowedTags` array in
+[pr-title-check.yml](/.github/workflows/pr-title-check.yml) is the source of
+truth — at the time of writing it accepts `management`, `client`, `signal`,
+`proxy`, `relay`, `misc`, `infrastructure`, `self-hosted`, and `doc`.
+
+Commit subjects follow the same convention — keep them short and put the
+reasoning in the body, why before what, with no bullet list of files changed.
+
+Keep the PR description itself under 1000 words on top of the template text.
+Reviewers read the diff; the description explains what the diff cannot.
> When pushing fixes to the PR comments, please push as separate commits; we will squash the PR before merging, so there is no need to squash it before pushing it, and we are more than okay with 10-100 commits in a single PR. This helps review the fixes to the requested changes.
+### Documentation
+
+User-facing changes need a matching PR in
+[netbirdio/docs](https://github.com/netbirdio/docs); link it in the PR
+description, or state why documentation is not needed.
+
+## When we close a PR
+
+We would rather redirect early than let a PR sit. We may close one if:
+
+- It changes behavior with no linked issue, or the approach was never agreed with a maintainer
+- The change was clearly never run or tested locally
+- CI has been red without a response
+- It mixes unrelated purposes, or the purpose is not clear
+- It is far too large to review and the scope was never agreed in a ticket
+- The author cannot answer questions about their own change — including PRs that read as unreviewed model output, where review turns into a relay between the maintainer and an LLM. Tooling is fine; unreviewed output is not, you are responsible for the code you sign your name to
+- There has been no activity for 14 days after we requested changes
+
+A closed PR is not a rejected idea. Take it back to the
+[discussion](https://github.com/netbirdio/netbird/discussions), settle the
+approach, and reopen the work from there.
+
## Other project repositories
NetBird project is composed of 3 main repositories:
diff --git a/Makefile b/Makefile
index 5d52b94fa..0a4fad2f2 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: lint lint-all lint-install setup-hooks
+.PHONY: lint lint-all lint-install setup-hooks test-unit test-privileged
GOLANGCI_LINT := $(shell pwd)/bin/golangci-lint
# Install golangci-lint locally if needed
@@ -25,3 +25,15 @@ setup-hooks:
@git config core.hooksPath .githooks
@chmod +x .githooks/pre-push
@echo "✅ Git hooks configured! Pre-push will now run 'make lint'"
+
+# Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating).
+# Runs as a normal user with no sudo and leaves host networking untouched.
+test-unit:
+ @go test -tags devcert -timeout 10m ./...
+
+# Privileged suite: runs the `privileged`-tagged tests inside a --privileged
+# --cap-add=NET_ADMIN container via the ory/dockertest harness. Requires Docker.
+# Narrow the run with env vars, e.g.:
+# PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
+test-privileged:
+ @go test -tags 'devcert privileged' -timeout 30m -run TestRunPrivilegedSuiteInDocker -v ./client/testutil/privileged/...
diff --git a/README.md b/README.md
index cc27e2d28..40c6b9ed5 100644
--- a/README.md
+++ b/README.md
@@ -33,10 +33,15 @@
- 🚀 We are hiring! Join us at careers.netbird.io
+ 🚀 We are hiring! Join us at https://netbird.io/careers
+> ### 🤖 NetBird Agent Network (Beta)
+> Identity-aware access control for AI agents — keyless access to LLM APIs and private
+> resources over the encrypted NetBird tunnel. See [`agent-network/`](agent-network/) or
+> read the docs at **[netbird.ai](https://netbird.ai)**.
+
**NetBird combines a configuration-free peer-to-peer private network and a centralized access control system in a single platform, making it easy to create secure private networks for your organization or home.**
**Connect.** NetBird creates a WireGuard-based overlay network that automatically connects your machines over an encrypted tunnel, leaving behind the hassle of opening ports, complex firewall rules, VPN gateways, and so forth.
diff --git a/SECURITY.md b/SECURITY.md
index 745c66e61..bdf88d670 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,12 +1,70 @@
# Security Policy
-NetBird's goal is to provide a secure network. If you find a vulnerability or bug, please report it by opening an issue [here](https://github.com/netbirdio/netbird/issues/new?assignees=&labels=&template=bug-issue-report.md&title=) or by contacting us by email.
-
-There has yet to be an official bug bounty program for the NetBird project.
-
-## Supported Versions
-- We currently support only the latest version
+NetBird's goal is to provide a secure network. The client runs as a privileged service on every machine it is installed on,
+so we take reports about it seriously and we publish what we fix.
## Reporting a Vulnerability
-Please report security issues to `security@netbird.io`
+**Please do not open a public issue for a security vulnerability.** Public issues are visible to everyone, including before
+a fix is available.
+
+Report security issues one of these two ways:
+
+- **GitHub private vulnerability reporting** — [open a private report](https://github.com/netbirdio/netbird/security/advisories/new)
+ on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place.
+- **Email** — `security@netbird.io`.
+
+If the finding affects NetBird Cloud or our hosted infrastructure rather than the open-source code, email us rather than
+filing a repository report.
+
+### What to include
+
+A report is easier to act on when it contains:
+
+- The affected component (client, management, signal, relay, dashboard) and the version or commit you tested
+- The platform and configuration, where relevant — operating system, self-hosted or NetBird Cloud, container or host install
+- What an attacker needs before they can exploit it: network position, an account, local access, a specific privilege level
+- Steps to reproduce, and a proof of concept if you have one
+- The impact you believe it has
+
+Partial reports are still welcome. If you are unsure whether something is a security issue, send it to `security@netbird.io`
+and let us make that call.
+
+## What to expect from us
+
+- **We acknowledge your report** and tell you whether we can reproduce it.
+- **We work with you on severity and scope.** If we assess it differently than you do, we will explain why rather than
+ silently downgrade it.
+- **We fix and release**, then publish a [GitHub Security Advisory](https://github.com/netbirdio/netbird/security/advisories)
+ naming the affected version range and the patched version.
+- **We credit reporters who want to be credited.** Tell us the name or handle you would like used, or that you would rather
+ stay anonymous.
+- **We keep you in the loop** until the advisory is published.
+
+We ask that you give us a reasonable opportunity to ship a fix before disclosing the issue publicly, and that you avoid
+accessing, modifying, or exfiltrating data belonging to other people while testing. Testing against your own installation
+or your own account is always fine.
+
+## Supported Versions
+
+We support the latest release. Security fixes ship in the next version rather than as backports to older releases, so
+upgrading to the current release is how you get them.
+
+Release notifications are available by watching [releases](https://github.com/netbirdio/netbird/releases).
+
+## Published advisories
+
+Every vulnerability we fix is published as a GitHub Security Advisory on the
+[advisories page](https://github.com/netbirdio/netbird/security/advisories), including the affected version range, the
+patched version, and the reporter's credit. Advisories for the Go module are also distributed through the Go vulnerability
+database, so `govulncheck` will report them against your dependencies.
+
+## Bug bounty
+
+There is no official bug bounty program for the NetBird project. We credit reporters in advisories, and we are grateful for
+the work, but we cannot currently offer payment for reports.
+
+## Non-security bugs
+
+For bugs that are not security issues, please use the
+[issue tracker](https://github.com/netbirdio/netbird/discussions/new/choose).
diff --git a/agent-network/README.md b/agent-network/README.md
new file mode 100644
index 000000000..1997ea299
--- /dev/null
+++ b/agent-network/README.md
@@ -0,0 +1,73 @@
+# NetBird Agent Network
+
+Agent Network is NetBird's access control layer for AI agents and the people who run them.
+It gives every agent a real identity, tied to an identity provider (IdP), and governs what it can reach: LLM APIs and
+AI gateways it can call, and the internal resources it can access. Traffic flows only over the encrypted NetBird tunnel,
+scoped by policy, with no API keys or other credentials to leak. It also gives you control over cost and token usage.
+
+Because every LLM request passes through an
+identity-aware proxy, you can:
+
+- **Set spending and rate limits** per agent, per user, or per team — with hard caps
+ that stop requests once a budget is reached.
+- **Restrict models and providers** so agents can only call approved (and cost-appropriate)
+ endpoints, keeping expensive models off-limits unless explicitly allowed.
+- **Attribute usage** by tracking token consumption and cost per identity, group, or cost center so every
+ request is tied back to the agent and person responsible.
+- **Reuse your existing AI gateway** — point the proxy at a gateway you already run,
+ keeping its routing and config in place while it adds identity on top, so you skip
+ API key distribution.
+
+https://github.com/user-attachments/assets/44d18286-d8ab-49f8-a457-98ccd66f3268
+
+> **Beta.** Agent Network is in beta, but it's stable and already running in
+> production environments. It's fully open source and can be self-hosted on your own
+> infrastructure, with no vendor lock-in and no data leaving your environment.
+
+## How it works
+
+Say you have a simple use case: your Engineering or IT team needs access to Claude Code or Codex, and you want visibility into usage plus the ability to enforce budgets.
+How can you do that without creating a dedicated API key for every team?
+
+With Agent Network you get a private endpoint inside your network, for example: https://mirror.netbird.ai
+Teams configure their agents to point to that endpoint instead of using individual API keys directly.
+
+This endpoint is only reachable when users are connected to your NetBird network and authenticated through your IdP. Otherwise, it is not accessible from the public internet.
+You can then use this private endpoint to configure your AI agents, whether that is Claude Code, Codex, or another tool.
+
+## Quickstart
+
+Full step-by-step setup:
+**https://docs.netbird.io/agent-network/quickstart**
+
+## Architecture
+
+Agent Network is built on two existing NetBird capabilities:
+
+- **Overlay network** — the encrypted WireGuard mesh between peers.
+- **Reverse proxy** — a NetBird peer that terminates LLM requests, establishes the
+ caller's identity, evaluates policies/limits/guardrails, injects the upstream provider
+ key server-side, forwards to the API or gateway, and records usage.
+
+LLM traffic is routed through the proxy's identity-aware pipeline, while internal
+resources (databases, internal APIs, self-hosted models) are reached directly over
+peer-to-peer WireGuard tunnels, governed by the same identities and access policies.
+
+
+
+
+## Where the code lives
+
+There is no separate "agent-network" service — it reuses the reverse-proxy and management
+components:
+
+- [`proxy/`](../proxy) — the NetBird reverse proxy that serves the agent network endpoint
+ and runs the per-request middleware pipeline.
+- [`management/internals/modules/reverseproxy/`](../management/internals/modules/reverseproxy)
+ — the management-side control plane: providers, policies, guardrails, limits, routing,
+ and usage/access logs.
+
+## Documentation
+
+Full documentation, architecture, and quickstart:
+**https://docs.netbird.io/agent-network**
diff --git a/client/android/client.go b/client/android/client.go
index 99ccdf393..501d7f77c 100644
--- a/client/android/client.go
+++ b/client/android/client.go
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"slices"
+ "strings"
"sync"
"time"
@@ -75,6 +76,24 @@ type Client struct {
connectClient *internal.ConnectClient
config *profilemanager.Config
cacheDir 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) {
@@ -148,11 +167,16 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
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)
+ // 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)
}
@@ -247,6 +271,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
deps.SyncResponse = resp
if e := cc.Engine(); e != nil {
+ deps.RefreshStatus = func() {
+ e.RunHealthProbes(context.Background(), true)
+ }
if cm := e.GetClientMetrics(); cm != nil {
deps.ClientMetrics = cm
}
@@ -274,7 +301,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 +323,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 +340,20 @@ func (c *Client) PeersList() *PeerInfoArray {
FQDN: p.FQDN,
ConnStatus: int(p.ConnStatus),
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
+
+ PubKey: p.PubKey,
+ Latency: formatDuration(p.Latency),
+ LatencyMs: p.Latency.Milliseconds(),
+ BytesRx: p.BytesRx,
+ BytesTx: p.BytesTx,
+ ConnStatusUpdate: formatTime(p.ConnStatusUpdate),
+ Relayed: p.Relayed,
+ RosenpassEnabled: p.RosenpassEnabled,
+ LastWireguardHandshake: formatTime(p.LastWireguardHandshake),
+ LocalIceCandidateType: p.LocalIceCandidateType,
+ RemoteIceCandidateType: p.RemoteIceCandidateType,
+ LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint,
+ RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint,
}
peerInfos[n] = pi
}
@@ -436,10 +484,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 +503,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 +553,28 @@ func exportEnvList(list *EnvList) {
}
}
}
+
+// formatDuration renders a duration for display, trimming the fractional part
+// to two digits so latencies read as "12.34ms" rather than "12.345678ms".
+func formatDuration(d time.Duration) string {
+ ds := d.String()
+ dotIndex := strings.Index(ds, ".")
+ if dotIndex == -1 {
+ return ds
+ }
+
+ endIndex := min(dotIndex+3, len(ds))
+
+ // Skip the remaining digits so only the unit suffix is appended back.
+ unitStart := endIndex
+ for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' {
+ unitStart++
+ }
+ return ds[:endIndex] + ds[unitStart:]
+}
+
+// formatTime renders a timestamp in UTC using a fixed layout. The zero time is
+// passed through as-is so the UI can recognise it and show "never" instead.
+func formatTime(t time.Time) string {
+ return t.UTC().Format("2006-01-02 15:04:05")
+}
diff --git a/client/android/env_list.go b/client/android/env_list.go
index a0a4d7040..d0e0a1e78 100644
--- a/client/android/env_list.go
+++ b/client/android/env_list.go
@@ -10,7 +10,7 @@ var (
EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay
// EnvKeyNBLazyConn Exported for Android java client to configure lazy connection
- EnvKeyNBLazyConn = lazyconn.EnvEnableLazyConn
+ EnvKeyNBLazyConn = lazyconn.EnvLazyConn
// EnvKeyNBInactivityThreshold Exported for Android java client to configure connection inactivity threshold
EnvKeyNBInactivityThreshold = lazyconn.EnvInactivityThreshold
diff --git a/client/android/peer_notifier.go b/client/android/peer_notifier.go
index c2595e574..f525055bb 100644
--- a/client/android/peer_notifier.go
+++ b/client/android/peer_notifier.go
@@ -12,12 +12,30 @@ const (
)
// PeerInfo describe information about the peers. It designed for the UI usage
+//
+// The fields below ConnStatus back the peer detail screen. Durations and times
+// are pre-formatted into strings so the UI does not have to know Go's layouts;
+// Latency is additionally exposed as LatencyMs for colour coding.
type PeerInfo struct {
IP string
IPv6 string
FQDN string
ConnStatus int
Routes PeerRoutes
+
+ PubKey string
+ Latency string
+ LatencyMs int64
+ BytesRx int64
+ BytesTx int64
+ ConnStatusUpdate string
+ Relayed bool
+ RosenpassEnabled bool
+ LastWireguardHandshake string
+ LocalIceCandidateType string
+ RemoteIceCandidateType string
+ LocalIceCandidateEndpoint string
+ RemoteIceCandidateEndpoint string
}
func (p *PeerInfo) GetPeerRoutes() *PeerRoutes {
diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go
index 87c001396..9a051137c 100644
--- a/client/android/profile_manager.go
+++ b/client/android/profile_manager.go
@@ -189,6 +189,19 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
return nil
}
+// RenameProfile changes a profile's display name. The profile ID, and therefore
+// its on-disk filename, is left untouched: only the "name" field of the config
+// is rewritten. This works for the default profile too, whose config lives in
+// netbird.cfg rather than under profiles/.
+func (pm *ProfileManager) RenameProfile(id string, newName string) error {
+ if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil {
+ return fmt.Errorf("failed to rename profile: %w", err)
+ }
+
+ log.Infof("renamed profile %s to: %s", id, newName)
+ return nil
+}
+
// RemoveProfile deletes a profile
func (pm *ProfileManager) RemoveProfile(id string) error {
// Use ServiceManager (removes profile from profiles/ directory)
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..961d52528
--- /dev/null
+++ b/client/android/session.go
@@ -0,0 +1,309 @@
+//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, _, cc := c.stateSnapshot()
+ 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()
+
+ a := &Auth{ctx: ctx, config: cfg}
+ tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
+ if err != nil {
+ return fmt.Errorf("interactive sso login failed: %v", err)
+ }
+
+ if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil {
+ return err
+ }
+ c.clearLoginRequired()
+
+ go urlOpener.OnLoginSuccess()
+ return nil
+}
diff --git a/client/cmd/daemon_error.go b/client/cmd/daemon_error.go
new file mode 100644
index 000000000..0d5b1307e
--- /dev/null
+++ b/client/cmd/daemon_error.go
@@ -0,0 +1,66 @@
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "google.golang.org/genproto/googleapis/rpc/errdetails"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+)
+
+// daemonCallError prepares a daemon error for display. A refusal the daemon
+// raised because the operation needs root/administrator is already guidance
+// written for the user, so it is surfaced on its own instead of buried under the
+// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped
+// with context as usual.
+func daemonCallError(context string, err error) error {
+ if guidance, ok := privilegeGuidance(err); ok {
+ return errors.New(guidance)
+ }
+ return fmt.Errorf("%s: %w", context, err)
+}
+
+// privilegeGuidance renders the daemon's privilege refusal as a summary and the
+// command that performs the operation with the privileges it needs. It reports
+// false for any other error.
+func privilegeGuidance(err error) (string, bool) {
+ info, ok := privilegeErrorInfo(err)
+ if !ok {
+ return "", false
+ }
+
+ summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
+ command := info.GetMetadata()[ipcauth.ErrorMetaCommand]
+ if summary == "" {
+ // Detail without a summary: fall back to the status message, which
+ // carries the same text.
+ summary = strings.TrimSpace(gstatus.Convert(err).Message())
+ }
+ if command == "" {
+ return summary, true
+ }
+
+ return fmt.Sprintf("%s\n\n %s\n", summary, command), true
+}
+
+// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
+// carries one.
+func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
+ if err == nil {
+ return nil, false
+ }
+
+ for _, detail := range gstatus.Convert(err).Details() {
+ info, ok := detail.(*errdetails.ErrorInfo)
+ if !ok {
+ continue
+ }
+ if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
+ return info, true
+ }
+ }
+ return nil, false
+}
diff --git a/client/cmd/debug.go b/client/cmd/debug.go
index bc7b0e98c..7ddc3afc4 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -29,8 +29,9 @@ const errCloseConnection = "Failed to close connection: %v"
var (
logFileCount uint32
systemInfoFlag bool
- uploadBundleFlag bool
- uploadBundleURLFlag string
+ uploadBundleFlag bool
+ uploadBundleURLFlag string
+ uploadBundleInsecureFlag bool
)
var debugCmd = &cobra.Command{
@@ -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 {
@@ -174,10 +175,11 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
}
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())
@@ -373,10 +375,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
}
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 +527,12 @@ func init() {
debugBundleCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
debugBundleCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
debugBundleCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
+ debugBundleCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Uint32VarP(&logFileCount, "log-file-count", "C", 1, "Number of rotated log files to include in debug bundle")
forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
+ forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle")
}
diff --git a/client/cmd/login.go b/client/cmd/login.go
index a7ee960b1..a53cb6d5f 100644
--- a/client/cmd/login.go
+++ b/client/cmd/login.go
@@ -17,16 +17,26 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
+ nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/util"
)
+// extendSessionFlag drives the `netbird login --extend` flow: refresh the
+// SSO session expiry on the management server without tearing down the
+// tunnel. Mutually exclusive with setup-key login (a setup-key cannot
+// refresh an SSO-tracked peer — see auth.errSetupKeyOnSSOExpiredPeer).
+var extendSessionFlag bool
+
func init() {
loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc)
loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location")
+ loginCmd.PersistentFlags().BoolVar(&extendSessionFlag, "extend", false,
+ "refresh the SSO session expiry without tearing down the tunnel (requires an active connection)")
}
var loginCmd = &cobra.Command{
@@ -61,6 +71,16 @@ var loginCmd = &cobra.Command{
return err
}
+ if extendSessionFlag {
+ if providedSetupKey != "" {
+ return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers")
+ }
+ if err := doExtendSession(ctx, cmd); err != nil {
+ return fmt.Errorf("extend session failed: %v", err)
+ }
+ return nil
+ }
+
// workaround to run without service
if util.FindFirstLogPath(logFiles) == "" {
if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil {
@@ -152,6 +172,65 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
return nil
}
+// doExtendSession drives the daemon's RequestExtendAuthSession /
+// WaitExtendAuthSession pair. The user is sent through a regular SSO flow
+// (browser + verification URL) and the resulting JWT is forwarded to the
+// management server's ExtendAuthSession RPC. The tunnel stays up
+// throughout — no Down/Up, no network-map resync.
+func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
+ conn, err := DialClientGRPCServer(ctx, daemonAddr)
+ if err != nil {
+ //nolint
+ return fmt.Errorf("failed to connect to daemon error: %v\n"+
+ "If the daemon is not running please run: "+
+ "\nnetbird service install \nnetbird service start\n", err)
+ }
+ defer conn.Close()
+
+ client := proto.NewDaemonServiceClient(conn)
+
+ req := &proto.RequestExtendAuthSessionRequest{}
+ // Pre-fill the IdP login hint from the active profile so the user
+ // doesn't have to retype their email. Best-effort: we still proceed
+ // without a hint if the lookup fails.
+ pm := profilemanager.NewProfileManager()
+ if active, perr := pm.GetActiveProfile(); perr == nil {
+ if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" {
+ req.Hint = &profState.Email
+ }
+ }
+
+ startResp, err := client.RequestExtendAuthSession(ctx, req)
+ if err != nil {
+ return fmt.Errorf("start extend session: %v", err)
+ }
+
+ uri := startResp.GetVerificationURIComplete()
+ if uri == "" {
+ uri = startResp.GetVerificationURI()
+ }
+ openURL(cmd, uri, startResp.GetUserCode(), noBrowser, showQR)
+
+ waitResp, err := client.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{
+ DeviceCode: startResp.GetDeviceCode(),
+ UserCode: startResp.GetUserCode(),
+ })
+ if err != nil {
+ return fmt.Errorf("wait for extend session: %v", err)
+ }
+
+ if ts := waitResp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
+ deadline := ts.AsTime().Local()
+ cmd.Printf("Session extended. New expiry: %s\n", deadline.Format("2006-01-02 15:04:05 MST"))
+ } else {
+ // Management reported the peer is not eligible (e.g. login
+ // expiration disabled on the account). Surface that fact
+ // instead of pretending the call succeeded.
+ cmd.Println("Session extension call completed, but the management server did not return a new deadline (peer may not be SSO-tracked or login expiration is disabled).")
+ }
+ return nil
+}
+
func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) (*profilemanager.Profile, error) {
// switch profile if provided
@@ -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)
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..ebaae7e3e 100644
--- a/client/cmd/root.go
+++ b/client/cmd/root.go
@@ -20,7 +20,6 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -71,12 +70,14 @@ var (
extraIFaceBlackList []string
anonymizeFlag bool
dnsRouteInterval time.Duration
- lazyConnEnabled bool
- mtu uint16
- profilesDisabled bool
- updateSettingsDisabled bool
- captureEnabled bool
- networksDisabled bool
+ // lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection
+ // flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead).
+ lazyConnEnabled bool
+ mtu uint16
+ profilesDisabled bool
+ updateSettingsDisabled bool
+ captureEnabled bool
+ networksDisabled bool
rootCmd = &cobra.Command{
Use: "netbird",
@@ -89,6 +90,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 +143,10 @@ func init() {
defaultDaemonAddr := "unix:///var/run/netbird.sock"
if runtime.GOOS == "windows" {
- defaultDaemonAddr = "tcp://127.0.0.1:41731"
+ defaultDaemonAddr = daddr.WindowsPipeAddr
}
- rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
+ rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
@@ -210,7 +212,8 @@ func init() {
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
- upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "[Experimental] Enable the lazy connection feature. If enabled, the client will establish connections on-demand. Note: this setting may be overridden by management configuration.")
+ upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.")
+ _ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable")
}
@@ -266,12 +269,10 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
- return grpc.DialContext(
- ctx,
- strings.TrimPrefix(addr, "tcp://"),
- grpc.WithTransportCredentials(insecure.NewCredentials()),
- grpc.WithBlock(),
- )
+ target, opts := daddr.DialTarget(addr)
+ opts = append(opts, grpc.WithBlock())
+
+ return grpc.DialContext(ctx, target, opts...)
}
// WithBackOff execute function in backoff cycle.
diff --git a/client/cmd/service.go b/client/cmd/service.go
index 56d8a8726..7410d60ea 100644
--- a/client/cmd/service.go
+++ b/client/cmd/service.go
@@ -5,6 +5,7 @@ package cmd
import (
"context"
"fmt"
+ "net/http"
"runtime"
"strings"
"sync"
@@ -22,15 +23,26 @@ var serviceCmd = &cobra.Command{
Short: "Manage the NetBird daemon service",
}
+const defaultJSONSocket = "unix:///var/run/netbird-http.sock"
+
var (
- serviceName string
- serviceEnvVars []string
+ serviceName string
+ serviceEnvVars []string
+ jsonSocket string
+ enableJSONSocket bool
)
type program struct {
- ctx context.Context
- cancel context.CancelFunc
- serv *grpc.Server
+ ctx context.Context
+ cancel context.CancelFunc
+ serv *grpc.Server
+ jsonServ *http.Server
+ // jsonClient is the gateway's own connection to the daemon. It is held so
+ // shutting the gateway down also closes it: nothing else references it once
+ // the handlers are registered, so its transport goroutines would otherwise
+ // outlive the server.
+ jsonClient *grpc.ClientConn
+ jsonServMu sync.Mutex
serverInstance *server.Server
serverInstanceMu sync.Mutex
}
@@ -46,6 +58,8 @@ func init() {
serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings")
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
+ serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket")
+ serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
serviceEnvDesc := `Sets extra environment variables for the service. ` +
diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go
index 8de147946..9ba3bce25 100644
--- a/client/cmd/service_controller.go
+++ b/client/cmd/service_controller.go
@@ -5,9 +5,7 @@ package cmd
import (
"context"
"fmt"
- "net"
- "os"
- "strings"
+ "runtime"
"time"
"github.com/kardianos/service"
@@ -16,69 +14,157 @@ import (
"github.com/spf13/cobra"
"google.golang.org/grpc"
+ "github.com/netbirdio/netbird/client/internal/daemonaddr"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/util"
)
+func validateJSONSocketFlags() error {
+ if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket {
+ return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway")
+ }
+ return nil
+}
+
+// daemonServerOptions installs the transport credentials that expose each
+// caller's kernel-authenticated identity to the handlers, which is what lets
+// the daemon require root/administrator for privileged operations.
+//
+// The handshake exchanges no bytes, so older CLI and UI binaries still
+// interoperate. Callers on a TCP socket carry no identity at all: the daemon
+// keeps serving them, and the privileged operations deny them, so a warning is
+// logged to make the loss of functionality visible.
+func daemonServerOptions(network string) []grpc.ServerOption {
+ if network == "tcp" {
+ log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
+ "so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+
+ "deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr)
+ return nil
+ }
+
+ creds := ipcauth.NewTransportCredentials()
+ if creds == nil {
+ log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS)
+ return nil
+ }
+
+ return []grpc.ServerOption{grpc.Creds(creds)}
+}
+
func (p *program) Start(svc service.Service) error {
// Start should not block. Do the actual work async.
log.Info("starting NetBird service") //nolint
+ if err := validateJSONSocketFlags(); err != nil {
+ return err
+ }
+
// Collect static system and platform information
system.UpdateStaticInfoAsync()
- // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
- p.serv = grpc.NewServer()
-
- split := strings.Split(daemonAddr, "://")
- switch split[0] {
- case "unix":
- // cleanup failed close
- stat, err := os.Stat(split[1])
- if err == nil && !stat.IsDir() {
- if err := os.Remove(split[1]); err != nil {
- log.Debugf("remove socket file: %v", err)
- }
- }
- case "tcp":
- default:
- return fmt.Errorf("unsupported daemon address protocol: %v", split[0])
+ // A daemon installed before named-pipe support has the loopback TCP address
+ // persisted. Move it to the named pipe so an upgraded daemon can identify
+ // its callers instead of silently serving an unauthenticated socket.
+ if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
+ log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated)
+ daemonAddr = migrated
}
- listen, err := net.Listen(split[0], split[1])
+ network, _, err := parseListenAddress(daemonAddr)
if err != nil {
- return fmt.Errorf("listen daemon interface: %w", err)
+ return fmt.Errorf("parse daemon address: %w", err)
}
+
+ // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
+ p.serv = grpc.NewServer(daemonServerOptions(network)...)
+
+ daemonListener, jsonListener, err := listenDaemonSockets()
+ if err != nil {
+ return err
+ }
+
go func() {
- defer listen.Close()
-
- if split[0] == "unix" {
- if err := os.Chmod(split[1], 0666); err != nil {
- log.Errorf("failed setting daemon permissions: %v", split[1])
- return
- }
- }
-
- serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
- if err := serverInstance.Start(); err != nil {
- log.Fatalf("failed to start daemon: %v", err)
- }
- proto.RegisterDaemonServiceServer(p.serv, serverInstance)
-
- p.serverInstanceMu.Lock()
- p.serverInstance = serverInstance
- p.serverInstanceMu.Unlock()
-
- log.Printf("started daemon server: %v", split[1])
- if err := p.serv.Serve(listen); err != nil {
- log.Errorf("failed to serve daemon requests: %v", err)
+ // Fatal here rather than inside serve, so serve's deferred listener
+ // closes run before the process exits.
+ if err := p.serve(daemonListener, jsonListener); err != nil {
+ log.Fatalf("failed to %v", err)
}
}()
return nil
}
+// listenDaemonSockets opens the daemon control socket and, when it is enabled, the
+// JSON gateway socket. The control socket is closed again if the second one fails,
+// so a failed start leaves nothing listening. The returned JSON listener is nil
+// when the socket is disabled.
+func listenDaemonSockets() (*socketListener, *socketListener, error) {
+ daemonListener, err := listenOnAddress(daemonAddr)
+ if err != nil {
+ return nil, nil, fmt.Errorf("listen daemon interface: %w", err)
+ }
+
+ if !enableJSONSocket {
+ removeStaleUnixSocketForAddress(jsonSocket)
+ return daemonListener, nil, nil
+ }
+
+ jsonListener, err := listenOnAddress(jsonSocket)
+ if err != nil {
+ if cerr := daemonListener.Close(); cerr != nil {
+ log.Debugf("close daemon listener: %v", cerr)
+ }
+ return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err)
+ }
+
+ return daemonListener, jsonListener, nil
+}
+
+// serve brings up the daemon server on an already-open control socket and blocks
+// until it stops. jsonListener is nil when the JSON socket is disabled. A returned
+// error means the daemon cannot run at all and the caller is expected to exit; the
+// failures it recovers from on its own are logged here.
+func (p *program) serve(daemonListener, jsonListener *socketListener) error {
+ defer daemonListener.Close()
+ if jsonListener != nil {
+ defer jsonListener.Close()
+ }
+
+ // chmodUnixSocket is a no-op for a nil listener and for a non-unix one.
+ if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
+ log.Error(err)
+ return nil
+ }
+ if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
+ log.Error(err)
+ return nil
+ }
+
+ serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
+ if err := serverInstance.Start(); err != nil {
+ return fmt.Errorf("start daemon: %w", err)
+ }
+ proto.RegisterDaemonServiceServer(p.serv, serverInstance)
+
+ p.serverInstanceMu.Lock()
+ p.serverInstance = serverInstance
+ p.serverInstanceMu.Unlock()
+
+ if jsonListener == nil {
+ log.Debug("daemon JSON socket disabled")
+ } else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
+ return fmt.Errorf("start daemon JSON server: %w", err)
+ }
+
+ log.Printf("started daemon server: %v", daemonListener.address)
+ if err := p.serv.Serve(daemonListener.Listener); err != nil {
+ log.Errorf("failed to serve daemon requests: %v", err)
+ }
+ return nil
+}
+
func (p *program) Stop(srv service.Service) error {
p.serverInstanceMu.Lock()
if p.serverInstance != nil {
@@ -92,6 +178,25 @@ func (p *program) Stop(srv service.Service) error {
p.cancel()
+ p.jsonServMu.Lock()
+ jsonServ, jsonClient := p.jsonServ, p.jsonClient
+ p.jsonServMu.Unlock()
+ if jsonClient != nil {
+ if err := jsonClient.Close(); err != nil {
+ log.Debugf("close daemon JSON gateway client: %v", err)
+ }
+ }
+ if jsonServ != nil {
+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
+ if err := jsonServ.Shutdown(shutdownCtx); err != nil {
+ log.Errorf("failed to stop daemon JSON server gracefully: %v", err)
+ if err := jsonServ.Close(); err != nil {
+ log.Errorf("failed to close daemon JSON server: %v", err)
+ }
+ }
+ shutdownCancel()
+ }
+
if p.serv != nil {
p.serv.Stop()
}
@@ -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..ed1f001a7
--- /dev/null
+++ b/client/cmd/service_socket.go
@@ -0,0 +1,119 @@
+//go:build !ios && !android
+
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "strings"
+ "syscall"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+type socketListener struct {
+ net.Listener
+ network string
+ address string
+}
+
+func listenOnAddress(addr string) (*socketListener, error) {
+ network, address, err := parseListenAddress(addr)
+ if err != nil {
+ return nil, err
+ }
+
+ if network == "npipe" {
+ listener, path, err := listenNamedPipe(address)
+ if err != nil {
+ return nil, err
+ }
+ return &socketListener{Listener: listener, network: network, address: path}, nil
+ }
+
+ if network == "unix" {
+ removeStaleUnixSocket(address)
+ }
+
+ listener, err := net.Listen(network, address)
+ if err != nil {
+ return nil, err
+ }
+
+ return &socketListener{Listener: listener, network: network, address: address}, nil
+}
+
+func parseListenAddress(addr string) (string, string, error) {
+ network, address, ok := strings.Cut(addr, "://")
+ if !ok || network == "" || address == "" {
+ return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr)
+ }
+
+ switch network {
+ case "unix", "tcp", "npipe":
+ return network, address, nil
+ default:
+ return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)
+ }
+}
+
+func removeStaleUnixSocket(path string) {
+ stat, err := os.Lstat(path)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ log.Debugf("stat socket file: %v", err)
+ }
+ return
+ }
+
+ if stat.Mode()&os.ModeSocket == 0 {
+ return
+ }
+
+ if !isStaleUnixSocket(path) {
+ return
+ }
+
+ if err := os.Remove(path); err != nil {
+ log.Debugf("remove socket file: %v", err)
+ }
+}
+
+func isStaleUnixSocket(path string) bool {
+ conn, err := net.DialTimeout("unix", path, 100*time.Millisecond)
+ if err == nil {
+ if closeErr := conn.Close(); closeErr != nil {
+ log.Debugf("close unix socket probe: %v", closeErr)
+ }
+ return false
+ }
+
+ if os.IsNotExist(err) || os.IsPermission(err) || os.IsTimeout(err) {
+ log.Debugf("not removing unix socket %s after probe error: %v", path, err)
+ return false
+ }
+
+ return errors.Is(err, syscall.ECONNREFUSED)
+}
+
+func removeStaleUnixSocketForAddress(addr string) {
+ network, address, err := parseListenAddress(addr)
+ if err != nil || network != "unix" {
+ return
+ }
+ removeStaleUnixSocket(address)
+}
+
+func (l *socketListener) chmodUnixSocket(description string) error {
+ if l == nil || l.network != "unix" {
+ return nil
+ }
+
+ if err := os.Chmod(l.address, 0666); err != nil {
+ return fmt.Errorf("failed setting %s permissions for %s: %w", description, l.address, err)
+ }
+ return nil
+}
diff --git a/client/cmd/service_test.go b/client/cmd/service_test.go
index ce6f71550..22eba206d 100644
--- a/client/cmd/service_test.go
+++ b/client/cmd/service_test.go
@@ -1,16 +1,12 @@
package cmd
import (
- "context"
- "fmt"
"os"
"os/signal"
"runtime"
"syscall"
"testing"
- "time"
- "github.com/kardianos/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -31,186 +27,6 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
-const (
- serviceStartTimeout = 10 * time.Second
- serviceStopTimeout = 5 * time.Second
- statusPollInterval = 500 * time.Millisecond
-)
-
-// waitForServiceStatus waits for service to reach expected status with timeout
-func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) {
- cfg, err := newSVCConfig()
- if err != nil {
- return false, err
- }
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- if err != nil {
- return false, err
- }
-
- ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout)
- defer timeoutCancel()
-
- ticker := time.NewTicker(statusPollInterval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus)
- case <-ticker.C:
- status, err := s.Status()
- if err != nil {
- // Continue polling on transient errors
- continue
- }
- if status == expectedStatus {
- return true, nil
- }
- }
- }
-}
-
-// TestServiceLifecycle tests the complete service lifecycle
-func TestServiceLifecycle(t *testing.T) {
- // TODO: Add support for Windows and macOS
- if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
- t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS)
- }
-
- if os.Getenv("CONTAINER") == "true" {
- t.Skip("Skipping service lifecycle test in container environment")
- }
-
- originalServiceName := serviceName
- serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix())
- defer func() {
- serviceName = originalServiceName
- }()
-
- tempDir := t.TempDir()
- configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir)
- logLevel = "info"
- daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir)
-
- // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run.
- t.Cleanup(func() {
- cfg, err := newSVCConfig()
- if err != nil {
- t.Errorf("cleanup: create service config: %v", err)
- return
- }
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- if err != nil {
- t.Errorf("cleanup: create service: %v", err)
- return
- }
-
- // If the subtests already cleaned up, there's nothing to do.
- if _, err := s.Status(); err != nil {
- return
- }
-
- if err := s.Stop(); err != nil {
- t.Errorf("cleanup: stop service: %v", err)
- }
- if err := s.Uninstall(); err != nil {
- t.Errorf("cleanup: uninstall service: %v", err)
- }
- })
-
- ctx := context.Background()
-
- t.Run("Install", func(t *testing.T) {
- installCmd.SetContext(ctx)
- err := installCmd.RunE(installCmd, []string{})
- require.NoError(t, err)
-
- cfg, err := newSVCConfig()
- require.NoError(t, err)
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- require.NoError(t, err)
-
- status, err := s.Status()
- assert.NoError(t, err)
- assert.NotEqual(t, service.StatusUnknown, status)
- })
-
- t.Run("Start", func(t *testing.T) {
- startCmd.SetContext(ctx)
- err := startCmd.RunE(startCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Restart", func(t *testing.T) {
- restartCmd.SetContext(ctx)
- err := restartCmd.RunE(restartCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Reconfigure", func(t *testing.T) {
- originalLogLevel := logLevel
- logLevel = "debug"
- defer func() {
- logLevel = originalLogLevel
- }()
-
- reconfigureCmd.SetContext(ctx)
- err := reconfigureCmd.RunE(reconfigureCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Stop", func(t *testing.T) {
- stopCmd.SetContext(ctx)
- err := stopCmd.RunE(stopCmd, []string{})
- require.NoError(t, err)
-
- stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout)
- require.NoError(t, err)
- assert.True(t, stopped)
- })
-
- t.Run("Uninstall", func(t *testing.T) {
- uninstallCmd.SetContext(ctx)
- err := uninstallCmd.RunE(uninstallCmd, []string{})
- require.NoError(t, err)
-
- cfg, err := newSVCConfig()
- require.NoError(t, err)
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- require.NoError(t, err)
-
- _, err = s.Status()
- assert.Error(t, err)
- })
-}
-
// TestServiceEnvVars tests environment variable parsing
func TestServiceEnvVars(t *testing.T) {
tests := []struct {
diff --git a/client/cmd/status.go b/client/cmd/status.go
index 5a7559cf1..c4057ed82 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,6 +116,11 @@ 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()
+ }
+
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
Anonymize: anonymizeFlag,
DaemonVersion: resp.GetDaemonVersion(),
@@ -125,6 +131,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/up.go b/client/cmd/up.go
index 0506bc65b..142bcf6bd 100644
--- a/client/cmd/up.go
+++ b/client/cmd/up.go
@@ -22,6 +22,8 @@ import (
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
+ nbnet "github.com/netbirdio/netbird/client/net"
+ "github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/util"
@@ -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
}
@@ -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 d0d88b177..99a6b8229 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -470,7 +470,7 @@ func (c *Client) Status() (peer.FullStatus, error) {
if connect != nil {
engine := connect.Engine()
if engine != nil {
- _ = engine.RunHealthProbes(false)
+ _ = engine.RunHealthProbes(context.Background(), false)
}
}
diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go
index cc4bda0e0..7b0989f6c 100644
--- a/client/firewall/iptables/manager_linux_test.go
+++ b/client/firewall/iptables/manager_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package iptables
import (
diff --git a/client/firewall/iptables/router_linux_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/legacy_rule_linux_test.go b/client/firewall/nftables/legacy_rule_linux_test.go
new file mode 100644
index 000000000..dc2f1c7a0
--- /dev/null
+++ b/client/firewall/nftables/legacy_rule_linux_test.go
@@ -0,0 +1,60 @@
+package nftables
+
+import (
+ "testing"
+
+ "github.com/google/nftables/expr"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildLegacyRouteRuleExpressions(t *testing.T) {
+ sourcePayload := &expr.Payload{}
+ sourceCmp := &expr.Cmp{}
+ destinationPayload := &expr.Payload{}
+ destinationCmp := &expr.Cmp{}
+ nilSourceDestination := &expr.Payload{}
+ nilDestinationSource := &expr.Cmp{}
+
+ tests := []struct {
+ name string
+ source []expr.Any
+ destination []expr.Any
+ matches []expr.Any
+ }{
+ {
+ name: "both non-empty",
+ source: []expr.Any{sourcePayload, sourceCmp},
+ destination: []expr.Any{destinationPayload, destinationCmp},
+ matches: []expr.Any{sourcePayload, sourceCmp, destinationPayload, destinationCmp},
+ },
+ {
+ name: "nil source",
+ destination: []expr.Any{nilSourceDestination},
+ matches: []expr.Any{nilSourceDestination},
+ },
+ {
+ name: "nil destination",
+ source: []expr.Any{nilDestinationSource},
+ matches: []expr.Any{nilDestinationSource},
+ },
+ {
+ name: "both nil",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := buildLegacyRouteRuleExpressions(tt.source, tt.destination)
+
+ require.Len(t, got, len(tt.matches)+2)
+ for i, match := range tt.matches {
+ require.Same(t, match, got[i])
+ }
+
+ require.IsType(t, &expr.Counter{}, got[len(tt.matches)])
+ verdict, ok := got[len(tt.matches)+1].(*expr.Verdict)
+ require.True(t, ok)
+ require.Equal(t, expr.VerdictAccept, verdict.Kind)
+ })
+ }
+}
diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go
index be4f65881..4eb466281 100644
--- a/client/firewall/nftables/manager_linux_test.go
+++ b/client/firewall/nftables/manager_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package nftables
import (
diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go
index 4214455a9..dfb94c514 100644
--- a/client/firewall/nftables/router_linux.go
+++ b/client/firewall/nftables/router_linux.go
@@ -953,6 +953,17 @@ func (r *router) addMSSClampingRules() error {
return r.conn.Flush()
}
+func buildLegacyRouteRuleExpressions(sourceExp, destExp []expr.Any) []expr.Any {
+ exprs := make([]expr.Any, 0, len(sourceExp)+len(destExp)+2)
+ exprs = append(exprs, sourceExp...)
+ exprs = append(exprs, destExp...)
+ exprs = append(exprs,
+ &expr.Counter{},
+ &expr.Verdict{Kind: expr.VerdictAccept},
+ )
+ return exprs
+}
+
// addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls
func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error {
sourceExp, err := r.applyNetwork(pair.Source, nil, true)
@@ -965,15 +976,7 @@ func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error {
return fmt.Errorf("apply destination: %w", err)
}
- exprs := []expr.Any{
- &expr.Counter{},
- &expr.Verdict{
- Kind: expr.VerdictAccept,
- },
- }
-
- exprs = append(exprs, sourceExp...)
- exprs = append(exprs, destExp...)
+ exprs := buildLegacyRouteRuleExpressions(sourceExp, destExp)
ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair)
diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go
index c5d6729d9..2fc664d51 100644
--- a/client/firewall/nftables/router_linux_test.go
+++ b/client/firewall/nftables/router_linux_test.go
@@ -1,4 +1,4 @@
-//go:build !android
+//go:build !android && privileged
package nftables
diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go
index 91866dcab..7376e59ca 100644
--- a/client/firewall/uspfilter/filter.go
+++ b/client/firewall/uspfilter/filter.go
@@ -121,6 +121,7 @@ type Manager struct {
udpTracker *conntrack.UDPTracker
icmpTracker *conntrack.ICMPTracker
tcpTracker *conntrack.TCPTracker
+ fragments *fragmentTracker
forwarder atomic.Pointer[forwarder.Forwarder]
pendingCapture atomic.Pointer[forwarder.PacketCapture]
logger *nblog.Logger
@@ -183,6 +184,41 @@ func (d *decoder) decodePacket(data []byte) error {
}
}
+// decodeTransport decodes the transport header of a first fragment (which
+// gopacket leaves undecoded) into the decoder and appends its layer type to
+// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns
+// false if the protocol is unsupported or the header is truncated.
+func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool {
+ var l4 gopacket.DecodingLayer
+ var layerType gopacket.LayerType
+ var minLen int
+ switch proto {
+ case layers.IPProtocolTCP:
+ l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20
+ case layers.IPProtocolUDP:
+ l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8
+ case layers.IPProtocolICMPv4:
+ l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8
+ case layers.IPProtocolICMPv6:
+ l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8
+ default:
+ return false
+ }
+
+ // Reject a fragment too small to hold the full transport header before
+ // decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping
+ // the decode avoids gopacket allocating an error on the drop path.
+ if len(payload) < minLen {
+ return false
+ }
+
+ if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil {
+ return false
+ }
+ d.decoded = append(d.decoded, layerType)
+ return true
+}
+
// Create userspace firewall manager constructor
func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) {
return create(iface, nil, disableServerRoutes, flowLogger, mtu)
@@ -286,6 +322,8 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
if err := m.localipmanager.UpdateLocalIPs(iface); err != nil {
return nil, fmt.Errorf("update local IPs: %w", err)
}
+ m.fragments = newFragmentTracker(m.logger)
+
if disableConntrack {
log.Info("conntrack is disabled")
} else {
@@ -299,6 +337,7 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
}
}
if err := iface.SetFilter(m); err != nil {
+ m.fragments.Close()
return nil, fmt.Errorf("set filter: %w", err)
}
return m, nil
@@ -694,6 +733,10 @@ func (m *Manager) resetState() {
m.tcpTracker.Close()
}
+ if m.fragments != nil {
+ m.fragments.Close()
+ }
+
if fwder := m.forwarder.Load(); fwder != nil {
fwder.SetCapture(nil)
fwder.Stop()
@@ -1046,19 +1089,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
return true
}
- // TODO: pass fragments of routed packets to forwarder
+ // gopacket does not decode the transport header of any IP fragment, so
+ // fragments take a dedicated path: the first fragment's header is decoded
+ // and ACL-evaluated here, and the remaining fragments inherit its verdict.
if fragment {
- if m.logger.Enabled(nblog.LevelTrace) {
- if d.decoded[0] == layers.LayerTypeIPv4 {
- m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
- srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
- } else {
- m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
- }
- }
- return false
+ return m.filterInboundFragment(d, srcIP, dstIP, size)
}
+ return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size)
+}
+
+// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully
+// decoded (non-fragment) inbound packet. It returns true if the packet should
+// be dropped.
+func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
// TODO: optimize port DNAT by caching matched rules in conntrack
if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated {
// Re-decode after port DNAT translation to update port information
@@ -1089,33 +1133,226 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size)
}
+// fragmentMeta holds the reassembly identity and layout of an IP fragment,
+// extracted uniformly for IPv4 and IPv6.
+type fragmentMeta struct {
+ key fragmentKey
+ // offset is the fragment offset in 8-byte units (zero for the first
+ // fragment).
+ offset uint16
+ // moreFragments is the More Fragments bit. A first fragment with it unset is
+ // an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing
+ // fragments to inherit a verdict, so it must not be recorded.
+ moreFragments bool
+ proto layers.IPProtocol
+ // l4payload is the fragmentable payload of this fragment. For the first
+ // fragment it starts with the transport header.
+ l4payload []byte
+ // headerEndOctets is the first fragment's payload length in 8-byte units:
+ // the smallest offset a trailing fragment may start at without overlapping
+ // the inspected transport header.
+ headerEndOctets uint16
+}
+
+// fragmentMetadata extracts the fragment identity and layout from a decoded IP
+// fragment. It returns false for fragments it can't interpret (e.g. an IPv6
+// fragment header shorter than 8 bytes), which are then dropped.
+func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) {
+ switch d.decoded[0] {
+ case layers.LayerTypeIPv4:
+ payload := d.ip4.Payload
+ return fragmentMeta{
+ key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)},
+ offset: d.ip4.FragOffset,
+ moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0,
+ proto: d.ip4.Protocol,
+ l4payload: payload,
+ headerEndOctets: octets(len(payload)),
+ }, true
+
+ case layers.LayerTypeIPv6:
+ // IPv6 fragment extension header: 8 bytes, followed by the fragmentable
+ // payload. Layout: next header (1), reserved (1), offset+flags (2), id (4).
+ payload := d.ip6.Payload
+ if len(payload) < 8 {
+ return fragmentMeta{}, false
+ }
+ nextHeader := layers.IPProtocol(payload[0])
+ offsetFlags := binary.BigEndian.Uint16(payload[2:4])
+ id := binary.BigEndian.Uint32(payload[4:8])
+ l4 := payload[8:]
+ return fragmentMeta{
+ key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)},
+ offset: offsetFlags >> 3,
+ moreFragments: offsetFlags&1 != 0,
+ proto: nextHeader,
+ l4payload: l4,
+ headerEndOctets: octets(len(l4)),
+ }, true
+
+ default:
+ return fragmentMeta{}, false
+ }
+}
+
+// octets rounds a byte length up to whole 8-byte units, the granularity of the
+// IP fragment offset field.
+func octets(nbytes int) uint16 {
+ return uint16((nbytes + 7) / 8)
+}
+
+// filterInboundFragment decides the fate of an IP fragment. gopacket stops
+// decoding at the network layer for every fragment, so the first fragment's
+// transport header is decoded and ACL-evaluated here and its verdict recorded;
+// the remaining (headerless) fragments inherit that verdict. Anything that
+// cannot be tied to an allowed, non-overlapping first fragment is dropped.
+func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool {
+ meta, ok := fragmentMetadata(d, srcIP, dstIP)
+ if !ok {
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP)
+ }
+ return true
+ }
+
+ if meta.offset != 0 {
+ return m.filterTrailingFragment(meta, srcIP, dstIP)
+ }
+
+ // A new first fragment supersedes any recorded verdict for this datagram, so
+ // a re-sent or overlapping offset-zero fragment can't inherit the old one.
+ m.fragments.poison(meta.key)
+
+ // First fragment: decode its transport header so the ACL can evaluate it. A
+ // decode failure means the fragment is too small to hold the full transport
+ // header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it.
+ if !d.decodeTransport(meta.proto, meta.l4payload) {
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v",
+ srcIP, dstIP, meta.key.id)
+ }
+ return true
+ }
+
+ return m.filterFirstFragment(d, meta, srcIP, dstIP, size)
+}
+
+// filterTrailingFragment applies a recorded first-fragment verdict to a
+// non-first fragment.
+func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool {
+ switch m.fragments.verdict(meta.key, meta.offset) {
+ case fragmentAllow:
+ return false
+ case fragmentOverlap:
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v",
+ srcIP, dstIP, meta.key.id)
+ }
+ return true
+ default:
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v",
+ srcIP, dstIP, meta.key.id)
+ }
+ return true
+ }
+}
+
+// filterFirstFragment runs the verdict part of the inbound pipeline on a first
+// fragment with its transport header decoded. It mirrors filterInboundDecoded
+// but skips DNAT (port rewriting on fragments is unsupported) and forwarder
+// injection (fragments are left to the stack to reassemble, not forwarded).
+// Allowed fragments have their verdict recorded so the datagram's trailing
+// fragments inherit it.
+func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool {
+ if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) {
+ m.recordFirstFragment(meta)
+ return false
+ }
+
+ if m.localipmanager.IsLocalIP(dstIP) {
+ ruleID, blocked := m.peerACLsBlock(srcIP, d, nil)
+ if blocked {
+ m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
+ d, srcIP, dstIP, ruleID, size)
+ return true
+ }
+ m.trackInbound(d, srcIP, dstIP, ruleID, size)
+ m.recordFirstFragment(meta)
+ return false
+ }
+
+ if !m.routingEnabled.Load() {
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP)
+ }
+ return true
+ }
+ if m.nativeRouter.Load() {
+ m.trackInbound(d, srcIP, dstIP, nil, size)
+ m.recordFirstFragment(meta)
+ return false
+ }
+
+ // TODO: pass fragments of routed packets to the forwarder; until then
+ // allowed routed fragments go to the native stack.
+ srcPort, dstPort := getPortsFromPacket(d)
+ ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort)
+ if !pass {
+ m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
+ d, srcIP, dstIP, ruleID, size)
+ return true
+ }
+
+ m.recordFirstFragment(meta)
+ return false
+}
+
+// recordFirstFragment caches an allowed first fragment's verdict for its
+// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are
+// complete datagrams with no trailing fragments, so they are not cached and
+// cannot exhaust the verdict table.
+func (m *Manager) recordFirstFragment(meta fragmentMeta) {
+ if !meta.moreFragments {
+ return
+ }
+ m.fragments.recordAllowed(meta.key, meta.headerEndOctets)
+}
+
+// storeDropFlow logs and records a netflow drop event for an inbound packet
+// denied by the ACLs. msg is the trace format taking rule id, protocol, source
+// and destination.
+func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) {
+ pnum := getProtocolFromPacket(d)
+ srcPort, dstPort := getPortsFromPacket(d)
+
+ if m.logger.Enabled(nblog.LevelTrace) {
+ m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
+ }
+
+ m.flowLogger.StoreEvent(nftypes.EventFields{
+ FlowID: uuid.New(),
+ Type: nftypes.TypeDrop,
+ RuleID: ruleID,
+ Direction: nftypes.Ingress,
+ Protocol: pnum,
+ SourceIP: srcIP,
+ DestIP: dstIP,
+ SourcePort: srcPort,
+ DestPort: dstPort,
+ // TODO: icmp type/code
+ RxPackets: 1,
+ RxBytes: uint64(size),
+ })
+}
+
// handleLocalTraffic handles local traffic.
// If it returns true, the packet should be dropped.
func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData)
if blocked {
- pnum := getProtocolFromPacket(d)
- srcPort, dstPort := getPortsFromPacket(d)
-
- if m.logger.Enabled(nblog.LevelTrace) {
- m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
- ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
- }
-
- m.flowLogger.StoreEvent(nftypes.EventFields{
- FlowID: uuid.New(),
- Type: nftypes.TypeDrop,
- RuleID: ruleID,
- Direction: nftypes.Ingress,
- Protocol: pnum,
- SourceIP: srcIP,
- DestIP: dstIP,
- SourcePort: srcPort,
- DestPort: dstPort,
- // TODO: icmp type/code
- RxPackets: 1,
- RxBytes: uint64(size),
- })
+ m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
+ d, srcIP, dstIP, ruleID, size)
return true
}
@@ -1168,27 +1405,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe
ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort)
if !pass {
- proto := getProtocolFromPacket(d)
-
- if m.logger.Enabled(nblog.LevelTrace) {
- m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
- ruleID, proto, srcIP, srcPort, dstIP, dstPort)
- }
-
- m.flowLogger.StoreEvent(nftypes.EventFields{
- FlowID: uuid.New(),
- Type: nftypes.TypeDrop,
- RuleID: ruleID,
- Direction: nftypes.Ingress,
- Protocol: proto,
- SourceIP: srcIP,
- DestIP: dstIP,
- SourcePort: srcPort,
- DestPort: dstPort,
- // TODO: icmp type/code
- RxPackets: 1,
- RxBytes: uint64(size),
- })
+ m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
+ d, srcIP, dstIP, ruleID, size)
return true
}
diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go
index 6291eb285..28320ad88 100644
--- a/client/firewall/uspfilter/forwarder/forwarder.go
+++ b/client/firewall/uspfilter/forwarder/forwarder.go
@@ -5,7 +5,9 @@ import (
"fmt"
"net"
"net/netip"
+ "os"
"runtime"
+ "strconv"
"sync"
"time"
@@ -31,6 +33,11 @@ const (
defaultMaxInFlight = 1024
iosReceiveWindow = 16384
iosMaxInFlight = 256
+
+ // envForceTCPRACK overrides the platform default for gVisor's RACK loss
+ // detection. Set to a truthy value to force RACK on, or a falsy value to
+ // force it off, on any platform.
+ envForceTCPRACK = "NB_FORCE_TCP_RACK"
)
type Forwarder struct {
@@ -152,6 +159,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
maxInFlight = iosMaxInFlight
}
+ configureTCPRecovery(s)
+
tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP)
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
@@ -466,3 +475,31 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool {
logger.Debug1("forwarder: raw %s socket access available", network)
return true
}
+
+// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where
+// it interacts poorly with the host and collapses throughput on routed TCP
+// connections (gVisor issue #9778). Other platforms keep the default. The
+// EnvForceTCPRACK environment variable overrides the platform default.
+func configureTCPRecovery(s *stack.Stack) {
+ disableRACK := runtime.GOOS == "windows"
+
+ if val := os.Getenv(envForceTCPRACK); val != "" {
+ force, err := strconv.ParseBool(val)
+ if err != nil {
+ log.Warnf("parse %s: %v", envForceTCPRACK, err)
+ } else {
+ disableRACK = !force
+ }
+ }
+
+ if !disableRACK {
+ return
+ }
+
+ opt := tcpip.TCPRecovery(0)
+ if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil {
+ log.Warnf("disable TCP RACK loss detection: %v", err)
+ return
+ }
+ log.Info("forwarder: TCP RACK loss detection disabled")
+}
diff --git a/client/firewall/uspfilter/fragment.go b/client/firewall/uspfilter/fragment.go
new file mode 100644
index 000000000..accc54365
--- /dev/null
+++ b/client/firewall/uspfilter/fragment.go
@@ -0,0 +1,204 @@
+package uspfilter
+
+import (
+ "context"
+ "net/netip"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+
+ nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
+)
+
+const (
+ // defaultFragmentTimeout bounds how long a first-fragment verdict is kept
+ // while the remaining fragments arrive. It mirrors the Linux IP reassembly
+ // timeout (net.ipv4.ipfrag_time).
+ defaultFragmentTimeout = 30 * time.Second
+ // fragmentCleanupInterval is how often expired verdicts are purged.
+ fragmentCleanupInterval = 10 * time.Second
+ // defaultMaxFragmentEntries caps the number of concurrently tracked
+ // fragmented datagrams. The table stays bounded because each datagram is a
+ // single small entry regardless of how many fragments it is split into, and
+ // the 13-bit IPv4 fragment-offset field limits any datagram to 64 KiB.
+ defaultMaxFragmentEntries = 16384
+
+ // EnvFragmentMaxEntries overrides defaultMaxFragmentEntries.
+ EnvFragmentMaxEntries = "NB_FRAGMENT_MAX_ENTRIES"
+)
+
+// fragmentVerdict is the decision for a trailing (headerless) fragment.
+type fragmentVerdict int
+
+const (
+ // fragmentDeny drops the fragment: no allowed first fragment is on record.
+ fragmentDeny fragmentVerdict = iota
+ // fragmentAllow passes the fragment: it belongs to an allowed datagram and
+ // does not overlap the already-inspected transport header.
+ fragmentAllow
+ // fragmentOverlap drops the fragment and poisons its datagram: it overlaps
+ // the transport header the ACL inspected (RFC 1858 §4, RFC 3128; RFC 5722
+ // requires discarding the whole datagram on overlap for IPv6).
+ fragmentOverlap
+)
+
+// fragmentKey identifies a fragmented datagram. It matches the RFC 791 / RFC
+// 8200 reassembly key: source, destination, protocol and identification. The id
+// is 32-bit to hold both the IPv4 (16-bit) and IPv6 (32-bit) identification.
+type fragmentKey struct {
+ srcIP netip.Addr
+ dstIP netip.Addr
+ id uint32
+ proto uint8
+}
+
+// fragmentEntry records the verdict of an allowed first fragment.
+type fragmentEntry struct {
+ // headerEndOctets is the offset, in 8-byte units, at which the first
+ // fragment's payload ended. A trailing fragment starting before this
+ // overlaps bytes the ACL already inspected and is rejected.
+ headerEndOctets uint16
+ // recordedAt is when the first fragment was accepted. The verdict expires a
+ // fixed timeout later and is not refreshed, mirroring the kernel reassembly
+ // timer so a trailing-fragment flood can't keep a datagram alive.
+ recordedAt time.Time
+}
+
+// fragmentTracker records the ACL verdict of a datagram's first fragment so the
+// remaining fragments, which carry no L4 header, can inherit the decision
+// without reassembling the datagram. Only allowed first fragments are stored;
+// anything that cannot be tied to an allowed, non-overlapping first fragment is
+// dropped (fail closed).
+type fragmentTracker struct {
+ logger *nblog.Logger
+ mutex sync.Mutex
+ entries map[fragmentKey]fragmentEntry
+ timeout time.Duration
+ // maxEntries caps the table; atCapacity dedups the capacity warning until
+ // the table drains below the cap again.
+ maxEntries int
+ atCapacity bool
+ cleanupTicker *time.Ticker
+ cancel context.CancelFunc
+}
+
+func newFragmentTracker(logger *nblog.Logger) *fragmentTracker {
+ ctx, cancel := context.WithCancel(context.Background())
+ t := &fragmentTracker{
+ logger: logger,
+ entries: make(map[fragmentKey]fragmentEntry),
+ timeout: defaultFragmentTimeout,
+ maxEntries: fragmentMaxEntries(logger),
+ cleanupTicker: time.NewTicker(fragmentCleanupInterval),
+ cancel: cancel,
+ }
+ go t.cleanupRoutine(ctx)
+ return t
+}
+
+func fragmentMaxEntries(logger *nblog.Logger) int {
+ v := os.Getenv(EnvFragmentMaxEntries)
+ if v == "" {
+ return defaultMaxFragmentEntries
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil || n <= 0 {
+ logger.Warn2("invalid %s=%q, using default", EnvFragmentMaxEntries, v)
+ return defaultMaxFragmentEntries
+ }
+ return n
+}
+
+// recordAllowed stores the verdict of an allowed first fragment. headerEndOctets
+// is the first fragment's payload length in 8-byte units. When the table is full
+// the record is dropped, which fails closed: the datagram's trailing fragments
+// will be denied.
+func (t *fragmentTracker) recordAllowed(key fragmentKey, headerEndOctets uint16) {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+
+ if t.entries == nil {
+ return
+ }
+ if _, ok := t.entries[key]; !ok && len(t.entries) >= t.maxEntries {
+ if !t.atCapacity {
+ t.atCapacity = true
+ t.logger.Warn2("fragment verdict table at capacity (%d/%d): trailing fragments of new datagrams will be dropped",
+ len(t.entries), t.maxEntries)
+ }
+ return
+ }
+ t.entries[key] = fragmentEntry{
+ headerEndOctets: headerEndOctets,
+ recordedAt: time.Now(),
+ }
+}
+
+// poison drops any recorded verdict for a datagram, so its later fragments are
+// denied until a new allowed first fragment is recorded. Called on every
+// offset-zero fragment to defeat offset-zero overlap rewrites (RFC 3128).
+func (t *fragmentTracker) poison(key fragmentKey) {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+ delete(t.entries, key)
+}
+
+// verdict decides the fate of a trailing fragment at fragOffsetOctets (the IPv4
+// fragment offset, in 8-byte units). A fragment overlapping the inspected
+// header poisons the datagram: the entry is removed so all further fragments of
+// that datagram are denied too.
+func (t *fragmentTracker) verdict(key fragmentKey, fragOffsetOctets uint16) fragmentVerdict {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+
+ entry, ok := t.entries[key]
+ if !ok {
+ return fragmentDeny
+ }
+ if time.Since(entry.recordedAt) > t.timeout {
+ delete(t.entries, key)
+ return fragmentDeny
+ }
+ if fragOffsetOctets < entry.headerEndOctets {
+ delete(t.entries, key)
+ return fragmentOverlap
+ }
+ return fragmentAllow
+}
+
+func (t *fragmentTracker) cleanupRoutine(ctx context.Context) {
+ defer t.cleanupTicker.Stop()
+ for {
+ select {
+ case <-t.cleanupTicker.C:
+ t.cleanup()
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+func (t *fragmentTracker) cleanup() {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+
+ for key, entry := range t.entries {
+ if time.Since(entry.recordedAt) > t.timeout {
+ delete(t.entries, key)
+ }
+ }
+
+ if len(t.entries) < t.maxEntries {
+ t.atCapacity = false
+ }
+}
+
+// Close stops the cleanup routine and releases resources.
+func (t *fragmentTracker) Close() {
+ t.cancel()
+
+ t.mutex.Lock()
+ t.entries = nil
+ t.mutex.Unlock()
+}
diff --git a/client/firewall/uspfilter/fragment_bench_test.go b/client/firewall/uspfilter/fragment_bench_test.go
new file mode 100644
index 000000000..a9e6d2d13
--- /dev/null
+++ b/client/firewall/uspfilter/fragment_bench_test.go
@@ -0,0 +1,115 @@
+package uspfilter
+
+import (
+ "encoding/binary"
+ "testing"
+)
+
+// benchFilterInbound drives filterInbound over a fixed packet in a tight loop.
+// Packets are built once, outside the timed region, so the benchmark measures
+// only pipeline cost, which is what an attacker can amplify.
+func benchFilterInbound(b *testing.B, pkt []byte) {
+ b.Helper()
+ b.ReportAllocs()
+ b.SetBytes(int64(len(pkt)))
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ m := benchManager
+ m.filterInbound(pkt, len(pkt))
+ }
+}
+
+// benchManager is a package-level manager reused across fragment benchmarks so
+// setup cost stays out of the timed region.
+var benchManager *Manager
+
+func setupBenchManager(b *testing.B) *Manager {
+ b.Helper()
+ m := newFragmentTestManager(b)
+ allowUDP(b, m, 8080)
+ // Disable conntrack so the allowed-first-fragment path measures transport
+ // decode + ACL every iteration instead of matching the connection tracked
+ // on the first iteration.
+ m.stateful = false
+ benchManager = m
+ return m
+}
+
+// BenchmarkInbound_NormalPacket is the baseline: a full, non-fragmented UDP
+// packet that passes the ACL. Fragment paths should stay comparable to this.
+func BenchmarkInbound_NormalPacket(b *testing.B) {
+ setupBenchManager(b)
+ pkt := normalUDPPacket(b, 8080, 32)
+ benchFilterInbound(b, pkt)
+}
+
+// BenchmarkInbound_FirstFragmentAllowed measures the first-fragment path:
+// transport decode + ACL evaluation + verdict record.
+func BenchmarkInbound_FirstFragmentAllowed(b *testing.B) {
+ setupBenchManager(b)
+ pkt := firstFragmentUDP(b, 0x2000, 8080, 32)
+ benchFilterInbound(b, pkt)
+}
+
+// BenchmarkInbound_TrailingFragmentAllowed measures the common trailing-fragment
+// path: a single map lookup after the first fragment is on record.
+func BenchmarkInbound_TrailingFragmentAllowed(b *testing.B) {
+ m := setupBenchManager(b)
+ first := firstFragmentUDP(b, 0x3000, 8080, 32)
+ m.filterInbound(first, len(first))
+ pkt := trailingFragment(b, 0x3000, 5, false, 24)
+ benchFilterInbound(b, pkt)
+}
+
+// BenchmarkInbound_TrailingFragmentNoFirst is the primary DoS vector: an
+// attacker floods trailing fragments with no first fragment on record. Each is
+// a map miss and must be cheap.
+func BenchmarkInbound_TrailingFragmentNoFirst(b *testing.B) {
+ setupBenchManager(b)
+ pkt := trailingFragment(b, 0x4000, 185, false, 40)
+ benchFilterInbound(b, pkt)
+}
+
+// BenchmarkInbound_TinyFirstFragment measures the tiny-fragment drop path: a
+// first fragment too small to decode a transport header.
+func BenchmarkInbound_TinyFirstFragment(b *testing.B) {
+ setupBenchManager(b)
+ pkt := trailingFragment(b, 0x5000, 0, true, 4)
+ benchFilterInbound(b, pkt)
+}
+
+// BenchmarkInbound_TrailingFragmentDistinctIDs is the worst case for the
+// verdict table: an attacker varies the datagram id on every packet so no first
+// fragment ever matches. Verdict lookups always miss and nothing is recorded,
+// so the table cannot grow. Each iteration rewrites the id field in place.
+func BenchmarkInbound_TrailingFragmentDistinctIDs(b *testing.B) {
+ setupBenchManager(b)
+ pkt := trailingFragment(b, 0x6000, 185, false, 40)
+ m := benchManager
+
+ b.ReportAllocs()
+ b.SetBytes(int64(len(pkt)))
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ // IPv4 identification field is at bytes 4:6.
+ binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
+ m.filterInbound(pkt, len(pkt))
+ }
+}
+
+// BenchmarkInbound_FirstFragmentDistinctIDs measures sustained first-fragment
+// pressure with distinct ids: transport decode + ACL + verdict insert until the
+// table caps, exercising the map growth and capacity guard.
+func BenchmarkInbound_FirstFragmentDistinctIDs(b *testing.B) {
+ setupBenchManager(b)
+ pkt := firstFragmentUDP(b, 0x7000, 8080, 32)
+ m := benchManager
+
+ b.ReportAllocs()
+ b.SetBytes(int64(len(pkt)))
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
+ m.filterInbound(pkt, len(pkt))
+ }
+}
diff --git a/client/firewall/uspfilter/fragment_test.go b/client/firewall/uspfilter/fragment_test.go
new file mode 100644
index 000000000..6960e4dda
--- /dev/null
+++ b/client/firewall/uspfilter/fragment_test.go
@@ -0,0 +1,554 @@
+package uspfilter
+
+import (
+ "encoding/binary"
+ "net"
+ "net/netip"
+ "testing"
+ "time"
+
+ "github.com/google/gopacket"
+ "github.com/google/gopacket/layers"
+ "github.com/stretchr/testify/require"
+
+ fw "github.com/netbirdio/netbird/client/firewall/manager"
+ nbiface "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/iface/device"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+)
+
+const (
+ fragTestSrc = "100.10.0.1"
+ fragTestDst = "100.10.0.100"
+ fragTestSrcV6 = "fd00::1"
+ fragTestDstV6 = "fd00::100"
+)
+
+func newFragmentTestManager(tb testing.TB) *Manager {
+ tb.Helper()
+
+ ifaceMock := &IFaceMock{
+ SetFilterFunc: func(device.PacketFilter) error { return nil },
+ AddressFunc: func() wgaddr.Address {
+ return wgaddr.Address{
+ IP: netip.MustParseAddr(fragTestDst),
+ Network: netip.MustParsePrefix("100.10.0.0/16"),
+ IPv6: netip.MustParseAddr(fragTestDstV6),
+ IPv6Net: netip.MustParsePrefix("fd00::/64"),
+ }
+ },
+ }
+
+ m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU)
+ require.NoError(tb, err)
+ require.NoError(tb, m.UpdateLocalIPs())
+ tb.Cleanup(func() { require.NoError(tb, m.Close(nil)) })
+ return m
+}
+
+// firstFragmentUDPTo builds the first fragment of a fragmented UDP datagram to
+// the given destination: it carries the full UDP header plus payloadLen bytes
+// of data, with the More Fragments flag set and offset zero.
+func firstFragmentUDPTo(tb testing.TB, dst string, id uint16, dstPort uint16, payloadLen int) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv4{
+ Version: 4,
+ TTL: 64,
+ Id: id,
+ Protocol: layers.IPProtocolUDP,
+ SrcIP: net.ParseIP(fragTestSrc),
+ DstIP: net.ParseIP(dst),
+ Flags: layers.IPv4MoreFragments,
+ }
+ udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
+ require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
+
+ buf := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
+ require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
+ return buf.Bytes()
+}
+
+func firstFragmentUDP(tb testing.TB, id uint16, dstPort uint16, payloadLen int) []byte {
+ tb.Helper()
+ return firstFragmentUDPTo(tb, fragTestDst, id, dstPort, payloadLen)
+}
+
+// firstFragmentTCP builds the first fragment of a fragmented TCP datagram: the
+// full 20-byte TCP header plus 12 bytes of data, with the More Fragments flag
+// set and offset zero.
+func firstFragmentTCP(tb testing.TB, id uint16, dstPort uint16) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv4{
+ Version: 4,
+ TTL: 64,
+ Id: id,
+ Protocol: layers.IPProtocolTCP,
+ SrcIP: net.ParseIP(fragTestSrc),
+ DstIP: net.ParseIP(fragTestDst),
+ Flags: layers.IPv4MoreFragments,
+ }
+ tcp := &layers.TCP{SrcPort: 40000, DstPort: layers.TCPPort(dstPort), SYN: true, Window: 64240}
+ require.NoError(tb, tcp.SetNetworkLayerForChecksum(ip))
+
+ buf := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
+ require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(make([]byte, 12))))
+ return buf.Bytes()
+}
+
+// trailingFragmentTo builds a non-first fragment to the given destination: an
+// IPv4 header at the given fragment offset (in 8-byte units) carrying raw
+// payload and no L4 header.
+func trailingFragmentTo(tb testing.TB, dst string, proto layers.IPProtocol, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv4{
+ Version: 4,
+ TTL: 64,
+ Id: id,
+ Protocol: proto,
+ SrcIP: net.ParseIP(fragTestSrc),
+ DstIP: net.ParseIP(dst),
+ FragOffset: fragOffsetOctets,
+ }
+ if moreFragments {
+ ip.Flags = layers.IPv4MoreFragments
+ }
+
+ buf := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{FixLengths: true}
+ require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, gopacket.Payload(make([]byte, payloadLen))))
+ return buf.Bytes()
+}
+
+func trailingFragment(tb testing.TB, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
+ tb.Helper()
+ return trailingFragmentTo(tb, fragTestDst, layers.IPProtocolUDP, id, fragOffsetOctets, moreFragments, payloadLen)
+}
+
+// outboundUDPPacket builds a complete outbound UDP packet from the local
+// address, used to establish conntrack state for reply-direction tests.
+func outboundUDPPacket(tb testing.TB, srcPort, dstPort uint16) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv4{
+ Version: 4,
+ TTL: 64,
+ Id: 1,
+ Protocol: layers.IPProtocolUDP,
+ SrcIP: net.ParseIP(fragTestDst),
+ DstIP: net.ParseIP(fragTestSrc),
+ }
+ udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)}
+ require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
+
+ buf := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
+ require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, 16))))
+ return buf.Bytes()
+}
+
+// normalUDPPacket builds a complete, non-fragmented UDP packet for baseline
+// comparisons against the fragment paths.
+func normalUDPPacket(tb testing.TB, dstPort uint16, payloadLen int) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv4{
+ Version: 4,
+ TTL: 64,
+ Id: 1,
+ Protocol: layers.IPProtocolUDP,
+ SrcIP: net.ParseIP(fragTestSrc),
+ DstIP: net.ParseIP(fragTestDst),
+ }
+ udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
+ require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
+
+ buf := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
+ require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
+ return buf.Bytes()
+}
+
+func allowUDP(tb testing.TB, m *Manager, dstPort uint16) {
+ tb.Helper()
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolUDP, nil,
+ &fw.Port{Values: []uint16{dstPort}}, fw.ActionAccept, "")
+ require.NoError(tb, err)
+}
+
+// TestFragment_TrailingWithoutFirstDropped is the core bypass repro: a trailing
+// fragment with no allowed first fragment on record must be dropped. Before the
+// fix, filterInbound returned false (allow) for any fragment.
+func TestFragment_TrailingWithoutFirstDropped(t *testing.T) {
+ m := newFragmentTestManager(t)
+
+ frag := trailingFragment(t, 0x1234, 185, false, 40)
+ require.True(t, m.filterInbound(frag, len(frag)),
+ "trailing fragment without an allowed first fragment must be dropped")
+}
+
+// TestFragment_AllowedFirstPassesTrailing verifies that once a first fragment
+// passes the ACL, its trailing fragments inherit the allow verdict.
+func TestFragment_AllowedFirstPassesTrailing(t *testing.T) {
+ m := newFragmentTestManager(t)
+ allowUDP(t, m, 8080)
+
+ // First fragment: UDP header (8) + 32 payload = 40 octets -> headerEnd = 5.
+ first := firstFragmentUDP(t, 0x2222, 8080, 32)
+ require.False(t, m.filterInbound(first, len(first)),
+ "allowed first fragment should pass and be recorded")
+
+ trailing := trailingFragment(t, 0x2222, 5, false, 24)
+ require.False(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of an allowed datagram should pass")
+}
+
+// TestFragment_DeniedFirstDropsTrailing verifies that a first fragment blocked
+// by the ACL leaves no verdict, so its trailing fragments are dropped.
+func TestFragment_DeniedFirstDropsTrailing(t *testing.T) {
+ m := newFragmentTestManager(t)
+ // No accept rule: local traffic defaults to deny.
+
+ first := firstFragmentUDP(t, 0x3333, 9999, 32)
+ require.True(t, m.filterInbound(first, len(first)),
+ "first fragment to a blocked port should be dropped by the ACL")
+
+ trailing := trailingFragment(t, 0x3333, 5, false, 24)
+ require.True(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of a denied datagram must be dropped")
+}
+
+// TestFragment_OverlappingHeaderDropped covers the RFC 1858 §4 / RFC 3128
+// overlapping-fragment rewrite: a trailing fragment starting inside the range
+// the ACL already inspected is dropped and poisons the datagram. TCP is used so
+// the overlap lands on real header bytes (the flags at byte 13).
+func TestFragment_OverlappingHeaderDropped(t *testing.T) {
+ m := newFragmentTestManager(t)
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
+ &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
+ require.NoError(t, err)
+
+ // First fragment: TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
+ first := firstFragmentTCP(t, 0x4444, 8080)
+ require.False(t, m.filterInbound(first, len(first)))
+
+ // Overlapping fragment at offset 1 (byte 8) falls inside the inspected TCP
+ // header, so it could rewrite the flags or port on reassembly.
+ overlap := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 1, true, 32)
+ require.True(t, m.filterInbound(overlap, len(overlap)),
+ "fragment overlapping the inspected header must be dropped")
+
+ // The datagram is now poisoned: a later, non-overlapping fragment is also
+ // dropped because the verdict was removed.
+ later := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 4, false, 24)
+ require.True(t, m.filterInbound(later, len(later)),
+ "fragments after an overlap must be dropped (datagram poisoned)")
+}
+
+// TestFragment_OffsetZeroOverlapPoisons covers the RFC 3128 offset-zero rewrite:
+// an allowed first fragment followed by a denied offset-zero fragment for the
+// same datagram must not leave the earlier allow verdict in place.
+func TestFragment_OffsetZeroOverlapPoisons(t *testing.T) {
+ m := newFragmentTestManager(t)
+ allowUDP(t, m, 8080)
+
+ allowed := firstFragmentUDP(t, 0x5A5A, 8080, 32)
+ require.False(t, m.filterInbound(allowed, len(allowed)),
+ "allowed first fragment should pass and be recorded")
+
+ // A second offset-zero fragment to a denied port supersedes the datagram's
+ // verdict; it is dropped and must not leave the allow in place.
+ denied := firstFragmentUDP(t, 0x5A5A, 9999, 32)
+ require.True(t, m.filterInbound(denied, len(denied)),
+ "denied offset-zero fragment must be dropped")
+
+ trailing := trailingFragment(t, 0x5A5A, 5, false, 24)
+ require.True(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment must be denied after the datagram was poisoned")
+}
+
+// TestFragment_TinyFirstDropped covers the tiny-fragment attack: a first
+// fragment too small to contain the full transport header can't be
+// ACL-evaluated and must be dropped.
+func TestFragment_TinyFirstDropped(t *testing.T) {
+ m := newFragmentTestManager(t)
+ allowUDP(t, m, 8080)
+
+ // IPv4 header + 4 raw bytes, MF set, offset 0: too small for the 8-byte UDP
+ // header, so it decodes to L3 only.
+ tiny := trailingFragment(t, 0x5555, 0, true, 4)
+ require.True(t, m.filterInbound(tiny, len(tiny)),
+ "tiny first fragment without a full L4 header must be dropped")
+}
+
+// TestFragment_TCPFirstFragment verifies the TCP arm of the transport decode: a
+// first fragment carrying the full 20-byte TCP header is ACL-evaluated and its
+// trailing fragments inherit the verdict.
+func TestFragment_TCPFirstFragment(t *testing.T) {
+ m := newFragmentTestManager(t)
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
+ &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
+ require.NoError(t, err)
+
+ // TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
+ first := firstFragmentTCP(t, 0x6666, 8080)
+ require.False(t, m.filterInbound(first, len(first)),
+ "allowed TCP first fragment should pass and be recorded")
+
+ trailing := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x6666, 4, false, 24)
+ require.False(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of an allowed TCP datagram should pass")
+}
+
+// TestFragment_TCPTinyFirstDropped verifies the TCP minimum header length: 12
+// bytes would satisfy a UDP header but falls short of the 20-byte TCP header.
+func TestFragment_TCPTinyFirstDropped(t *testing.T) {
+ m := newFragmentTestManager(t)
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
+ &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
+ require.NoError(t, err)
+
+ tiny := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x7777, 0, true, 12)
+ require.True(t, m.filterInbound(tiny, len(tiny)),
+ "first fragment shorter than the TCP header must be dropped")
+}
+
+// TestFragment_ConntrackAllowsFirstFragment verifies the conntrack branch: reply
+// fragments of an outbound-established UDP flow pass without any inbound rule.
+func TestFragment_ConntrackAllowsFirstFragment(t *testing.T) {
+ m := newFragmentTestManager(t)
+
+ out := outboundUDPPacket(t, 12345, 40000)
+ require.False(t, m.filterOutbound(out, len(out)))
+
+ first := firstFragmentUDP(t, 0x8888, 12345, 32)
+ require.False(t, m.filterInbound(first, len(first)),
+ "reply first fragment should pass via conntrack")
+
+ trailing := trailingFragment(t, 0x8888, 5, false, 24)
+ require.False(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of a tracked flow should pass")
+}
+
+// TestFragment_RoutingDisabledDropsFragment verifies routed first fragments are
+// dropped when routing is disabled.
+func TestFragment_RoutingDisabledDropsFragment(t *testing.T) {
+ m := newFragmentTestManager(t)
+ m.routingEnabled.Store(false)
+
+ first := firstFragmentUDPTo(t, "198.51.100.10", 0x9999, 8080, 32)
+ require.True(t, m.filterInbound(first, len(first)),
+ "routed first fragment must be dropped when routing is disabled")
+}
+
+// TestFragment_RouteACL verifies the route-ACL branch: fragments to a non-local
+// destination follow the route rules, allowed datagrams pass their trailing
+// fragments and denied ones don't.
+func TestFragment_RouteACL(t *testing.T) {
+ m := newFragmentTestManager(t)
+ m.routingEnabled.Store(true)
+ m.nativeRouter.Store(false)
+
+ _, err := m.AddRouteFiltering(
+ []byte("rt-1"),
+ []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")},
+ fw.Network{Prefix: netip.MustParsePrefix("198.51.100.0/24")},
+ fw.ProtocolUDP,
+ nil,
+ &fw.Port{Values: []uint16{8080}},
+ fw.ActionAccept,
+ )
+ require.NoError(t, err)
+
+ first := firstFragmentUDPTo(t, "198.51.100.10", 0xAAAA, 8080, 32)
+ require.False(t, m.filterInbound(first, len(first)),
+ "route-ACL-allowed first fragment should pass")
+ trailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xAAAA, 5, false, 24)
+ require.False(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of an allowed routed datagram should pass")
+
+ denied := firstFragmentUDPTo(t, "198.51.100.10", 0xBBBB, 9999, 32)
+ require.True(t, m.filterInbound(denied, len(denied)),
+ "route-ACL-denied first fragment must be dropped")
+ deniedTrailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xBBBB, 5, false, 24)
+ require.True(t, m.filterInbound(deniedTrailing, len(deniedTrailing)),
+ "trailing fragment of a denied routed datagram must be dropped")
+}
+
+// TestFragment_ExpiredVerdictDropsTrailing verifies a verdict older than the
+// tracker timeout no longer admits trailing fragments.
+func TestFragment_ExpiredVerdictDropsTrailing(t *testing.T) {
+ m := newFragmentTestManager(t)
+ allowUDP(t, m, 8080)
+
+ first := firstFragmentUDP(t, 0xCCCC, 8080, 32)
+ require.False(t, m.filterInbound(first, len(first)))
+
+ m.fragments.mutex.Lock()
+ for key, entry := range m.fragments.entries {
+ entry.recordedAt = time.Now().Add(-defaultFragmentTimeout - time.Second)
+ m.fragments.entries[key] = entry
+ }
+ m.fragments.mutex.Unlock()
+
+ trailing := trailingFragment(t, 0xCCCC, 5, false, 24)
+ require.True(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment after verdict expiry must be dropped")
+}
+
+// TestFragment_CapacityFailsClosed verifies the table cap: at capacity, new
+// datagram verdicts are not recorded (their trailing fragments are dropped)
+// while already-recorded datagrams keep working.
+func TestFragment_CapacityFailsClosed(t *testing.T) {
+ m := newFragmentTestManager(t)
+ allowUDP(t, m, 8080)
+
+ m.fragments.mutex.Lock()
+ m.fragments.maxEntries = 1
+ m.fragments.mutex.Unlock()
+
+ first1 := firstFragmentUDP(t, 0x0101, 8080, 32)
+ require.False(t, m.filterInbound(first1, len(first1)))
+
+ first2 := firstFragmentUDP(t, 0x0202, 8080, 32)
+ require.False(t, m.filterInbound(first2, len(first2)),
+ "first fragment itself still passes at capacity")
+
+ trailing2 := trailingFragment(t, 0x0202, 5, false, 24)
+ require.True(t, m.filterInbound(trailing2, len(trailing2)),
+ "trailing fragment of an unrecorded datagram must be dropped at capacity")
+
+ trailing1 := trailingFragment(t, 0x0101, 5, false, 24)
+ require.False(t, m.filterInbound(trailing1, len(trailing1)),
+ "already-recorded datagram should keep passing at capacity")
+}
+
+// v6FragmentHeader builds the 8-byte IPv6 fragment extension header for the
+// given inner protocol, offset (8-byte units), More Fragments bit and id.
+func v6FragmentHeader(proto layers.IPProtocol, offsetOctets uint16, moreFragments bool, id uint32) []byte {
+ offsetFlags := offsetOctets << 3
+ if moreFragments {
+ offsetFlags |= 1
+ }
+ hdr := make([]byte, 8)
+ hdr[0] = uint8(proto)
+ binary.BigEndian.PutUint16(hdr[2:4], offsetFlags)
+ binary.BigEndian.PutUint32(hdr[4:8], id)
+ return hdr
+}
+
+func v6UDPHeader(dstPort uint16, dataLen int) []byte {
+ hdr := make([]byte, 8)
+ binary.BigEndian.PutUint16(hdr[0:2], 40000)
+ binary.BigEndian.PutUint16(hdr[2:4], dstPort)
+ binary.BigEndian.PutUint16(hdr[4:6], uint16(8+dataLen))
+ return hdr
+}
+
+// firstFragmentUDPv6 builds the first fragment of a fragmented IPv6 UDP
+// datagram: fragment header (offset 0, More Fragments set) + full UDP header +
+// data.
+func firstFragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int) []byte {
+ tb.Helper()
+ return fragmentUDPv6(tb, id, dstPort, dataLen, true)
+}
+
+// fragmentUDPv6 builds an offset-zero IPv6 UDP fragment. With moreFragments
+// false it is an atomic fragment (a complete datagram, RFC 6946).
+func fragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int, moreFragments bool) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv6{
+ Version: 6,
+ NextHeader: layers.IPProtocolIPv6Fragment,
+ HopLimit: 64,
+ SrcIP: net.ParseIP(fragTestSrcV6),
+ DstIP: net.ParseIP(fragTestDstV6),
+ }
+ payload := append(v6FragmentHeader(layers.IPProtocolUDP, 0, moreFragments, id), v6UDPHeader(dstPort, dataLen)...)
+ payload = append(payload, make([]byte, dataLen)...)
+
+ buf := gopacket.NewSerializeBuffer()
+ require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
+ return buf.Bytes()
+}
+
+// trailingFragmentV6 builds a non-first IPv6 fragment: fragment header at the
+// given offset carrying raw data and no transport header.
+func trailingFragmentV6(tb testing.TB, id uint32, offsetOctets uint16, moreFragments bool, dataLen int) []byte {
+ tb.Helper()
+
+ ip := &layers.IPv6{
+ Version: 6,
+ NextHeader: layers.IPProtocolIPv6Fragment,
+ HopLimit: 64,
+ SrcIP: net.ParseIP(fragTestSrcV6),
+ DstIP: net.ParseIP(fragTestDstV6),
+ }
+ payload := append(v6FragmentHeader(layers.IPProtocolUDP, offsetOctets, moreFragments, id), make([]byte, dataLen)...)
+
+ buf := gopacket.NewSerializeBuffer()
+ require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
+ return buf.Bytes()
+}
+
+// TestFragmentV6_TrailingWithoutFirstDropped verifies the IPv6 bypass is closed:
+// a trailing fragment with no allowed first fragment is dropped.
+func TestFragmentV6_TrailingWithoutFirstDropped(t *testing.T) {
+ m := newFragmentTestManager(t)
+
+ frag := trailingFragmentV6(t, 0xAABBCCDD, 100, false, 40)
+ require.True(t, m.filterInbound(frag, len(frag)),
+ "IPv6 trailing fragment without an allowed first fragment must be dropped")
+}
+
+// TestFragmentV6_AllowedFirstPassesTrailing verifies IPv6 fragments are
+// evaluated like IPv4: an allowed first fragment lets its trailing fragments
+// through.
+func TestFragmentV6_AllowedFirstPassesTrailing(t *testing.T) {
+ m := newFragmentTestManager(t)
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
+ &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
+ require.NoError(t, err)
+
+ // First fragment: UDP header (8) + 32 data = 40 octets -> headerEnd = 5.
+ first := firstFragmentUDPv6(t, 0xAABBCCDD, 8080, 32)
+ require.False(t, m.filterInbound(first, len(first)),
+ "allowed IPv6 first fragment should pass and be recorded")
+
+ trailing := trailingFragmentV6(t, 0xAABBCCDD, 5, false, 24)
+ require.False(t, m.filterInbound(trailing, len(trailing)),
+ "trailing fragment of an allowed IPv6 datagram should pass")
+}
+
+// TestFragmentV6_AtomicNotCached verifies an IPv6 atomic fragment (fragment
+// header with offset 0 and no More Fragments, a complete datagram per RFC 6946)
+// is evaluated but not recorded, so a flood of allowed atomic fragments can't
+// exhaust the verdict table.
+func TestFragmentV6_AtomicNotCached(t *testing.T) {
+ m := newFragmentTestManager(t)
+ _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
+ &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
+ require.NoError(t, err)
+
+ atomic := fragmentUDPv6(t, 0xA70301C, 8080, 16, false)
+ require.False(t, m.filterInbound(atomic, len(atomic)),
+ "allowed IPv6 atomic fragment should pass")
+
+ m.fragments.mutex.Lock()
+ n := len(m.fragments.entries)
+ m.fragments.mutex.Unlock()
+ require.Zero(t, n, "atomic fragment must not create a verdict entry")
+
+ // A genuine fragmented datagram (More Fragments set) is still recorded.
+ first := fragmentUDPv6(t, 0xBEEF, 8080, 32, true)
+ require.False(t, m.filterInbound(first, len(first)))
+ m.fragments.mutex.Lock()
+ n = len(m.fragments.entries)
+ m.fragments.mutex.Unlock()
+ require.Equal(t, 1, n, "genuine first fragment must record a verdict")
+}
diff --git a/client/iface/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/iface_test.go b/client/iface/iface_test.go
index dbeb69bc6..89c8cd16e 100644
--- a/client/iface/iface_test.go
+++ b/client/iface/iface_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package iface
import (
@@ -462,6 +464,8 @@ func Test_RemovePeer(t *testing.T) {
}
func Test_ConnectPeers(t *testing.T) {
+ t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true")
+
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
peer1Key, _ := wgtypes.GeneratePrivateKey()
@@ -503,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) {
t.Fatal(err)
}
- localIP, err := getLocalIP()
- if err != nil {
- t.Fatal(err)
- }
-
- peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
+ localIP1 := "127.0.0.1"
+ peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
if err != nil {
t.Fatal(err)
}
@@ -544,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) {
t.Fatal(err)
}
- peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
+ localIP2 := "127.0.0.1"
+ peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
if err != nil {
t.Fatal(err)
}
@@ -567,17 +568,17 @@ func Test_ConnectPeers(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- // todo: investigate why in some tests execution we need 30s
+ // The peers use userspace WireGuard (stdnet transport). A tight busy-loop
+ // here starves the wireguard-go goroutines that process the handshake, so
+ // poll on a ticker instead and yield the CPU between checks. WireGuard also
+ // only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which
+ // is why the overall wait can occasionally stretch to tens of seconds.
timeout := 30 * time.Second
timeoutChannel := time.After(timeout)
+ ticker := time.NewTicker(500 * time.Millisecond)
+ defer ticker.Stop()
for {
- select {
- case <-timeoutChannel:
- t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
- default:
- }
-
peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String())
if gpErr != nil {
t.Fatal(gpErr)
@@ -586,6 +587,12 @@ func Test_ConnectPeers(t *testing.T) {
t.Log("peers successfully handshake")
break
}
+
+ select {
+ case <-timeoutChannel:
+ t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
+ case <-ticker.C:
+ }
}
}
@@ -613,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) {
}
return wgtypes.Peer{}, fmt.Errorf("peer not found")
}
-
-func getLocalIP() (string, error) {
- // Get all interfaces
- addrs, err := net.InterfaceAddrs()
- if err != nil {
- return "", err
- }
-
- for _, addr := range addrs {
- ipNet, ok := addr.(*net.IPNet)
- if !ok {
- continue
- }
- if ipNet.IP.IsLoopback() {
- continue
- }
-
- if ipNet.IP.To4() == nil {
- continue
- }
- return ipNet.IP.String(), nil
- }
-
- return "", fmt.Errorf("no local IP found")
-}
diff --git a/client/iface/netstack/env.go b/client/iface/netstack/env.go
index dd8cf29a3..b069301c1 100644
--- a/client/iface/netstack/env.go
+++ b/client/iface/netstack/env.go
@@ -3,14 +3,31 @@
package netstack
import (
- "fmt"
+ "net"
"os"
"strconv"
log "github.com/sirupsen/logrus"
)
-const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
+const (
+ EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
+
+ // EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on.
+ EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT"
+
+ // EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to.
+ // The proxy is a bridge for local host applications into the userspace
+ // WireGuard netstack, so it binds to loopback by default. Override this only
+ // when the proxy must be reachable from other hosts (e.g. a container
+ // gateway); doing so exposes an unauthenticated SOCKS5 proxy on that
+ // address.
+ EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS"
+
+ // defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless
+ // overridden via EnvSocks5ListenerAddress.
+ defaultSocks5Host = "127.0.0.1"
+)
// IsEnabled todo: move these function to cmd layer
func IsEnabled() bool {
@@ -18,24 +35,40 @@ func IsEnabled() bool {
}
func ListenAddr() string {
- sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT")
+ return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort()))
+}
+
+// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to
+// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP.
+func listenHost() string {
+ addr := os.Getenv(EnvSocks5ListenerAddress)
+ if addr == "" {
+ return defaultSocks5Host
+ }
+ if net.ParseIP(addr) == nil {
+ log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host)
+ return defaultSocks5Host
+ }
+ return addr
+}
+
+// listenPort returns the port the SOCKS5 proxy binds to, defaulting to
+// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid.
+func listenPort() int {
+ sPort := os.Getenv(EnvSocks5ListenerPort)
if sPort == "" {
- return listenAddr(DefaultSocks5Port)
+ return DefaultSocks5Port
}
port, err := strconv.Atoi(sPort)
if err != nil {
log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port)
- return listenAddr(DefaultSocks5Port)
+ return DefaultSocks5Port
}
if port < 1 || port > 65535 {
log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port)
- return listenAddr(DefaultSocks5Port)
+ return DefaultSocks5Port
}
- return listenAddr(port)
-}
-
-func listenAddr(port int) string {
- return fmt.Sprintf("0.0.0.0:%d", port)
+ return port
}
diff --git a/client/iface/netstack/env_test.go b/client/iface/netstack/env_test.go
new file mode 100644
index 000000000..1083435a4
--- /dev/null
+++ b/client/iface/netstack/env_test.go
@@ -0,0 +1,63 @@
+//go:build !js
+
+package netstack
+
+import (
+ "net"
+ "strconv"
+ "testing"
+)
+
+func TestListenAddr_DefaultsToLoopback(t *testing.T) {
+ // No env overrides: must bind loopback, never all interfaces.
+ got := ListenAddr()
+ want := net.JoinHostPort("127.0.0.1", strconv.Itoa(DefaultSocks5Port))
+ if got != want {
+ t.Fatalf("ListenAddr() = %q, want %q", got, want)
+ }
+}
+
+func TestListenAddr_AddressOverride(t *testing.T) {
+ tests := []struct {
+ name string
+ env string
+ want string
+ }{
+ {name: "valid override honored", env: "0.0.0.0", want: "0.0.0.0"},
+ {name: "valid specific ip honored", env: "10.0.0.5", want: "10.0.0.5"},
+ {name: "ipv6 loopback bracketed", env: "::1", want: "::1"},
+ {name: "invalid falls back to loopback", env: "not-an-ip", want: "127.0.0.1"},
+ {name: "empty falls back to loopback", env: "", want: "127.0.0.1"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(EnvSocks5ListenerAddress, tc.env)
+ want := net.JoinHostPort(tc.want, strconv.Itoa(DefaultSocks5Port))
+ if got := ListenAddr(); got != want {
+ t.Fatalf("ListenAddr() = %q, want %q", got, want)
+ }
+ })
+ }
+}
+
+func TestListenAddr_PortOverride(t *testing.T) {
+ tests := []struct {
+ name string
+ env string
+ want int
+ }{
+ {name: "valid port honored", env: "1081", want: 1081},
+ {name: "non-numeric falls back", env: "abc", want: DefaultSocks5Port},
+ {name: "out of range falls back", env: "70000", want: DefaultSocks5Port},
+ {name: "zero falls back", env: "0", want: DefaultSocks5Port},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(EnvSocks5ListenerPort, tc.env)
+ want := net.JoinHostPort("127.0.0.1", strconv.Itoa(tc.want))
+ if got := ListenAddr(); got != want {
+ t.Fatalf("ListenAddr() = %q, want %q", got, want)
+ }
+ })
+ }
+}
diff --git a/client/iface/wgproxy/bind/proxy.go b/client/iface/wgproxy/bind/proxy.go
index be6f3806e..be690ed4f 100644
--- a/client/iface/wgproxy/bind/proxy.go
+++ b/client/iface/wgproxy/bind/proxy.go
@@ -136,6 +136,11 @@ func (p *ProxyBind) CloseConn() error {
return p.close()
}
+// InjectPacket is a no-op for the userspace proxy: first-packet reinjection is kernel-only.
+func (p *ProxyBind) InjectPacket(_ []byte) error {
+ return nil
+}
+
func (p *ProxyBind) close() error {
if p.remoteConn == nil {
return nil
diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/ebpf/wrapper.go
index 6e80945c4..a6156a661 100644
--- a/client/iface/wgproxy/ebpf/wrapper.go
+++ b/client/iface/wgproxy/ebpf/wrapper.go
@@ -219,6 +219,17 @@ func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) {
p.pausedCond.L.Unlock()
}
+// InjectPacket writes b to the remote peer over the underlying transport.
+func (p *ProxyWrapper) InjectPacket(b []byte) error {
+ if p.remoteConn == nil {
+ return errors.New("proxy not started")
+ }
+ if _, err := p.remoteConn.Write(b); err != nil {
+ return err
+ }
+ return nil
+}
+
// CloseConn close the remoteConn and automatically remove the conn instance from the map
func (p *ProxyWrapper) CloseConn() error {
if p.cancel == nil {
diff --git a/client/iface/wgproxy/proxy.go b/client/iface/wgproxy/proxy.go
index 3c8dfd30e..40346bc15 100644
--- a/client/iface/wgproxy/proxy.go
+++ b/client/iface/wgproxy/proxy.go
@@ -18,4 +18,9 @@ type Proxy interface {
RedirectAs(endpoint *net.UDPAddr)
CloseConn() error
SetDisconnectListener(disconnected func())
+
+ // InjectPacket writes a raw packet directly to the remote peer over the underlying transport,
+ // bypassing WireGuard. Used to replay the captured lazyconn handshake initiation. Only the
+ // kernel-mode proxies act on it; the userspace proxy is a no-op since reinjection is kernel-only.
+ InjectPacket(b []byte) error
}
diff --git a/client/iface/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go
index 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/redirect_test.go b/client/iface/wgproxy/redirect_test.go
index b52eead25..135970838 100644
--- a/client/iface/wgproxy/redirect_test.go
+++ b/client/iface/wgproxy/redirect_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package wgproxy
@@ -26,64 +26,6 @@ func compareUDPAddr(addr1, addr2 net.Addr) bool {
return udpAddr1.IP.Equal(udpAddr2.IP) && udpAddr1.Port == udpAddr2.Port
}
-// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses
-func TestRedirectAs_eBPF_IPv4(t *testing.T) {
- wgPort := 51850
- ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
- if err := ebpfProxy.Listen(); err != nil {
- t.Fatalf("failed to initialize ebpf proxy: %v", err)
- }
- defer func() {
- if err := ebpfProxy.Free(); err != nil {
- t.Errorf("failed to free ebpf proxy: %v", err)
- }
- }()
-
- proxy := ebpf.NewProxyWrapper(ebpfProxy)
-
- // NetBird UDP address of the remote peer
- nbAddr := &net.UDPAddr{
- IP: net.ParseIP("100.108.111.177"),
- Port: 38746,
- }
-
- p2pEndpoint := &net.UDPAddr{
- IP: net.ParseIP("192.168.0.56"),
- Port: 51820,
- }
-
- testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
-}
-
-// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses
-func TestRedirectAs_eBPF_IPv6(t *testing.T) {
- wgPort := 51851
- ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
- if err := ebpfProxy.Listen(); err != nil {
- t.Fatalf("failed to initialize ebpf proxy: %v", err)
- }
- defer func() {
- if err := ebpfProxy.Free(); err != nil {
- t.Errorf("failed to free ebpf proxy: %v", err)
- }
- }()
-
- proxy := ebpf.NewProxyWrapper(ebpfProxy)
-
- // NetBird UDP address of the remote peer
- nbAddr := &net.UDPAddr{
- IP: net.ParseIP("100.108.111.177"),
- Port: 38746,
- }
-
- p2pEndpoint := &net.UDPAddr{
- IP: net.ParseIP("fe80::56"),
- Port: 51820,
- }
-
- testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
-}
-
// TestRedirectAs_UDP_IPv4 tests RedirectAs with UDP proxy using IPv4 addresses
func TestRedirectAs_UDP_IPv4(t *testing.T) {
wgPort := 51852
@@ -256,6 +198,64 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *
}
}
+// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses
+func TestRedirectAs_eBPF_IPv4(t *testing.T) {
+ wgPort := 51850
+ ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
+ if err := ebpfProxy.Listen(); err != nil {
+ t.Fatalf("failed to initialize ebpf proxy: %v", err)
+ }
+ defer func() {
+ if err := ebpfProxy.Free(); err != nil {
+ t.Errorf("failed to free ebpf proxy: %v", err)
+ }
+ }()
+
+ proxy := ebpf.NewProxyWrapper(ebpfProxy)
+
+ // NetBird UDP address of the remote peer
+ nbAddr := &net.UDPAddr{
+ IP: net.ParseIP("100.108.111.177"),
+ Port: 38746,
+ }
+
+ p2pEndpoint := &net.UDPAddr{
+ IP: net.ParseIP("192.168.0.56"),
+ Port: 51820,
+ }
+
+ testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
+}
+
+// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses
+func TestRedirectAs_eBPF_IPv6(t *testing.T) {
+ wgPort := 51851
+ ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
+ if err := ebpfProxy.Listen(); err != nil {
+ t.Fatalf("failed to initialize ebpf proxy: %v", err)
+ }
+ defer func() {
+ if err := ebpfProxy.Free(); err != nil {
+ t.Errorf("failed to free ebpf proxy: %v", err)
+ }
+ }()
+
+ proxy := ebpf.NewProxyWrapper(ebpfProxy)
+
+ // NetBird UDP address of the remote peer
+ nbAddr := &net.UDPAddr{
+ IP: net.ParseIP("100.108.111.177"),
+ Port: 38746,
+ }
+
+ p2pEndpoint := &net.UDPAddr{
+ IP: net.ParseIP("fe80::56"),
+ Port: 51820,
+ }
+
+ testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
+}
+
// TestRedirectAs_Multiple_Switches tests switching between multiple endpoints
func TestRedirectAs_Multiple_Switches(t *testing.T) {
wgPort := 51856
diff --git a/client/iface/wgproxy/udp/proxy.go b/client/iface/wgproxy/udp/proxy.go
index 6069d1960..783843aba 100644
--- a/client/iface/wgproxy/udp/proxy.go
+++ b/client/iface/wgproxy/udp/proxy.go
@@ -147,6 +147,17 @@ func (p *WGUDPProxy) RedirectAs(endpoint *net.UDPAddr) {
p.sendPkg = p.srcFakerConn.SendPkg
}
+// InjectPacket writes b to the remote peer over the underlying transport.
+func (p *WGUDPProxy) InjectPacket(b []byte) error {
+ if p.remoteConn == nil {
+ return errors.New("proxy not started")
+ }
+ if _, err := p.remoteConn.Write(b); err != nil {
+ return err
+ }
+ return nil
+}
+
// CloseConn close the localConn
func (p *WGUDPProxy) CloseConn() error {
if p.cancel == nil {
diff --git a/client/installer.nsis b/client/installer.nsis
index 63bff1c5b..71699071b 100644
--- a/client/installer.nsis
+++ b/client/installer.nsis
@@ -6,7 +6,7 @@
!define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls."
!define INSTALLER_NAME "netbird-installer.exe"
!define MAIN_APP_EXE "Netbird"
-!define ICON "ui\\assets\\netbird.ico"
+!define ICON "ui\\build\\windows\\icon.ico"
!define BANNER "ui\\build\\banner.bmp"
!define LICENSE_DATA "..\\LICENSE"
@@ -79,8 +79,6 @@ ShowInstDetails Show
!insertmacro MUI_PAGE_DIRECTORY
-Page custom AutostartPage AutostartPageLeave
-
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
@@ -97,40 +95,12 @@ UninstPage custom un.DeleteDataPage un.DeleteDataPageLeave
!insertmacro MUI_LANGUAGE "English"
-; Variables for autostart option
-Var AutostartCheckbox
-Var AutostartEnabled
-
; Variables for uninstall data deletion option
Var DeleteDataCheckbox
Var DeleteDataEnabled
######################################################################
-; Function to create the autostart options page
-Function AutostartPage
- !insertmacro MUI_HEADER_TEXT "Startup Options" "Configure how ${APP_NAME} launches with Windows."
-
- nsDialogs::Create 1018
- Pop $0
-
- ${If} $0 == error
- Abort
- ${EndIf}
-
- ${NSD_CreateCheckbox} 0 20u 100% 10u "Start ${APP_NAME} UI automatically when Windows starts"
- Pop $AutostartCheckbox
- ${NSD_Check} $AutostartCheckbox
- StrCpy $AutostartEnabled "1"
-
- nsDialogs::Show
-FunctionEnd
-
-; Function to handle leaving the autostart page
-Function AutostartPageLeave
- ${NSD_GetState} $AutostartCheckbox $AutostartEnabled
-FunctionEnd
-
; Function to create the uninstall data deletion page
Function un.DeleteDataPage
!insertmacro MUI_HEADER_TEXT "Uninstall Options" "Choose whether to delete ${APP_NAME} data."
@@ -201,8 +171,6 @@ Pop $0
Function .onInit
StrCpy $INSTDIR "${INSTALL_DIR}"
-; Default autostart to enabled so silent installs (/S) match the interactive default
-StrCpy $AutostartEnabled "1"
; Pre-0.70.1 installers ran without SetRegView, so their uninstall keys live
; in the 32-bit view. Fall back to it so upgrades still find them.
@@ -260,17 +228,12 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
-; Create autostart registry entry based on checkbox
-DetailPrint "Autostart enabled: $AutostartEnabled"
-${If} $AutostartEnabled == "1"
- WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
- DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
-${Else}
- DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
- ; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
- DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
- DetailPrint "Autostart not enabled by user"
-${EndIf}
+; Autostart is owned by the UI's per-user setting (HKCU\...\Run via Wails),
+; not the installer. Drop the machine-wide entry older installers wrote so the
+; toggle is the single source of truth. HKCU is left untouched -- it may hold
+; the user's own toggle state, which must survive upgrades.
+DetailPrint "Removing installer-managed autostart registry entry if present..."
+DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
EnVar::SetHKLM
EnVar::AddValueEx "path" "$INSTDIR"
@@ -280,6 +243,43 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
SectionEnd
+# Install the Microsoft Edge WebView2 runtime if it isn't already present.
+# Macro adapted from Wails3's NSIS template (wails_tools.nsh): a registry
+# probe followed by a silent install of the embedded evergreen bootstrapper.
+# The MicrosoftEdgeWebview2Setup.exe payload is staged next to this script
+# by the sign-pipelines build step (`wails3 generate webview2bootstrapper`).
+!macro nb.webview2runtime
+ SetRegView 64
+ # Per-machine install marker — populated when the runtime ships with
+ # Edge or has been installed by an admin previously.
+ ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
+ ${If} $0 != ""
+ Goto webview2_ok
+ ${EndIf}
+ # Per-user fallback for HKCU installs.
+ ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
+ ${If} $0 != ""
+ Goto webview2_ok
+ ${EndIf}
+
+ SetDetailsPrint both
+ DetailPrint "Installing: WebView2 Runtime"
+ SetDetailsPrint listonly
+
+ InitPluginsDir
+ CreateDirectory "$pluginsdir\webview2bootstrapper"
+ SetOutPath "$pluginsdir\webview2bootstrapper"
+ File "MicrosoftEdgeWebview2Setup.exe"
+ ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
+
+ SetDetailsPrint both
+ webview2_ok:
+!macroend
+
+Section -WebView2
+ !insertmacro nb.webview2runtime
+SectionEnd
+
Section -Post
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
@@ -299,11 +299,14 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
DetailPrint "Terminating Netbird UI process..."
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
-; Remove autostart registry entry
-DetailPrint "Removing autostart registry entry if exists..."
+; Remove autostart registry entries
+DetailPrint "Removing autostart registry entries if they exist..."
+; Legacy machine-wide entry written by older installers.
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
+; Per-user entry the UI toggle writes via Wails (value name is the lowercase
+; app-name slug). Uninstall removes the app, so drop it too.
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
+DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "netbird"
; Handle data deletion based on checkbox
DetailPrint "Checking if user requested data deletion..."
@@ -326,9 +329,9 @@ DetailPrint "Deleting application files..."
Delete "$INSTDIR\${UI_APP_EXE}"
Delete "$INSTDIR\${MAIN_APP_EXE}"
Delete "$INSTDIR\wintun.dll"
-!if ${ARCH} == "amd64"
+# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove
+# any leftover copy on uninstall so old upgrades don't leave it behind.
Delete "$INSTDIR\opengl32.dll"
-!endif
DetailPrint "Removing application directory..."
RmDir /r "$INSTDIR"
diff --git a/client/internal/acl/manager.go b/client/internal/acl/manager.go
index c54a3e897..d9b179457 100644
--- a/client/internal/acl/manager.go
+++ b/client/internal/acl/manager.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/hashicorp/go-multierror"
+ "github.com/mitchellh/hashstructure/v2"
log "github.com/sirupsen/logrus"
nberrors "github.com/netbirdio/netbird/client/errors"
@@ -30,11 +31,13 @@ type Manager interface {
// DefaultManager uses firewall manager to handle
type DefaultManager struct {
- firewall firewall.Manager
- ipsetCounter int
- peerRulesPairs map[id.RuleID][]firewall.Rule
- routeRules map[id.RuleID]struct{}
- mutex sync.Mutex
+ firewall firewall.Manager
+ ipsetCounter int
+ peerRulesPairs map[id.RuleID][]firewall.Rule
+ routeRules map[id.RuleID]struct{}
+ previousConfigHash uint64
+ hasAppliedConfig bool
+ mutex sync.Mutex
}
func NewDefaultManager(fm firewall.Manager) *DefaultManager {
@@ -57,6 +60,23 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
return
}
+ // Skip the full rebuild + flush when the inputs that drive the firewall
+ // state are byte-for-byte identical to the last successfully applied
+ // update. Management re-sends the same network map far more often than it
+ // actually changes (account-wide updates, peer meta churn), and rebuilding
+ // every peer/route ACL and flushing the firewall on every such sync is the
+ // dominant client-side cost when nothing changed. Mirrors the same guard the
+ // DNS server already uses (previousConfigHash). Only the fields ApplyFiltering
+ // consumes participate in the hash, so an unrelated map change cannot mask a
+ // real ACL change.
+ hash, err := d.firewallConfigHash(networkMap, dnsRouteFeatureFlag)
+ if err != nil {
+ log.Errorf("unable to hash firewall configuration, applying unconditionally: %v", err)
+ } else if d.hasAppliedConfig && d.previousConfigHash == hash {
+ log.Debugf("not applying the firewall configuration update as there is nothing new (hash: %d)", hash)
+ return
+ }
+
start := time.Now()
defer func() {
total := 0
@@ -70,13 +90,49 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
d.applyPeerACLs(networkMap)
- if err := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag); err != nil {
- log.Errorf("Failed to apply route ACLs: %v", err)
+ routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag)
+ if routeErr != nil {
+ log.Errorf("Failed to apply route ACLs: %v", routeErr)
}
- if err := d.firewall.Flush(); err != nil {
- log.Error("failed to flush firewall rules: ", err)
+ flushErr := d.firewall.Flush()
+ if flushErr != nil {
+ log.Error("failed to flush firewall rules: ", flushErr)
}
+
+ // Only remember the hash once the firewall actually reflects this config.
+ // If applying or flushing failed, leave the previous hash untouched so the
+ // next (possibly identical) update is not skipped and gets a chance to
+ // reconcile the firewall state.
+ if err == nil && routeErr == nil && flushErr == nil {
+ d.previousConfigHash = hash
+ d.hasAppliedConfig = true
+ } else {
+ d.hasAppliedConfig = false
+ }
+}
+
+// firewallConfigHash hashes exactly the inputs ApplyFiltering uses to build the
+// firewall state, so an identical hash means an identical resulting ruleset.
+func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) {
+ return hashstructure.Hash(struct {
+ PeerRules []*mgmProto.FirewallRule
+ PeerRulesIsEmpty bool
+ RouteRules []*mgmProto.RouteFirewallRule
+ RouteRulesIsEmpty bool
+ DNSRouteFeatureFlag bool
+ }{
+ PeerRules: networkMap.GetFirewallRules(),
+ PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(),
+ RouteRules: networkMap.GetRoutesFirewallRules(),
+ RouteRulesIsEmpty: networkMap.GetRoutesFirewallRulesIsEmpty(),
+ DNSRouteFeatureFlag: dnsRouteFeatureFlag,
+ }, hashstructure.FormatV2, &hashstructure.HashOptions{
+ ZeroNil: true,
+ IgnoreZeroValue: true,
+ SlicesAsSets: true,
+ UseStringer: true,
+ })
}
func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) {
diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go
index 408ed992f..968654ae9 100644
--- a/client/internal/acl/manager_test.go
+++ b/client/internal/acl/manager_test.go
@@ -1,6 +1,7 @@
package acl
import (
+ "fmt"
"net/netip"
"testing"
@@ -485,3 +486,149 @@ func TestPortInfoEmpty(t *testing.T) {
})
}
}
+
+// TestApplyFilteringSkipsUnchangedConfig verifies that an identical network map
+// re-applied is recognized as a no-op (hash unchanged), while a real change to
+// any firewall-relevant input forces a re-apply (hash changes). This is the
+// guard that prevents a full ruleset rebuild + flush on every redundant sync.
+func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) {
+ t.Setenv("NB_WG_KERNEL_DISABLED", "true")
+ t.Setenv(firewall.EnvForceUserspaceFirewall, "true")
+
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ ifaceMock := mocks.NewMockIFaceMapper(ctrl)
+ ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes()
+ ifaceMock.EXPECT().SetFilter(gomock.Any())
+ network := netip.MustParsePrefix("172.0.0.1/32")
+ ifaceMock.EXPECT().Name().Return("lo").AnyTimes()
+ ifaceMock.EXPECT().Address().Return(wgaddr.Address{
+ IP: network.Addr(),
+ Network: network,
+ }).AnyTimes()
+ ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes()
+
+ fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU)
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, fw.Close(nil))
+ }()
+
+ acl := NewDefaultManager(fw)
+
+ networkMap := &mgmProto.NetworkMap{
+ FirewallRules: []*mgmProto.FirewallRule{
+ {
+ PeerIP: "10.93.0.1",
+ Direction: mgmProto.RuleDirection_IN,
+ Action: mgmProto.RuleAction_ACCEPT,
+ Protocol: mgmProto.RuleProtocol_TCP,
+ Port: "22",
+ },
+ },
+ FirewallRulesIsEmpty: false,
+ }
+
+ acl.ApplyFiltering(networkMap, false)
+ require.True(t, acl.hasAppliedConfig, "config should be marked applied after first apply")
+ firstHash := acl.previousConfigHash
+ require.NotZero(t, firstHash)
+
+ // Re-applying the identical map must not change the recorded hash: the
+ // expensive rebuild path was skipped.
+ acl.ApplyFiltering(networkMap, false)
+ assert.Equal(t, firstHash, acl.previousConfigHash,
+ "identical re-apply must be a no-op (hash unchanged)")
+
+ // A real change must produce a different hash and re-apply.
+ networkMap.FirewallRules[0].Action = mgmProto.RuleAction_DROP
+ acl.ApplyFiltering(networkMap, false)
+ assert.NotEqual(t, firstHash, acl.previousConfigHash,
+ "changing a rule's action must force a re-apply (hash changed)")
+
+ // The dnsRouteFeatureFlag also participates in the hash.
+ changedHash := acl.previousConfigHash
+ acl.ApplyFiltering(networkMap, true)
+ assert.NotEqual(t, changedHash, acl.previousConfigHash,
+ "flipping dnsRouteFeatureFlag must force a re-apply (hash changed)")
+}
+
+func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap {
+ nm := &mgmProto.NetworkMap{
+ FirewallRulesIsEmpty: peerRules == 0,
+ RoutesFirewallRulesIsEmpty: routeRules == 0,
+ }
+ for i := range peerRules {
+ nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{
+ PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff),
+ Direction: mgmProto.RuleDirection_IN,
+ Action: mgmProto.RuleAction_ACCEPT,
+ Protocol: mgmProto.RuleProtocol_TCP,
+ Port: fmt.Sprintf("%d", 1024+i%64511),
+ })
+ }
+ for i := range routeRules {
+ nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, &mgmProto.RouteFirewallRule{
+ Destination: fmt.Sprintf("192.168.%d.0/24", i%256),
+ SourceRanges: []string{fmt.Sprintf("10.0.%d.0/24", i%256)},
+ Action: mgmProto.RuleAction_ACCEPT,
+ Protocol: mgmProto.RuleProtocol_ALL,
+ })
+ }
+ return nm
+}
+
+func BenchmarkFirewallConfigHash_Small(b *testing.B) {
+ d := &DefaultManager{}
+ nm := buildNetworkMap(10, 5)
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = d.firewallConfigHash(nm, false)
+ }
+}
+
+func BenchmarkFirewallConfigHash_Medium(b *testing.B) {
+ d := &DefaultManager{}
+ nm := buildNetworkMap(100, 50)
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = d.firewallConfigHash(nm, false)
+ }
+}
+
+func BenchmarkFirewallConfigHash_Large(b *testing.B) {
+ d := &DefaultManager{}
+ nm := buildNetworkMap(1000, 200)
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = d.firewallConfigHash(nm, false)
+ }
+}
+
+// TestFirewallConfigHashDeterministic verifies the hash is stable for equal
+// inputs and order-independent for the rule slices (management does not
+// guarantee rule order).
+func TestFirewallConfigHashDeterministic(t *testing.T) {
+ d := &DefaultManager{}
+
+ nm1 := &mgmProto.NetworkMap{
+ FirewallRules: []*mgmProto.FirewallRule{
+ {PeerIP: "10.0.0.1", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_TCP, Port: "22"},
+ {PeerIP: "10.0.0.2", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_TCP, Port: "80"},
+ },
+ }
+ // Same rules, reversed order.
+ nm2 := &mgmProto.NetworkMap{
+ FirewallRules: []*mgmProto.FirewallRule{
+ nm1.FirewallRules[1],
+ nm1.FirewallRules[0],
+ },
+ }
+
+ h1, err := d.firewallConfigHash(nm1, false)
+ require.NoError(t, err)
+ h2, err := d.firewallConfigHash(nm2, false)
+ require.NoError(t, err)
+ assert.Equal(t, h1, h2, "hash must be order-independent for rule slices")
+}
diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go
index afc8ee77f..153727a6c 100644
--- a/client/internal/auth/auth.go
+++ b/client/internal/auth/auth.go
@@ -3,6 +3,7 @@ package auth
import (
"context"
"net/url"
+ "strings"
"sync"
"time"
@@ -21,6 +22,25 @@ import (
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
+// peerLoginExpiredMsg is the exact phrase the management server returns
+// when a previously SSO-enrolled peer's login has expired. Sourced from
+// shared/management/status/error.go (NewPeerLoginExpiredError). Matched
+// by substring so a future server-side rewording that keeps the phrase
+// still triggers the friendly fallback in Login().
+const peerLoginExpiredMsg = "peer login has expired"
+
+// errSetupKeyOnSSOExpiredPeer replaces the raw management error when the
+// user runs `netbird login -k ` against a peer that was
+// originally enrolled via SSO. Wrapped in a PermissionDenied gRPC status
+// so callers' existing isPermissionDenied / isAuthError checks still
+// classify it correctly (early-exit from retry backoff, StatusNeedsLogin
+// in the server state machine).
+var errSetupKeyOnSSOExpiredPeer = status.Error(
+ codes.PermissionDenied,
+ "this peer was originally enrolled via SSO and its session has expired. "+
+ "Setup keys can only enrol new peers — run `netbird up` (interactive SSO) to re-login.",
+)
+
// Auth manages authentication operations with the management server
// It maintains a long-lived connection and automatically handles reconnection with backoff
type Auth struct {
@@ -184,6 +204,15 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
log.Debugf("peer registration required")
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
if err != nil {
+ // The peer pub-key is already on file with the management
+ // server (originally enrolled via SSO) and the session has
+ // expired. The setup-key path can only enrol new peers, so
+ // retrying with -k will keep failing. Replace the raw mgm
+ // message with an actionable hint that tells the user to
+ // re-authenticate via SSO instead.
+ if setupKey != "" && jwtToken == "" && isPeerLoginExpired(err) {
+ err = errSetupKeyOnSSOExpiredPeer
+ }
isAuthError = isPermissionDenied(err)
return err
}
@@ -322,7 +351,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
a.config.BlockLANAccess,
a.config.BlockInbound,
a.config.DisableIPv6,
- a.config.LazyConnectionEnabled,
+ a.config.SyncMessageVersion,
a.config.EnableSSHRoot,
a.config.EnableSSHSFTP,
a.config.EnableSSHLocalPortForwarding,
@@ -474,3 +503,16 @@ func isLoginNeeded(err error) bool {
func isRegistrationNeeded(err error) bool {
return isPermissionDenied(err)
}
+
+// isPeerLoginExpired reports whether err is the management server's
+// "peer login has expired" PermissionDenied response. Used by Login to
+// detect the case where the caller passed a setup-key but the peer is
+// actually an SSO-enrolled record whose session needs refreshing — the
+// setup-key path cannot help there.
+func isPeerLoginExpired(err error) bool {
+ if !isPermissionDenied(err) {
+ return false
+ }
+ s, _ := status.FromError(err)
+ return strings.Contains(s.Message(), peerLoginExpiredMsg)
+}
diff --git a/client/internal/auth/auth_test.go b/client/internal/auth/auth_test.go
new file mode 100644
index 000000000..e393beccb
--- /dev/null
+++ b/client/internal/auth/auth_test.go
@@ -0,0 +1,80 @@
+package auth
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+func TestIsPeerLoginExpired(t *testing.T) {
+ cases := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {
+ name: "nil",
+ err: nil,
+ want: false,
+ },
+ {
+ name: "plain error (not a gRPC status)",
+ err: errors.New("network read: connection reset"),
+ want: false,
+ },
+ {
+ name: "PermissionDenied with different message",
+ err: status.Error(codes.PermissionDenied, "user is blocked"),
+ want: false,
+ },
+ {
+ name: "Unauthenticated with the expected phrase",
+ // Wrong status code — must still return false.
+ err: status.Error(codes.Unauthenticated, "peer login has expired, please log in once more"),
+ want: false,
+ },
+ {
+ name: "exact server message",
+ err: status.Error(codes.PermissionDenied, "peer login has expired, please log in once more"),
+ want: true,
+ },
+ {
+ name: "phrase as substring",
+ // Future-proofing: if mgm reworords but keeps the phrase,
+ // the friendly fallback must still kick in.
+ err: status.Error(codes.PermissionDenied, "session refused: peer login has expired (account=foo)"),
+ want: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := isPeerLoginExpired(tc.err); got != tc.want {
+ t.Fatalf("isPeerLoginExpired(%v) = %v, want %v", tc.err, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestErrSetupKeyOnSSOExpiredPeer(t *testing.T) {
+ // Sentinel must surface as PermissionDenied so the upstream
+ // isPermissionDenied / isAuthError checks classify it correctly
+ // (short-circuit retry backoff, set StatusNeedsLogin).
+ if !isPermissionDenied(errSetupKeyOnSSOExpiredPeer) {
+ t.Fatalf("errSetupKeyOnSSOExpiredPeer must be a PermissionDenied gRPC error")
+ }
+
+ // Message must actually mention SSO and `netbird up` so it is
+ // actionable for the end user. Loose substring checks keep the
+ // test resilient to copy edits.
+ s, _ := status.FromError(errSetupKeyOnSSOExpiredPeer)
+ msg := strings.ToLower(s.Message())
+ for _, want := range []string{"sso", "netbird up"} {
+ if !strings.Contains(msg, want) {
+ t.Errorf("sentinel message should contain %q, got %q", want, s.Message())
+ }
+ }
+}
diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go
index e33765300..9dec7cf53 100644
--- a/client/internal/auth/device_flow.go
+++ b/client/internal/auth/device_flow.go
@@ -259,12 +259,18 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
ticker := time.NewTicker(interval)
defer ticker.Stop()
+ log.Infof("device flow: waiting for user authorization, polling token endpoint every %s, code expires in %s", interval, timeout)
+
+ start := time.Now()
+ polls := 0
+
for {
select {
case <-waitCtx.Done():
return TokenInfo{}, waitCtx.Err()
case <-ticker.C:
+ polls++
tokenResponse, err := d.requestToken(info)
if err != nil {
return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err)
@@ -272,10 +278,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
if tokenResponse.Error != "" {
if tokenResponse.Error == "authorization_pending" {
+ log.Tracef("device flow: authorization still pending after poll %d", polls)
continue
} else if tokenResponse.Error == "slow_down" {
interval += (3 * time.Second)
ticker.Reset(interval)
+ log.Infof("device flow: IdP requested slow_down, polling interval increased to %s", interval)
continue
}
@@ -291,11 +299,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
UseIDToken: d.providerConfig.UseIDToken,
}
- err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
+ err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
if err != nil {
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
}
+ log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
return tokenInfo, err
}
}
diff --git a/client/internal/auth/pending_flow.go b/client/internal/auth/pending_flow.go
new file mode 100644
index 000000000..daeb18bc2
--- /dev/null
+++ b/client/internal/auth/pending_flow.go
@@ -0,0 +1,89 @@
+package auth
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+// PendingFlow stores an in-progress OAuth flow between the RPC that
+// initiates it (returns the verification URI to the UI) and the RPC
+// that waits for the user to complete it. The flow handle, the
+// device-code info, and the absolute expiry are kept together so the
+// waiting RPC can validate the device code and reuse the same flow.
+//
+// PendingFlow is safe for concurrent use; callers must not access the
+// stored fields directly.
+type PendingFlow struct {
+ mu sync.Mutex
+ flow OAuthFlow
+ info AuthFlowInfo
+ expiresAt time.Time
+ waitCancel context.CancelFunc
+}
+
+// NewPendingFlow returns an empty PendingFlow ready to be populated by Set.
+func NewPendingFlow() *PendingFlow {
+ return &PendingFlow{}
+}
+
+// Set stores the flow and its authorization info, computing the absolute
+// expiry from info.ExpiresIn (seconds, as returned by the IdP).
+func (p *PendingFlow) Set(flow OAuthFlow, info AuthFlowInfo) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.flow = flow
+ p.info = info
+ p.expiresAt = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second)
+}
+
+// Get returns the stored flow, info, and whether a flow is currently
+// pending. Returns (nil, zero, false) after Clear or before Set.
+func (p *PendingFlow) Get() (OAuthFlow, AuthFlowInfo, bool) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.flow == nil {
+ return nil, AuthFlowInfo{}, false
+ }
+ return p.flow, p.info, true
+}
+
+// ExpiresAt returns the absolute expiry of the pending flow. Returns
+// the zero time when no flow is pending.
+func (p *PendingFlow) ExpiresAt() time.Time {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.expiresAt
+}
+
+// SetWaitCancel records the cancel function for the goroutine currently
+// blocked in WaitToken so a new RequestAuth can preempt it.
+func (p *PendingFlow) SetWaitCancel(cancel context.CancelFunc) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.waitCancel = cancel
+}
+
+// CancelWait invokes and clears the stored wait-cancel, if any. Safe to
+// call when no wait is in progress.
+func (p *PendingFlow) CancelWait() {
+ p.mu.Lock()
+ cancel := p.waitCancel
+ p.waitCancel = nil
+ p.mu.Unlock()
+ if cancel != nil {
+ cancel()
+ }
+}
+
+// Clear resets the pending flow to empty. Any stored wait-cancel is
+// dropped without being invoked — call CancelWait first if the waiting
+// goroutine must be stopped.
+func (p *PendingFlow) Clear() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.flow = nil
+ p.info = AuthFlowInfo{}
+ p.expiresAt = time.Time{}
+ p.waitCancel = nil
+}
diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go
index 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 7cd2bab22..87126b222 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -27,12 +27,14 @@ import (
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/internal/dns"
+ "github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/metrics"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/stdnet"
+ "github.com/netbirdio/netbird/client/internal/tunnelnotifier"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/internal/updater/installer"
nbnet "github.com/netbirdio/netbird/client/net"
@@ -135,10 +137,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,
}
@@ -256,7 +261,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
log.Errorf("failed to clean up temporary installer file: %v", err)
}
- defer c.statusRecorder.ClientStop()
+ defer func() {
+ c.statusRecorder.SetSessionExpiresAt(time.Time{})
+ c.statusRecorder.ClientStop()
+ }()
operation := func() error {
// if context cancelled we not start new backoff cycle
if c.ctx.Err() != nil {
@@ -276,6 +284,15 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
if err != nil {
+ // On daemon shutdown / Down() the parent context is cancelled
+ // and the dial fails with "context canceled". Wrapping that
+ // into state would leave the snapshot stuck at Connecting+err
+ // until the backoff loop wakes up — instead let the operation
+ // return cleanly so the deferred state.Set(StatusIdle) takes
+ // effect on the next iteration.
+ if c.ctx.Err() != nil {
+ return nil
+ }
return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err))
}
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
@@ -314,6 +331,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(),
@@ -399,6 +420,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
StateManager: stateManager,
UpdateManager: c.updateManager,
ClientMetrics: c.clientMetrics,
+ MetricsCtx: c.ctx,
}, mobileDependency)
engine.SetSyncResponsePersistence(c.persistSyncResponse)
c.engine = engine
@@ -409,6 +431,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)
@@ -445,6 +471,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
}
c.statusRecorder.ClientStart()
+ // Wrap the backoff with c.ctx so Down()/actCancel propagates into the
+ // inter-attempt sleep — otherwise a 15s MaxInterval can keep the retry
+ // loop alive long after the caller asked to give up, leaving the
+ // status stream stuck at Connecting.
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
if err != nil {
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
@@ -595,8 +625,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,
@@ -670,7 +701,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..0f81844f6 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -232,6 +232,12 @@ const (
errorLogFile = "netbird.err"
stdoutLogFile = "netbird.out"
+ // Rotated-log glob prefixes (base log name without extension) passed to
+ // addRotatedLogFiles. The daemon's own log and the GUI log live in the same
+ // dir, so the prefixes must be disjoint to keep their rotated siblings apart.
+ clientLogPrefix = "client"
+ uiLogPrefix = "gui-client"
+
darwinErrorLogPath = "/var/log/netbird.out.log"
darwinStdoutLogPath = "/var/log/netbird.err.log"
)
@@ -241,6 +247,20 @@ type MetricsExporter interface {
Export(w io.Writer) error
}
+// LogOpener opens a log file for inclusion in the bundle. It exists so that log
+// files whose path was supplied by an IPC caller can be opened under a check
+// the daemon defines, instead of being opened with the daemon's privileges
+// unconditionally.
+type LogOpener func(path string) (*os.File, error)
+
+func openLogFile(path string) (*os.File, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", path, err)
+ }
+ return f, nil
+}
+
type BundleGenerator struct {
anonymizer *anonymize.Anonymizer
@@ -249,6 +269,8 @@ type BundleGenerator struct {
statusRecorder *peer.Status
syncResponse *mgmProto.SyncResponse
logPath string
+ uiLogPath string
+ uiLogOpener LogOpener
tempDir string
statePath string
cpuProfile []byte
@@ -276,14 +298,21 @@ type GeneratorDependencies struct {
StatusRecorder *peer.Status
SyncResponse *mgmProto.SyncResponse
LogPath string
- TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
- 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,6 +322,11 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
logFileCount = 1
}
+ uiLogOpener := deps.UILogOpener
+ if uiLogOpener == nil {
+ uiLogOpener = openLogFile
+ }
+
return &BundleGenerator{
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
@@ -300,6 +334,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
statusRecorder: deps.StatusRecorder,
syncResponse: deps.SyncResponse,
logPath: deps.LogPath,
+ uiLogPath: deps.UILogPath,
+ uiLogOpener: uiLogOpener,
tempDir: deps.TempDir,
statePath: deps.StatePath,
cpuProfile: deps.CPUProfile,
@@ -411,6 +447,10 @@ func (g *BundleGenerator) createArchive() error {
log.Errorf("failed to add logs to debug bundle: %v", err)
}
+ if err := g.addUILog(); err != nil {
+ log.Errorf("failed to add UI log to debug bundle: %v", err)
+ }
+
if err := g.addUpdateLogs(); err != nil {
log.Errorf("failed to add updater logs: %v", err)
}
@@ -466,7 +506,6 @@ 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,
@@ -663,6 +702,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
+ configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
if g.internalConfig.DisableNotifications != nil {
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))
@@ -681,7 +721,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("ClientCertKeyPath: %s\n", g.internalConfig.ClientCertKeyPath))
}
- configContent.WriteString(fmt.Sprintf("LazyConnectionEnabled: %v\n", g.internalConfig.LazyConnectionEnabled))
+ configContent.WriteString(fmt.Sprintf("LazyConnection: %q\n", g.internalConfig.LazyConnection))
configContent.WriteString(fmt.Sprintf("MTU: %d\n", g.internalConfig.MTU))
}
@@ -982,11 +1022,11 @@ func (g *BundleGenerator) addLogfile() error {
logDir := filepath.Dir(g.logPath)
- if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil {
+ if err := g.addSingleLogfile(openLogFile, g.logPath, clientLogFile); err != nil {
return fmt.Errorf("add client log file to zip: %w", err)
}
- g.addRotatedLogFiles(logDir)
+ g.addRotatedLogFiles(openLogFile, logDir, clientLogPrefix)
stdErrLogPath := filepath.Join(logDir, errorLogFile)
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
@@ -995,20 +1035,39 @@ func (g *BundleGenerator) addLogfile() error {
stdoutLogPath = darwinStdoutLogPath
}
- if err := g.addSingleLogfile(stdErrLogPath, errorLogFile); err != nil {
+ if err := g.addSingleLogfile(openLogFile, stdErrLogPath, errorLogFile); err != nil {
log.Warnf("Failed to add %s to zip: %v", errorLogFile, err)
}
- if err := g.addSingleLogfile(stdoutLogPath, stdoutLogFile); err != nil {
+ if err := g.addSingleLogfile(openLogFile, stdoutLogPath, stdoutLogFile); err != nil {
log.Warnf("Failed to add %s to zip: %v", stdoutLogFile, err)
}
return nil
}
+// addUILog adds the desktop UI's gui-client.log (and its rotated siblings) to
+// the bundle. The path is reported by the UI via RegisterUILog; empty when no
+// UI registered one (e.g. headless / server). Missing file is non-fatal — the
+// UI only writes it while the daemon is in debug, so it's often absent.
+func (g *BundleGenerator) addUILog() error {
+ if g.uiLogPath == "" {
+ log.Debugf("no UI log path registered, skipping in debug bundle")
+ return nil
+ }
+
+ if err := g.addSingleLogfile(g.uiLogOpener, g.uiLogPath, configs.UILogFile); err != nil {
+ return fmt.Errorf("add UI log file to zip: %w", err)
+ }
+
+ g.addRotatedLogFiles(g.uiLogOpener, filepath.Dir(g.uiLogPath), uiLogPrefix)
+
+ return nil
+}
+
// addSingleLogfile adds a single log file to the archive
-func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
- logFile, err := os.Open(logPath)
+func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName string) error {
+ logFile, err := open(logPath)
if err != nil {
return fmt.Errorf("open log file %s: %w", targetName, err)
}
@@ -1033,8 +1092,8 @@ func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
}
// addSingleLogFileGz adds a single gzipped log file to the archive
-func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
- f, err := os.Open(logPath)
+func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName string) error {
+ f, err := open(logPath)
if err != nil {
return fmt.Errorf("open gz log file %s: %w", targetName, err)
}
@@ -1078,14 +1137,16 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
return nil
}
-// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount
-func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
+// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount.
+// prefix is the base log name without extension (e.g. "client", "gui-client");
+// the glob matches both files rotated by us and by logrotate on linux.
+func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix string) {
if g.logFileCount == 0 {
return
}
- // 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 +1180,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)
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_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_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/uilog_test.go b/client/internal/debug/uilog_test.go
new file mode 100644
index 000000000..103e98c6f
--- /dev/null
+++ b/client/internal/debug/uilog_test.go
@@ -0,0 +1,64 @@
+package debug
+
+import (
+ "archive/zip"
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/configs"
+)
+
+// bundleEntries generates a bundle with the given generator and returns the
+// set of entry names in the resulting archive.
+func bundleEntries(t *testing.T, g *BundleGenerator) map[string]struct{} {
+ t.Helper()
+
+ path, err := g.Generate()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = os.Remove(path) })
+
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+ require.NoError(t, err)
+
+ names := make(map[string]struct{}, len(zr.File))
+ for _, f := range zr.File {
+ names[f.Name] = struct{}{}
+ }
+ return names
+}
+
+func TestBundleIncludesUILogWhenOpenerAllows(t *testing.T) {
+ path := filepath.Join(t.TempDir(), configs.UILogFile)
+ require.NoError(t, os.WriteFile(path, []byte("gui log"), 0600))
+
+ g := NewBundleGenerator(GeneratorDependencies{
+ UILogPath: path,
+ UILogOpener: openLogFile,
+ }, BundleConfig{})
+
+ require.Contains(t, bundleEntries(t, g), configs.UILogFile)
+}
+
+// A UILogOpener that refuses (as the ownership check does for a foreign file)
+// keeps the UI log out of the bundle without failing bundle generation.
+func TestBundleExcludesUILogWhenOpenerRefuses(t *testing.T) {
+ path := filepath.Join(t.TempDir(), configs.UILogFile)
+ require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
+
+ g := NewBundleGenerator(GeneratorDependencies{
+ UILogPath: path,
+ UILogOpener: func(string) (*os.File, error) {
+ return nil, fmt.Errorf("not owned by the caller")
+ },
+ }, BundleConfig{})
+
+ require.NotContains(t, bundleEntries(t, g), configs.UILogFile)
+}
diff --git a/client/internal/debug/upload.go b/client/internal/debug/upload.go
index cdf52409d..88fde6d6f 100644
--- a/client/internal/debug/upload.go
+++ b/client/internal/debug/upload.go
@@ -3,10 +3,12 @@ package debug
import (
"context"
"crypto/sha256"
+ "crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
+ neturl "net/url"
"os"
"github.com/netbirdio/netbird/upload-server/types"
@@ -14,20 +16,80 @@ import (
const maxBundleUploadSize = 50 * 1024 * 1024
-func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) {
- response, err := getUploadURL(ctx, url, managementURL)
+// requireHTTPS refuses any URL the daemon would fetch or upload to that is not
+// https. The daemon runs as root and the bundle carries its logs and state, so a
+// plaintext hop is a place to intercept the bundle or the presigned redirect.
+// The server-side gate already enforces this for the desktop path; this also
+// covers the mobile and job-runner callers that reach this package directly.
+// Skipped when the caller opted into an insecure upload (self-hosted server).
+func requireHTTPS(what, rawURL string) error {
+ parsed, err := neturl.Parse(rawURL)
+ if err != nil {
+ return fmt.Errorf("parse %s: %w", what, err)
+ }
+ if parsed.Scheme != "https" {
+ return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme)
+ }
+ return nil
+}
+
+// uploadClient returns the HTTP client for the upload requests. The default
+// client verifies TLS and refuses a redirect that would downgrade to a non-https
+// hop, so a bundle can never leave over http after an https start. The insecure
+// variant accepts http and untrusted certificates, and is only reachable for a
+// privileged caller that passed --upload-bundle-insecure (see
+// requirePrivilegeForUploadURL).
+func uploadClient(insecure bool) *http.Client {
+ if !insecure {
+ return &http.Client{CheckRedirect: rejectInsecureRedirect}
+ }
+ return &http.Client{
+ Transport: &http.Transport{
+ //nolint:gosec // opt-in, privileged, self-hosted upload servers
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},
+ },
+ }
+}
+
+// rejectInsecureRedirect refuses a redirect to a non-https target and keeps the
+// standard library's 10-hop limit that a custom CheckRedirect would otherwise
+// disable.
+func rejectInsecureRedirect(req *http.Request, via []*http.Request) error {
+ if req.URL.Scheme != "https" {
+ return fmt.Errorf("refusing redirect to non-https URL %s", req.URL.Redacted())
+ }
+ if len(via) >= 10 {
+ return fmt.Errorf("stopped after 10 redirects")
+ }
+ return nil
+}
+
+func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) {
+ if !insecure {
+ if err := requireHTTPS("upload service URL", url); err != nil {
+ return "", err
+ }
+ }
+
+ response, err := getUploadURL(ctx, url, managementURL, insecure)
if err != nil {
return "", err
}
- err = upload(ctx, filePath, response)
+ if !insecure {
+ if err := requireHTTPS("upload URL from service", response.URL); err != nil {
+ return "", err
+ }
+ }
+
+ err = upload(ctx, filePath, response, insecure)
if err != nil {
return "", err
}
return response.Key, nil
}
-func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error {
+func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error {
fileData, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file: %w", err)
@@ -52,7 +114,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
req.ContentLength = stat.Size()
req.Header.Set("Content-Type", "application/octet-stream")
- putResp, err := http.DefaultClient.Do(req)
+ putResp, err := uploadClient(insecure).Do(req)
if err != nil {
return fmt.Errorf("upload failed: %v", err)
}
@@ -65,16 +127,23 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
return nil
}
-func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) {
- id := getURLHash(managementURL)
- getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil)
+func getUploadURL(ctx context.Context, serviceURL string, managementURL string, insecure bool) (*types.GetURLResponse, error) {
+ parsed, err := neturl.Parse(serviceURL)
+ if err != nil {
+ return nil, fmt.Errorf("parse upload service URL: %w", err)
+ }
+ q := parsed.Query()
+ q.Set("id", getURLHash(managementURL))
+ parsed.RawQuery = q.Encode()
+
+ getReq, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil)
if err != nil {
return nil, fmt.Errorf("create GET request: %w", err)
}
getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue)
- resp, err := http.DefaultClient.Do(getReq)
+ resp, err := uploadClient(insecure).Do(getReq)
if err != nil {
return nil, fmt.Errorf("get presigned URL: %w", err)
}
diff --git a/client/internal/debug/upload_test.go b/client/internal/debug/upload_test.go
index f224b8d3f..f3927cb81 100644
--- a/client/internal/debug/upload_test.go
+++ b/client/internal/debug/upload_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"net"
"net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -43,7 +44,7 @@ func TestUpload(t *testing.T) {
fileContent := []byte("test file content")
err := os.WriteFile(file, fileContent, 0640)
require.NoError(t, err)
- key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file)
+ key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file, true)
require.NoError(t, err)
id := getURLHash(testURL)
require.Contains(t, key, id+"/")
@@ -79,3 +80,47 @@ func waitForServer(t *testing.T, addr string) {
}
t.Fatalf("server did not start listening on %s in time", addr)
}
+
+func TestRequireHTTPS(t *testing.T) {
+ require.NoError(t, requireHTTPS("upload URL", "https://upload.example/path"))
+ require.Error(t, requireHTTPS("upload URL", "http://upload.example/path"))
+ require.Error(t, requireHTTPS("upload URL", "ftp://upload.example/path"))
+ require.Error(t, requireHTTPS("upload URL", "://malformed"))
+}
+
+func TestRejectInsecureRedirect(t *testing.T) {
+ httpsReq, err := http.NewRequest(http.MethodGet, "https://a.example/", nil)
+ require.NoError(t, err)
+ require.NoError(t, rejectInsecureRedirect(httpsReq, nil), "https redirect target must be allowed")
+
+ httpReq, err := http.NewRequest(http.MethodGet, "http://a.example/", nil)
+ require.NoError(t, err)
+ require.Error(t, rejectInsecureRedirect(httpReq, nil), "http redirect target must be refused")
+
+ require.Error(t, rejectInsecureRedirect(httpsReq, make([]*http.Request, 10)), "the 10-redirect limit must be enforced")
+}
+
+// The secure client refuses to follow an https response that redirects to http,
+// so a bundle can't be downgraded onto plaintext mid-flight.
+func TestUploadClientRefusesHTTPSToHTTPRedirect(t *testing.T) {
+ plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(plain.Close)
+
+ secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, plain.URL, http.StatusFound)
+ }))
+ t.Cleanup(secure.Close)
+
+ client := uploadClient(false)
+ // Trust the test server's cert without disabling verification globally.
+ client.Transport = secure.Client().Transport
+
+ resp, err := client.Get(secure.URL)
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+ require.Error(t, err, "redirect from https to http must be refused")
+ require.Contains(t, err.Error(), "non-https")
+}
diff --git a/client/internal/dns/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/resutil/resolve.go b/client/internal/dns/resutil/resolve.go
index a2599aee7..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 {
diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go
index e6a8cc6a5..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"
@@ -121,6 +122,164 @@ 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{
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index 7556c66cc..f79454457 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 {
@@ -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()
@@ -1435,11 +1443,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..a03aea169
--- /dev/null
+++ b/client/internal/dns/server_privileged_test.go
@@ -0,0 +1,485 @@
+//go:build privileged
+
+package dns
+
+import (
+ "context"
+ "fmt"
+ "net/netip"
+ "os"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+ "github.com/netbirdio/netbird/client/iface"
+ pfmock "github.com/netbirdio/netbird/client/iface/mocks"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/dns/local"
+ "github.com/netbirdio/netbird/client/internal/dns/test"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/stdnet"
+ nbdns "github.com/netbirdio/netbird/dns"
+)
+
+func TestUpdateDNSServer(t *testing.T) {
+
+ nameServers := []nbdns.NameServer{
+ {
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ {
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ }
+
+ testCases := []struct {
+ name string
+ initUpstreamMap []handlerWrapper
+ initLocalZones []nbdns.CustomZone
+ initSerial uint64
+ inputSerial uint64
+ inputUpdate nbdns.Config
+ shouldFail bool
+ expectedUpstreamMap []handlerWrapper
+ expectedLocalQs []dns.Question
+ }{
+ {
+ name: "Initial Config Should Succeed",
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.io",
+ priority: PriorityUpstream,
+ },
+ {
+ domain: "netbird.cloud",
+ priority: PriorityLocal,
+ },
+ {
+ domain: nbdns.RootZone,
+ priority: PriorityDefault,
+ },
+ },
+ expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
+ },
+ {
+ name: "New Config Should Succeed",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.cloud",
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.io",
+ priority: PriorityUpstream,
+ },
+ {
+ domain: "netbird.cloud",
+ priority: PriorityLocal,
+ },
+ },
+ expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
+ },
+ {
+ name: "Smaller Config Serial Should Be Skipped",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 2,
+ inputSerial: 1,
+ shouldFail: true,
+ },
+ {
+ name: "Empty NS Group Domain Or Not Primary Element Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ },
+ },
+ },
+ shouldFail: true,
+ },
+ {
+ name: "Invalid NS Group Nameservers list Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ },
+ },
+ },
+ shouldFail: true,
+ },
+ {
+ name: "Invalid Custom Zone Records list Should Skip",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{{
+ domain: ".",
+ priority: PriorityDefault,
+ }},
+ },
+ {
+ name: "Empty Config Should Succeed and Clean Maps",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{ServiceEnable: true},
+ expectedUpstreamMap: nil,
+ expectedLocalQs: []dns.Question{},
+ },
+ {
+ name: "Disabled Service Should clean map",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{ServiceEnable: false},
+ expectedUpstreamMap: nil,
+ expectedLocalQs: []dns.Question{},
+ },
+ }
+
+ for n, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ privKey, _ := wgtypes.GenerateKey()
+ newNet, err := stdnet.NewNet(context.Background(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ opts := iface.WGIFaceOpts{
+ IFaceName: fmt.Sprintf("utun230%d", n),
+ Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)),
+ WGPort: 33100,
+ WGPrivKey: privKey.String(),
+ MTU: iface.DefaultMTU,
+ TransportNet: newNet,
+ }
+
+ wgIface, err := iface.NewWGIFace(opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = wgIface.Create()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ err = wgIface.Close()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+ dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
+ WgInterface: wgIface,
+ CustomAddress: "",
+ StatusRecorder: peer.NewRecorder("mgm"),
+ StateManager: nil,
+ DisableSys: false,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = dnsServer.Initialize()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ err = dnsServer.hostManager.restoreHostDNS()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+
+ dnsServer.dnsMuxHandlers = testCase.initUpstreamMap
+ dnsServer.localResolver.Update(testCase.initLocalZones)
+ dnsServer.updateSerial = testCase.initSerial
+
+ err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
+ if err != nil {
+ if testCase.shouldFail {
+ return
+ }
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ }
+
+ if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) {
+ t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers))
+ }
+
+ for _, expected := range testCase.expectedUpstreamMap {
+ found := false
+ for _, got := range dnsServer.dnsMuxHandlers {
+ if got.domain == expected.domain && got.priority == expected.priority {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers)
+ }
+ }
+
+ var responseMSG *dns.Msg
+ responseWriter := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ responseMSG = m
+ return nil
+ },
+ }
+ for _, q := range testCase.expectedLocalQs {
+ dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{
+ Question: []dns.Question{q},
+ })
+ }
+
+ if len(testCase.expectedLocalQs) > 0 {
+ assert.NotNil(t, responseMSG, "response message should not be nil")
+ assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success")
+ assert.NotEmpty(t, responseMSG.Answer, "response message should have answers")
+ }
+ })
+ }
+}
+
+func TestDNSFakeResolverHandleUpdates(t *testing.T) {
+ ov := os.Getenv("NB_WG_KERNEL_DISABLED")
+ defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
+
+ t.Setenv("NB_WG_KERNEL_DISABLED", "true")
+ newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
+ if err != nil {
+ t.Errorf("create stdnet: %v", err)
+ return
+ }
+
+ privKey, _ := wgtypes.GeneratePrivateKey()
+ opts := iface.WGIFaceOpts{
+ IFaceName: "utun2301",
+ Address: wgaddr.MustParseWGAddress("100.66.100.1/32"),
+ WGPort: 33100,
+ WGPrivKey: privKey.String(),
+ MTU: iface.DefaultMTU,
+ TransportNet: newNet,
+ }
+ wgIface, err := iface.NewWGIFace(opts)
+ if err != nil {
+ t.Errorf("build interface wireguard: %v", err)
+ return
+ }
+
+ err = wgIface.Create()
+ if err != nil {
+ t.Errorf("create and init wireguard interface: %v", err)
+ return
+ }
+ defer func() {
+ if err = wgIface.Close(); err != nil {
+ t.Logf("close wireguard interface: %v", err)
+ }
+ }()
+
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ packetfilter := pfmock.NewMockPacketFilter(ctrl)
+ packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes()
+ packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+ packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+
+ if err := wgIface.SetFilter(packetfilter); err != nil {
+ t.Errorf("set packet filter: %v", err)
+ return
+ }
+
+ dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
+ WgInterface: wgIface,
+ CustomAddress: "",
+ StatusRecorder: peer.NewRecorder("mgm"),
+ StateManager: nil,
+ DisableSys: false,
+ })
+ if err != nil {
+ t.Errorf("create DNS server: %v", err)
+ return
+ }
+
+ err = dnsServer.Initialize()
+ if err != nil {
+ t.Errorf("run DNS server: %v", err)
+ return
+ }
+ defer func() {
+ if err = dnsServer.hostManager.restoreHostDNS(); err != nil {
+ t.Logf("restore DNS settings on the host: %v", err)
+ return
+ }
+ }()
+
+ dnsServer.dnsMuxHandlers = []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &local.Resolver{},
+ priority: PriorityUpstream,
+ },
+ }
+ dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
+ dnsServer.updateSerial = 0
+
+ nameServers := []nbdns.NameServer{
+ {
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ {
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ }
+
+ update := nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ }
+
+ // Start the server with regular configuration
+ if err := dnsServer.UpdateDNSServer(1, update); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+
+ update2 := update
+ update2.ServiceEnable = false
+ // Disable the server, stop the listener
+ if err := dnsServer.UpdateDNSServer(2, update2); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+
+ update3 := update2
+ update3.NameServerGroups = update3.NameServerGroups[:1]
+ // But service still get updates and we checking that we handle
+ // internal state in the right way
+ if err := dnsServer.UpdateDNSServer(3, update3); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+}
diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go
index 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/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_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 c15a8520f..b7e5a10e3 100644
--- a/client/internal/dnsfwd/forwarder.go
+++ b/client/internal/dnsfwd/forwarder.go
@@ -37,6 +37,12 @@ const (
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 {
@@ -210,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 == "" {
@@ -227,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, query.IsEdns0() != nil, startTime)
+ f.handleDNSError(ctx, logger, w, question, resp, qname, result, reqHasEdns, startTime)
return
}
@@ -240,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)
diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go
index 046595473..c69a9166e 100644
--- a/client/internal/dnsfwd/forwarder_test.go
+++ b/client/internal/dnsfwd/forwarder_test.go
@@ -133,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
@@ -545,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
}{
{
@@ -562,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",
},
}
@@ -599,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
@@ -614,10 +638,213 @@ 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
diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o
index 6e9cda44a..7433ad740 100644
Binary files a/client/internal/ebpf/ebpf/bpf_bpfeb.o and b/client/internal/ebpf/ebpf/bpf_bpfeb.o differ
diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.o b/client/internal/ebpf/ebpf/bpf_bpfel.o
index 6338f4774..779f43a00 100644
Binary files a/client/internal/ebpf/ebpf/bpf_bpfel.o and b/client/internal/ebpf/ebpf/bpf_bpfel.o differ
diff --git a/client/internal/ebpf/ebpf/src/dns_fwd.c b/client/internal/ebpf/ebpf/src/dns_fwd.c
index 5f3fbcc32..9f8de2001 100644
--- a/client/internal/ebpf/ebpf/src/dns_fwd.c
+++ b/client/internal/ebpf/ebpf/src/dns_fwd.c
@@ -52,11 +52,14 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
udp->dest = dns_port;
+ // Clear the now-stale checksum; zero means "not computed" for IPv4.
+ udp->check = 0;
return XDP_PASS;
}
if (udp->source == dns_port && ip->saddr == dns_ip) {
udp->source = GENERAL_DNS_PORT;
+ udp->check = 0;
return XDP_PASS;
}
diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c
index 88fea65cf..5e7474928 100644
--- a/client/internal/ebpf/ebpf/src/wg_proxy.c
+++ b/client/internal/ebpf/ebpf/src/wg_proxy.c
@@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
__be16 new_dst_port = htons(proxy_port);
udp->dest = new_dst_port;
udp->source = new_src_port;
+
+ // The ports are covered by the UDP checksum. This is an IPv4 loopback hop
+ // and the payload is already integrity-protected, so clear the checksum (a
+ // zero UDP checksum means "not computed" for IPv4) rather than leave a
+ // stale value the kernel would drop as UDP_CSUM.
+ udp->check = 0;
return XDP_PASS;
}
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 452075da8..617892e43 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -40,6 +40,7 @@ import (
"github.com/netbirdio/netbird/client/internal/dnsfwd"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/ingressgw"
+ "github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/metrics"
"github.com/netbirdio/netbird/client/internal/netflow"
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
@@ -63,7 +64,10 @@ import (
"github.com/netbirdio/netbird/route"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
+ sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
+ nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+ types "github.com/netbirdio/netbird/shared/management/types"
"github.com/netbirdio/netbird/shared/netiputil"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
@@ -82,6 +86,12 @@ 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")
@@ -140,8 +150,11 @@ type EngineConfig struct {
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
+ SyncMessageVersion *int
- LazyConnectionEnabled bool
+ // LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to
+ // the env var and management feature flag.
+ LazyConnection lazyconn.State
MTU uint16
@@ -166,6 +179,7 @@ type EngineServices struct {
StateManager *statemanager.Manager
UpdateManager *updater.Manager
ClientMetrics *metrics.ClientMetrics
+ MetricsCtx context.Context
}
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
@@ -210,6 +224,13 @@ type Engine struct {
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
networkSerial uint64
+ // latestComponents is the most-recent NetworkMapComponents decoded from
+ // a NetworkMapEnvelope (capability=3 peers only). Held alongside the
+ // NetworkMap that Calculate() produced from it so future incremental
+ // updates have a base to apply changes against. nil for legacy-format
+ // peers. Guarded by syncMsgMux.
+ latestComponents *types.NetworkMapComponents
+
networkMonitor *networkmonitor.NetworkMonitor
sshServer sshServer
@@ -258,11 +279,26 @@ type Engine struct {
// clientMetrics collects and pushes metrics
clientMetrics *metrics.ClientMetrics
+ metricsCtx context.Context
jobExecutor *jobexec.Executor
jobExecutorWG sync.WaitGroup
exposeManager *expose.Manager
+
+ sessionWatcher sessionDeadlineWatcher
+}
+
+// sessionDeadlineWatcher is the engine-facing surface of the SSO session
+// expiry watcher. The concrete implementation (sessionwatch.Watcher) is wired
+// in via newSessionWatcher, which is build-tagged so the js/wasm build links a
+// no-op stub instead of pulling the full sessionwatch package (and its timer
+// machinery) into the binary — the wasm client never runs the engine's
+// session-warning flow.
+type sessionDeadlineWatcher interface {
+ Update(deadline time.Time) error
+ Dismiss()
+ Close()
}
// Peer is an instance of the Connection Peer
@@ -310,9 +346,21 @@ func NewEngine(
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
jobExecutor: jobexec.NewExecutor(),
clientMetrics: services.ClientMetrics,
+ metricsCtx: services.MetricsCtx,
updateManager: services.UpdateManager,
syncStoreDir: config.StateDir,
}
+ // sessionWatcher keeps the SubscribeStatus consumers in sync with the
+ // session expiry deadline. Deadline-change ticks come for free via
+ // Status.SetSessionExpiresAt; the watcher exists to push a wake-up at
+ // T-WarningLead and T-FinalWarningLead so the UI repaints the remaining
+ // time / warning state even when nothing else changed, and to publish
+ // two SystemEvents (the warning composition lives in sessionwatch so
+ // the wire format stays owned by one package):
+ // - T-WarningLead → interactive "Extend now / Dismiss" notification
+ // - T-FinalWarningLead → auto-opened SessionAboutToExpire dialog,
+ // suppressed when the user dismissed the earlier warning
+ engine.sessionWatcher = newSessionWatcher(engine.statusRecorder)
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
return engine
@@ -379,6 +427,10 @@ func (e *Engine) stopLocked() {
e.srWatcher.Close()
}
+ if e.sessionWatcher != nil {
+ e.sessionWatcher.Close()
+ }
+
if e.updateManager != nil {
e.updateManager.SetDownloadOnly()
}
@@ -510,7 +562,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
} else {
log.Infof("running rosenpass in strict mode")
}
- e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName)
+ e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey)
if err != nil {
return fmt.Errorf("create rosenpass manager: %w", err)
}
@@ -611,8 +663,24 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
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())
@@ -895,6 +963,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() {
@@ -910,29 +988,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 {
@@ -944,6 +1079,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.
@@ -973,6 +1121,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)
}
@@ -1042,6 +1192,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")
@@ -1066,11 +1224,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,
@@ -1082,19 +1251,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 {
@@ -1209,7 +1386,7 @@ 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)
},
}
@@ -1240,31 +1417,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
@@ -1357,13 +1518,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)
@@ -1372,29 +1536,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()))
@@ -1409,42 +1604,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 {
@@ -1924,7 +2120,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
- e.config.LazyConnectionEnabled,
+ e.config.SyncMessageVersion,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
@@ -2117,7 +2313,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()
@@ -2140,9 +2349,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)
@@ -2485,13 +2694,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
}
@@ -2501,6 +2711,27 @@ func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers
return excludedPeers
}
+// peerRoutesAddr reports whether the peer is a router for addr, matched against
+// the peer's already-parsed AllowedIPs from the store (the same typed value the
+// lazy manager consumes) rather than re-parsing the network map strings.
+func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool {
+ prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey())
+ if !ok {
+ return false
+ }
+ return prefixesContain(prefixes, addr)
+}
+
+// prefixesContain reports whether addr falls within any of the prefixes.
+func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool {
+ for _, prefix := range prefixes {
+ if prefix.Contains(addr) {
+ return true
+ }
+ }
+ return false
+}
+
// isChecksEqual checks if two slices of checks are equal.
func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
normalize := func(checks []*mgmProto.Checks) []string {
diff --git a/client/internal/engine_authsession.go b/client/internal/engine_authsession.go
new file mode 100644
index 000000000..725c0903f
--- /dev/null
+++ b/client/internal/engine_authsession.go
@@ -0,0 +1,108 @@
+package internal
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
+ cProto "github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/client/system"
+)
+
+// ApplySessionDeadline propagates the absolute SSO session deadline carried on
+// LoginResponse / SyncResponse to both the watcher (for the edge-triggered
+// warning) and the status recorder (for the SubscribeStatus / Status RPC
+// snapshot the UI consumes).
+//
+// The wire field is 3-state:
+// - nil → snapshot carries no info; keep the
+// previously-anchored deadline (no-op)
+// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is
+// disabled; clear both sinks
+// - valid timestamp → new deadline; arm watcher, expose on
+// status recorder
+//
+// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected
+// value is treated as a clear on both sinks: the alternative — leaving the
+// previously-known deadline in place — risks the UI confidently displaying
+// a stale "expires in X" while the server has actually invalidated it.
+func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
+ if ts == nil {
+ return
+ }
+ var deadline time.Time
+ // Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled".
+ // Everything else flows through Watcher.Update, whose sanity-checks
+ // reject out-of-range / pre-epoch / far-future / too-stale values and
+ // clear on rejection.
+ if ts.GetSeconds() != 0 || ts.GetNanos() != 0 {
+ deadline = ts.AsTime().UTC()
+ }
+ if e.sessionWatcher == nil {
+ return
+ }
+ // Watcher.Update owns the propagation to the status recorder (the
+ // SubscribeStatus / Status snapshot the UI reads): a set writes the
+ // deadline, a clear or a sanity-check rejection writes the zero value.
+ // Keeping a single writer is what stops the recorder from drifting out
+ // of sync with the warning timers.
+ if err := e.sessionWatcher.Update(deadline); err != nil {
+ log.Errorf("auth session deadline rejected: %v, clearing", err)
+ e.statusRecorder.PublishEvent(
+ cProto.SystemEvent_ERROR,
+ cProto.SystemEvent_AUTHENTICATION,
+ "session deadline rejected",
+ "",
+ map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()},
+ )
+ }
+}
+
+// DismissSessionWarning records the user's "Dismiss" click on the
+// T-WarningLead interactive notification and suppresses the upcoming
+// T-FinalWarningLead fallback for the current deadline. No-op when the
+// watcher is not running or holds no deadline.
+func (e *Engine) DismissSessionWarning() {
+ if e.sessionWatcher == nil {
+ return
+ }
+ e.sessionWatcher.Dismiss()
+}
+
+// ExtendAuthSession asks the management server to refresh the SSO session
+// expiry deadline using the supplied JWT, then mirrors the new deadline into
+// the daemon's state. The tunnel is untouched; no resync, no reconnect.
+//
+// Returns the new absolute UTC deadline (or zero time when the server
+// reports the peer is not eligible for extension).
+func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) {
+ if jwtToken == "" {
+ return time.Time{}, errors.New("jwt token is required")
+ }
+ if e.mgmClient == nil {
+ return time.Time{}, errors.New("management client is not initialised")
+ }
+
+ info, err := system.GetInfoWithChecks(ctx, e.checks)
+ if err != nil {
+ log.Warnf("failed to collect system info for session extend: %v", err)
+ info = system.GetInfo(ctx)
+ }
+
+ resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("extend auth session on management: %w", err)
+ }
+
+ e.ApplySessionDeadline(resp.GetSessionExpiresAt())
+
+ if resp.GetSessionExpiresAt().IsValid() {
+ return resp.GetSessionExpiresAt().AsTime().UTC(), nil
+ }
+ return time.Time{}, nil
+}
diff --git a/client/internal/engine_lazy_exclude_test.go b/client/internal/engine_lazy_exclude_test.go
new file mode 100644
index 000000000..b5ef16c3b
--- /dev/null
+++ b/client/internal/engine_lazy_exclude_test.go
@@ -0,0 +1,87 @@
+package internal
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/peerstore"
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+func TestPrefixesContain(t *testing.T) {
+ tests := []struct {
+ name string
+ prefixes []string
+ addr string
+ want bool
+ }{
+ {name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true},
+ {name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true},
+ {name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false},
+ {name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false},
+ {name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true},
+ {name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ prefixes := make([]netip.Prefix, 0, len(tt.prefixes))
+ for _, p := range tt.prefixes {
+ prefixes = append(prefixes, netip.MustParsePrefix(p))
+ }
+ require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr)))
+ })
+ }
+}
+
+// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target
+// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from
+// lazy connections, matched via the peer's already-parsed AllowedIPs.
+func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) {
+ const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0="
+ const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0="
+
+ store := peerstore.NewConnStore()
+ store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32"))
+ store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32"))
+
+ e := &Engine{peerStore: store}
+
+ peers := []*mgmProto.RemotePeerConfig{
+ {WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}},
+ {WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}},
+ }
+ rules := []firewallManager.ForwardRule{
+ {TranslatedAddress: netip.MustParseAddr("100.110.8.145")},
+ }
+
+ excluded := e.toExcludedLazyPeers(rules, peers)
+
+ require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections")
+ require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded")
+ require.Len(t, excluded, 1)
+}
+
+func TestToExcludedLazyPeers_NoRules(t *testing.T) {
+ e := &Engine{peerStore: peerstore.NewConnStore()}
+
+ peers := []*mgmProto.RemotePeerConfig{
+ {WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}},
+ }
+
+ require.Empty(t, e.toExcludedLazyPeers(nil, peers))
+}
+
+func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn {
+ t.Helper()
+ conn, err := peer.NewConn(peer.ConnConfig{
+ Key: key,
+ WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}},
+ }, peer.ServiceDependencies{})
+ require.NoError(t, err)
+ return conn
+}
diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go
new file mode 100644
index 000000000..f787f741f
--- /dev/null
+++ b/client/internal/engine_privileged_test.go
@@ -0,0 +1,565 @@
+//go:build privileged
+
+package internal
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/google/uuid"
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/iface/device"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/dns"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ "github.com/netbirdio/netbird/client/system"
+ nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbcache "github.com/netbirdio/netbird/management/server/cache"
+ "github.com/netbirdio/netbird/management/server/groups"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
+ "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/telemetry"
+ "github.com/netbirdio/netbird/management/server/types"
+ mgmt "github.com/netbirdio/netbird/shared/management/client"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ relayClient "github.com/netbirdio/netbird/shared/relay/client"
+ signal "github.com/netbirdio/netbird/shared/signal/client"
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ signalServer "github.com/netbirdio/netbird/signal/server"
+ "github.com/netbirdio/netbird/util"
+)
+
+func TestEngine_SSH(t *testing.T) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ engine := NewEngine(
+ ctx, cancel,
+ &EngineConfig{
+ WgIfaceName: "utun101",
+ WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
+ WgPrivateKey: key,
+ WgPort: 33100,
+ ServerSSHAllowed: true,
+ MTU: iface.DefaultMTU,
+ SSHKey: sshKey,
+ },
+ EngineServices{
+ SignalClient: &signal.MockClient{},
+ MgmClient: &mgmt.MockClient{},
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ },
+ MobileDependency{},
+ )
+
+ engine.dnsServer = &dns.MockServer{
+ UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
+ }
+
+ err = engine.Start(nil, nil)
+ require.NoError(t, err)
+
+ defer func() {
+ err := engine.Stop()
+ if err != nil {
+ return
+ }
+ }()
+
+ peerWithSSH := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ AllowedIps: []string{"100.64.0.21/24"},
+ SshConfig: &mgmtProto.SSHConfig{
+ SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"),
+ },
+ }
+
+ // SSH server is not enabled so SSH config of a remote peer should be ignored
+ networkMap := &mgmtProto.NetworkMap{
+ Serial: 6,
+ PeerConfig: nil,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ assert.Nil(t, engine.sshServer)
+
+ // SSH server is enabled, therefore SSH config should be applied
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 7,
+ PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
+ SshConfig: &mgmtProto.SSHConfig{
+ SshEnabled: true,
+ JwtConfig: &mgmtProto.JWTConfig{
+ Issuer: "test-issuer",
+ Audience: "test-audience",
+ KeysLocation: "test-keys",
+ MaxTokenAge: 3600,
+ },
+ }},
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ time.Sleep(250 * time.Millisecond)
+ assert.NotNil(t, engine.sshServer)
+
+ // now remove peer
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 8,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ // time.Sleep(250 * time.Millisecond)
+ assert.NotNil(t, engine.sshServer)
+
+ // now disable SSH server
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 9,
+ PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
+ SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}},
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ assert.Nil(t, engine.sshServer)
+}
+
+func TestEngine_Sync(t *testing.T) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ // feed updates to Engine via mocked Management client
+ updates := make(chan *mgmtProto.SyncResponse)
+ defer close(updates)
+ syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
+ for msg := range updates {
+ err := msgHandler(msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ engine := NewEngine(ctx, cancel, &EngineConfig{
+ WgIfaceName: "utun103",
+ WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
+ WgPrivateKey: key,
+ WgPort: 33100,
+ MTU: iface.DefaultMTU,
+ }, EngineServices{
+ SignalClient: &signal.MockClient{},
+ MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ }, MobileDependency{})
+ engine.ctx = ctx
+
+ engine.dnsServer = &dns.MockServer{
+ UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
+ }
+
+ defer func() {
+ err := engine.Stop()
+ if err != nil {
+ return
+ }
+ }()
+
+ err = engine.Start(nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ peer1 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ AllowedIps: []string{"100.64.0.10/24"},
+ }
+ peer2 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
+ AllowedIps: []string{"100.64.0.11/24"},
+ }
+ peer3 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
+ AllowedIps: []string{"100.64.0.12/24"},
+ }
+ // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update
+ updates <- &mgmtProto.SyncResponse{
+ NetworkMap: &mgmtProto.NetworkMap{
+ Serial: 10,
+ PeerConfig: nil,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3},
+ RemotePeersIsEmpty: false,
+ },
+ }
+
+ timeout := time.After(time.Second * 2)
+ for {
+ select {
+ case <-timeout:
+ t.Fatalf("timeout while waiting for test to finish")
+ return
+ default:
+ }
+
+ if getPeers(engine) == 3 && engine.networkSerial == 10 {
+ break
+ }
+ }
+}
+
+func TestEngine_MultiplePeers(t *testing.T) {
+ // log.SetLevel(log.DebugLevel)
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ sigServer, signalAddr, err := startSignal(t)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+ defer sigServer.Stop()
+ mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql")
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+ defer mgmtServer.GracefulStop()
+
+ setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
+
+ mu := sync.Mutex{}
+ engines := []*Engine{}
+ numPeers := 10
+ wg := sync.WaitGroup{}
+ wg.Add(numPeers)
+ // create and start peers
+ for i := 0; i < numPeers; i++ {
+ j := i
+ go func() {
+ engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr)
+ if err != nil {
+ wg.Done()
+ t.Errorf("unable to create the engine for peer %d with error %v", j, err)
+ return
+ }
+ engine.dnsServer = &dns.MockServer{}
+ mu.Lock()
+ defer mu.Unlock()
+ guid := fmt.Sprintf("{%s}", uuid.New().String())
+ device.CustomWindowsGUIDString = strings.ToLower(guid)
+ err = engine.Start(nil, nil)
+ if err != nil {
+ t.Errorf("unable to start engine for peer %d with error %v", j, err)
+ wg.Done()
+ return
+ }
+ engines = append(engines, engine)
+ wg.Done()
+ }()
+ }
+
+ // wait until all have been created and started
+ wg.Wait()
+ if len(engines) != numPeers {
+ t.Fatal("not all peers were started")
+ }
+ // check whether all the peer have expected peers connected
+
+ expectedConnected := numPeers * (numPeers - 1)
+
+ // adjust according to timeouts
+ timeout := 50 * time.Second
+ timeoutChan := time.After(timeout)
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+loop:
+ for {
+ select {
+ case <-timeoutChan:
+ t.Fatalf("waiting for expected connections timeout after %s", timeout.String())
+ break loop
+ case <-ticker.C:
+ totalConnected := 0
+ for _, engine := range engines {
+ totalConnected += getConnectedPeers(engine)
+ }
+ if totalConnected == expectedConnected {
+ log.Infof("total connected=%d", totalConnected)
+ break loop
+ }
+ log.Infof("total connected=%d", totalConnected)
+ }
+ }
+ // cleanup test
+ for n, peerEngine := range engines {
+ t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n)
+ errStop := peerEngine.mgmClient.Close()
+ if errStop != nil {
+ log.Infoln("got error trying to close management clients from engine: ", errStop)
+ }
+ errStop = peerEngine.Stop()
+ if errStop != nil {
+ log.Infoln("got error trying to close testing peers engine: ", errStop)
+ }
+ }
+}
+
+var (
+ kaep = keepalive.EnforcementPolicy{
+ MinTime: 15 * time.Second,
+ PermitWithoutStream: true,
+ }
+
+ kasp = keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Second,
+ MaxConnectionAgeGrace: 5 * time.Second,
+ Time: 5 * time.Second,
+ Timeout: 2 * time.Second,
+ }
+)
+
+func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ return nil, err
+ }
+ mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false)
+ if err != nil {
+ return nil, err
+ }
+ signalClient, err := signal.NewClient(ctx, signalAddr, key, false)
+ if err != nil {
+ return nil, err
+ }
+
+ info := system.GetInfo(ctx)
+ resp, err := mgmtClient.Register(setupKey, "", info, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var ifaceName string
+ if runtime.GOOS == "darwin" {
+ ifaceName = fmt.Sprintf("utun1%d", i)
+ } else {
+ ifaceName = fmt.Sprintf("wt%d", i)
+ }
+
+ wgPort := 33100 + i
+ conf := &EngineConfig{
+ WgIfaceName: ifaceName,
+ WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
+ WgPrivateKey: key,
+ WgPort: wgPort,
+ MTU: iface.DefaultMTU,
+ }
+
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ e, err := NewEngine(ctx, cancel, conf, EngineServices{
+ SignalClient: signalClient,
+ MgmClient: mgmtClient,
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ }, MobileDependency{}), nil
+ e.ctx = ctx
+ return e, err
+}
+
+func startSignal(t *testing.T) (*grpc.Server, string, error) {
+ t.Helper()
+
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ log.Fatalf("failed to listen: %v", err)
+ }
+
+ srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ proto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) {
+ t.Helper()
+
+ config := &config.Config{
+ Stuns: []*config.Host{},
+ TURNConfig: &config.TURNConfig{},
+ Relay: &config.Relay{
+ Addresses: []string{"127.0.0.1:1234"},
+ CredentialsTTL: util.Duration{Duration: time.Hour},
+ Secret: "222222222222222222",
+ },
+ Signal: &config.Host{
+ Proto: "http",
+ URI: "localhost:10000",
+ },
+ Datadir: dataDir,
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir)
+ if err != nil {
+ return nil, "", err
+ }
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+ if err != nil {
+ return nil, "", err
+ }
+
+ permissionsManager := permissions.NewManager(store)
+ peersManager := peers.NewManager(store, permissionsManager)
+ jobManager := job.NewJobManager(nil, store, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
+
+ metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+ require.NoError(t, err)
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ settingsMockManager := settings.NewMockManager(ctrl)
+ settingsMockManager.EXPECT().
+ GetSettings(gomock.Any(), gomock.Any(), gomock.Any()).
+ Return(&types.Settings{}, nil).
+ AnyTimes()
+ settingsMockManager.EXPECT().
+ GetExtraSettings(gomock.Any(), gomock.Any()).
+ Return(&types.ExtraSettings{}, nil).
+ AnyTimes()
+
+ groupsManager := groups.NewManagerMock()
+
+ updateManager := update_channel.NewPeersUpdateManager(metrics)
+ requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
+ networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
+ if err != nil {
+ return nil, "", err
+ }
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+// getConnectedPeers returns a connection Status or nil if peer connection wasn't found
+func getConnectedPeers(e *Engine) int {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+ i := 0
+ for _, id := range e.peerStore.PeersPubKey() {
+ conn, _ := e.peerStore.PeerConn(id)
+ if conn.IsConnected() {
+ i++
+ }
+ }
+ return i
+}
+
+func getPeers(e *Engine) int {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+
+ return len(e.peerStore.PeersPubKey())
+}
diff --git a/client/internal/engine_session_deadline_test.go b/client/internal/engine_session_deadline_test.go
new file mode 100644
index 000000000..5a67f103a
--- /dev/null
+++ b/client/internal/engine_session_deadline_test.go
@@ -0,0 +1,88 @@
+package internal
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the
+// wire field carried on LoginResponse / SyncResponse:
+//
+// - nil pointer → no info; previously-anchored deadline survives
+// - explicit zero value → "expiry disabled" sentinel; both sinks cleared
+// - valid future timestamp → new deadline propagated to both sinks
+func TestApplySessionDeadline_ThreeState(t *testing.T) {
+ newEngine := func() *Engine {
+ recorder := peer.NewRecorder("")
+ return &Engine{
+ statusRecorder: recorder,
+ sessionWatcher: sessionwatch.New(recorder),
+ }
+ }
+
+ t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) {
+ e := newEngine()
+ deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
+
+ e.ApplySessionDeadline(timestamppb.New(deadline))
+
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline),
+ "status recorder should hold the new deadline")
+ })
+
+ t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) {
+ e := newEngine()
+ seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
+ e.ApplySessionDeadline(timestamppb.New(seeded))
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
+
+ e.ApplySessionDeadline(nil)
+
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded),
+ "nil snapshot must not disturb the existing deadline")
+ })
+
+ t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) {
+ e := newEngine()
+ seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
+ e.ApplySessionDeadline(timestamppb.New(seeded))
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
+
+ // Explicit zero Timestamp{} (seconds=0, nanos=0) is the
+ // "expiry disabled / not SSO" sentinel.
+ e.ApplySessionDeadline(×tamppb.Timestamp{})
+
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
+ "explicit zero sentinel must clear the deadline")
+ })
+
+ t.Run("invalid timestamp clears the deadline", func(t *testing.T) {
+ e := newEngine()
+ seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
+ e.ApplySessionDeadline(timestamppb.New(seeded))
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
+
+ // Out-of-range nanos → IsValid()==false; same-meaning as the
+ // disabled sentinel for downstream sinks.
+ e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1})
+
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
+ "invalid timestamp must clear the deadline")
+ })
+
+ t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
+ e := newEngine()
+ expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
+
+ e.ApplySessionDeadline(timestamppb.New(expired))
+
+ require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
+ "recently-expired deadline must stay on the recorder so consumers render it as expired")
+ })
+}
diff --git a/client/internal/engine_sessionwatch.go b/client/internal/engine_sessionwatch.go
new file mode 100644
index 000000000..a46d73f87
--- /dev/null
+++ b/client/internal/engine_sessionwatch.go
@@ -0,0 +1,16 @@
+//go:build !js
+
+package internal
+
+import (
+ "github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+// newSessionWatcher returns the real SSO session expiry watcher for every
+// non-wasm build. The js/wasm build gets a no-op stub from
+// engine_sessionwatch_js.go so the sessionwatch package (and its timer
+// machinery) never links into the wasm binary.
+func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
+ return sessionwatch.New(recorder)
+}
diff --git a/client/internal/engine_sessionwatch_js.go b/client/internal/engine_sessionwatch_js.go
new file mode 100644
index 000000000..50e148ab9
--- /dev/null
+++ b/client/internal/engine_sessionwatch_js.go
@@ -0,0 +1,44 @@
+//go:build js
+
+package internal
+
+import (
+ "time"
+
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The
+// wasm client never runs the engine's session-warning flow (the interactive
+// T-WarningLead notification and the T-FinalWarningLead fallback dialog live
+// in the desktop UI), so linking the full sessionwatch package (timers, event
+// composition) would only bloat the binary.
+//
+// It still mirrors the deadline into the status recorder so the SubscribeStatus
+// / Status snapshot the UI consumes stays correct — only the timer-driven
+// warnings are dropped.
+type noopSessionWatcher struct {
+ recorder *peer.Status
+}
+
+func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
+ return noopSessionWatcher{recorder: recorder}
+}
+
+// Update mirrors the real watcher's recorder propagation without the timers or
+// sanity-check sentinels: a valid deadline is exposed on the status snapshot,
+// the zero time clears it.
+func (w noopSessionWatcher) Update(deadline time.Time) error {
+ if w.recorder != nil {
+ w.recorder.SetSessionExpiresAt(deadline)
+ }
+ return nil
+}
+
+func (noopSessionWatcher) Dismiss() {
+ // No-op: only suppresses the timer-driven final-warning, which this stub never arms.
+}
+
+func (noopSessionWatcher) Close() {
+ // No-op: no timers to stop and no state to unwind; the recorder is cleared via Update(zero).
+}
diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go
index 8f29bf072..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(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_SSHUpdateLogic(t *testing.T) {
// Test that SSH server start/stop logic works based on config
engine := &Engine{
@@ -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(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_UpdateNetworkMapWithRoutes(t *testing.T) {
testCases := []struct {
name string
@@ -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/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/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/infra/README.md b/client/internal/metrics/infra/README.md
index 5a93dbd87..7941a30cf 100644
--- a/client/internal/metrics/infra/README.md
+++ b/client/internal/metrics/infra/README.md
@@ -78,6 +78,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 +210,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..09a4e8b02 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"
@@ -29,6 +30,11 @@ import (
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(
@@ -117,6 +123,9 @@ type Conn struct {
wgWatcher *WGWatcher
wgWatcherWg sync.WaitGroup
wgWatcherCancel context.CancelFunc
+ // wgTimeouts counts consecutive WireGuard handshake timeouts without a
+ // successful handshake in between. Guarded by mu.
+ wgTimeouts int
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
rosenpassRemoteKey []byte
@@ -136,6 +145,39 @@ type Conn struct {
// Connection stage timestamps for metrics
metricsRecorder MetricsRecorder
metricsStages *MetricsStages
+
+ // pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real
+ // transport is up.
+ pendingFirstPacket []byte
+}
+
+// injectPendingFirstPacket replays the captured handshake through the proxy if present, else
+// directly through the ICE conn. The packet is cleared only after a successful write, so a failed
+// or transport-less attempt leaves it available for a later reinjection. Caller must hold conn.mu.
+func (conn *Conn) injectPendingFirstPacket(proxy wgproxy.Proxy, directConn net.Conn) {
+ pkt := conn.pendingFirstPacket
+ if len(pkt) == 0 {
+ return
+ }
+
+ switch {
+ case proxy != nil:
+ if err := proxy.InjectPacket(pkt); err != nil {
+ conn.Log.Debugf("failed to reinject captured first packet via proxy: %v", err)
+ return
+ }
+ case directConn != nil:
+ if _, err := directConn.Write(pkt); err != nil {
+ conn.Log.Debugf("failed to reinject captured first packet via direct conn: %v", err)
+ return
+ }
+ default:
+ conn.Log.Debugf("no transport available to reinject captured first packet")
+ return
+ }
+
+ conn.pendingFirstPacket = nil
+ conn.Log.Debugf("reinjected captured first packet (%d bytes)", len(pkt))
}
// NewConn creates a new not opened Conn to the remote peer.
@@ -161,7 +203,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
statusICE: worker.NewAtomicStatus(),
dumpState: dumpState,
endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)),
- wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState),
metricsRecorder: services.MetricsRecorder,
}
@@ -172,6 +213,16 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
// It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will
// be used.
func (conn *Conn) Open(engineCtx context.Context) error {
+ return conn.open(engineCtx, nil)
+}
+
+// OpenWithFirstPacket opens the connection like Open and stashes firstPacket to be replayed once
+// the real transport is established. The packet is retained only on a successful open.
+func (conn *Conn) OpenWithFirstPacket(engineCtx context.Context, firstPacket []byte) error {
+ return conn.open(engineCtx, firstPacket)
+}
+
+func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
conn.mu.Lock()
defer conn.mu.Unlock()
@@ -227,6 +278,9 @@ func (conn *Conn) Open(engineCtx context.Context) error {
defer conn.wg.Done()
conn.guard.Start(conn.ctx, conn.onGuardEvent)
}()
+ if len(firstPacket) > 0 {
+ conn.pendingFirstPacket = slices.Clone(firstPacket)
+ }
conn.opened = true
return nil
}
@@ -423,6 +477,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 +602,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 +670,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 +691,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 +833,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) {
@@ -790,7 +888,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 +939,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 {
diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go
index 59216b647..49979ea83 100644
--- a/client/internal/peer/conn_test.go
+++ b/client/internal/peer/conn_test.go
@@ -7,6 +7,7 @@ import (
"testing"
"time"
+ log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/iface"
@@ -304,3 +305,84 @@ 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")
+}
diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go
index 2e5efbcc5..6c2e846a9 100644
--- a/client/internal/peer/guard/guard.go
+++ b/client/internal/peer/guard/guard.go
@@ -85,7 +85,11 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
defer g.srWatcher.RemoveListener(srReconnectedChan)
ticker := g.initialTicker(ctx)
- defer ticker.Stop()
+ defer func() {
+ // If backoff.Ticker.send is blocked, context.Done will not close the Ticker goroutine.
+ // We have to explicitly call Stop, even if we use backoff.WithContext.
+ ticker.Stop()
+ }()
tickerChannel := ticker.C
diff --git a/client/internal/peer/guard/guard_leak_test.go b/client/internal/peer/guard/guard_leak_test.go
new file mode 100644
index 000000000..ded3e4aea
--- /dev/null
+++ b/client/internal/peer/guard/guard_leak_test.go
@@ -0,0 +1,92 @@
+package guard
+
+import (
+ "context"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/peer/ice"
+)
+
+func newTestGuard(status connStatusFunc) *Guard {
+ srw := NewSRWatcher(nil, nil, nil, ice.Config{})
+ return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw)
+}
+
+// countBackoffTickerGoroutines returns how many goroutines are currently sitting
+// in backoff/v4.(*Ticker).run (a ticker goroutine that has not exited).
+func countBackoffTickerGoroutines() int {
+ buf := make([]byte, 1<<25) // 32MB
+ n := runtime.Stack(buf, true)
+ return strings.Count(string(buf[:n]), "backoff/v4.(*Ticker).run")
+}
+
+// TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown reproduces a observed
+// leak: after a shutdown burst, ticker run/send goroutines stay parked
+// forever even though every reconnect loop has exited.
+func TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown(t *testing.T) {
+ before := countBackoffTickerGoroutines()
+
+ const peers = 6000
+ cancels := make([]context.CancelFunc, 0, peers)
+ var wg sync.WaitGroup
+
+ // A status check slower than the tick cadence. This models the real
+ // isConnectedOnAllWay/callback doing work: while the loop is busy in the
+ // handler, the ticker fires the next tick and parks in send(), because
+ // send() never selects on ctx.
+ slowStatus := func() ConnStatus {
+ time.Sleep(70 * time.Millisecond)
+ return ConnStatusConnected
+ }
+
+ for range peers {
+ g := newTestGuard(slowStatus)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancels = append(cancels, cancel)
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ g.Start(ctx, func() {})
+ }()
+ // Force the live ticker to be a newReconnectTicker.
+ g.SetRelayedConnDisconnected()
+ }
+
+ // Let the replacement tickers get past their 800ms initial interval, so
+ // many are parked in send() waiting on the (slow) consumer when we tear
+ // everything down.
+ time.Sleep(1500 * time.Millisecond)
+
+ // Shutdown burst: cancel every peer at once, like engine teardown.
+ for _, c := range cancels {
+ c()
+ }
+
+ // Every reconnect loop must return
+ waitCh := make(chan struct{})
+ go func() { wg.Wait(); close(waitCh) }()
+ select {
+ case <-waitCh:
+ case <-time.After(30 * time.Second):
+ t.Fatal("not all reconnect loops returned after ctx cancel")
+ }
+
+ // Give any correctly-stopped ticker goroutines time to unwind.
+ for range 50 {
+ runtime.Gosched()
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ leaked := countBackoffTickerGoroutines() - before
+ t.Logf("backoff Ticker.run goroutines still parked after teardown of %d peers: %d", peers, leaked)
+ if leaked > 0 {
+ t.Errorf("LEAK: %d backoff ticker goroutines parked after all reconnect loops exited "+
+ "(defer ticker.Stop() stops the initial ticker, not the live replacement)", leaked)
+ }
+}
diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go
index 1d44096b6..56e82e6e3 100644
--- a/client/internal/peer/handshaker.go
+++ b/client/internal/peer/handshaker.go
@@ -195,14 +195,14 @@ func (h *Handshaker) sendOffer() error {
}
offer := h.buildOfferAnswer()
- h.log.Infof("sending offer with serial: %s", offer.SessionIDString())
+ h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
answer := h.buildOfferAnswer()
- h.log.Infof("sending answer with serial: %s", answer.SessionIDString())
+ h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
return h.signaler.SignalAnswer(answer, h.config.Key)
}
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index 3e5c56dd2..423ce9b23 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -7,6 +7,7 @@ import (
"net/netip"
"slices"
"sync"
+ "sync/atomic"
"time"
"github.com/google/uuid"
@@ -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,19 @@ func (d *Status) GetFullStatus() FullStatus {
// ClientStart will notify all listeners about the new service state
func (d *Status) ClientStart() {
d.notifier.clientStart()
+ d.notifyStateChange()
}
// ClientStop will notify all listeners about the new service state
func (d *Status) ClientStop() {
d.notifier.clientStop()
+ d.notifyStateChange()
}
// ClientTeardown will notify all listeners about the service is under teardown
func (d *Status) ClientTeardown() {
d.notifier.clientTearDown()
+ d.notifyStateChange()
}
// SetConnectionListener set a listener to the notifier
@@ -1258,6 +1350,79 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent {
return d.eventQueue.GetAll()
}
+// SubscribeToStateChanges hands back a channel that receives a tick on
+// every connection-state change (connected / disconnected / connecting /
+// address change / peers-list change). The channel is buffered to one
+// pending tick so a coalesced burst still wakes the consumer exactly
+// once. Pass the returned id to UnsubscribeFromStateChanges to detach.
+func (d *Status) SubscribeToStateChanges() (string, <-chan struct{}) {
+ d.stateChangeMux.Lock()
+ defer d.stateChangeMux.Unlock()
+
+ id := uuid.New().String()
+ ch := make(chan struct{}, 1)
+ d.stateChangeStreams[id] = ch
+ return id, ch
+}
+
+// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel
+// and closes it so any consumer goroutine selecting on the channel
+// unblocks cleanly.
+func (d *Status) UnsubscribeFromStateChanges(id string) {
+ d.stateChangeMux.Lock()
+ defer d.stateChangeMux.Unlock()
+
+ if ch, ok := d.stateChangeStreams[id]; ok {
+ close(ch)
+ delete(d.stateChangeStreams, id)
+ }
+}
+
+// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops
+// the tick if a subscriber's buffer is full — by definition the consumer
+// is already going to fetch the latest snapshot, so multiple pending ticks
+// would be redundant.
+func (d *Status) notifyStateChange() {
+ d.stateChangeMux.Lock()
+ defer d.stateChangeMux.Unlock()
+
+ for _, ch := range d.stateChangeStreams {
+ select {
+ case ch <- struct{}{}:
+ default:
+ }
+ }
+}
+
+// NotifyStateChange is the public wake-the-subscribers entry point used by
+// callers that mutate state outside the peer recorder — most importantly
+// the connect-state machine, which writes StatusNeedsLogin into the
+// shared contextState (client/internal/state.go) without touching any
+// recorder field. Without this push the SubscribeStatus stream stays on
+// the previous snapshot until an unrelated peer/management/signal
+// change happens to fire notifyStateChange, leaving the UI's status
+// out of sync with the daemon.
+func (d *Status) NotifyStateChange() {
+ d.notifyStateChange()
+}
+
+// BumpNetworksRevision increments the routed-networks revision and wakes every
+// SubscribeStatus subscriber. The route manager calls it when a network map
+// changes the available routes or when a selection is applied — the peer
+// status itself only records actively-routed (chosen) networks, so without
+// this bump a candidate route appearing/disappearing would never reach the UI.
+func (d *Status) BumpNetworksRevision() {
+ d.networksRevision.Add(1)
+ d.notifyStateChange()
+}
+
+// GetNetworksRevision returns the current routed-networks revision, surfaced in
+// the status snapshot so the UI can detect route/selection changes (see
+// BumpNetworksRevision).
+func (d *Status) GetNetworksRevision() uint64 {
+ return d.networksRevision.Load()
+}
+
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
d.mux.Lock()
defer d.mux.Unlock()
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/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/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 5ddd11b04..696a60310 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"
@@ -439,7 +440,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..9e9577796 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"
@@ -71,3 +72,22 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return nil
}
+
+// RemoveProfileState deletes the per-profile state file (which holds the
+// account email used for the SSO login hint and the UI display). Called after
+// a successful logout so a logged-out profile no longer shows a stale account
+// email. The state file only stores the email, so deleting it is equivalent to
+// clearing it; the next SSO login recreates it. A missing file is not an error.
+func (pm *ProfileManager) RemoveProfileState(profileName string) error {
+ configDir, err := getConfigDir()
+ if err != nil {
+ return fmt.Errorf("get config directory: %w", err)
+ }
+
+ stateFile := filepath.Join(configDir, profileName+".state.json")
+ if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("remove profile state: %w", err)
+ }
+
+ return nil
+}
diff --git a/client/internal/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 22f3355c8..f92300bfd 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()
@@ -293,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 {
diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go
index f0efd7b22..bb3b1c59c 100644
--- a/client/internal/routemanager/dynamic/route.go
+++ b/client/internal/routemanager/dynamic/route.go
@@ -135,7 +135,7 @@ func (r *Route) RemoveAllowedIPs() error {
var merr *multierror.Error
for _, domainPrefixes := range r.dynamicDomains {
for _, prefix := range domainPrefixes {
- if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil {
+ if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err))
}
}
@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
}
func (r *Route) update(ctx context.Context) error {
- resolved, err := r.resolveDomains()
+ resolved, err := r.resolveDomains(ctx)
if err != nil {
if len(resolved) == 0 {
return fmt.Errorf("resolve domains: %w", err)
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
return nil
}
-func (r *Route) resolveDomains() (domainMap, error) {
+func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
results := make(chan resolveResult)
- go r.resolve(results)
+ go r.resolve(ctx, results)
resolved := domainMap{}
var merr *multierror.Error
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) {
return resolved, nberrors.FormatErrorOrNil(merr)
}
-func (r *Route) resolve(results chan resolveResult) {
+func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
var wg sync.WaitGroup
for _, d := range r.route.Domains {
@@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) {
go func(domain domain.Domain) {
defer wg.Done()
- ips, err := r.getIPsFromResolver(domain)
+ ips, err := r.getIPsFromResolver(ctx, domain)
if err != nil {
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
- ips, err = net.LookupIP(domain.PunycodeString())
+ ips, err = lookupHostIPs(ctx, domain)
if err != nil {
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
return
@@ -320,7 +320,7 @@ func (r *Route) removeRoutes(prefixes []netip.Prefix) ([]netip.Prefix, error) {
merr = multierror.Append(merr, fmt.Errorf("remove dynamic route for IP %s: %w", prefix, err))
}
if r.currentPeerKey != "" {
- if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil {
+ if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err))
}
}
@@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
return
}
+// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
+func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
+ if err != nil {
+ return nil, err
+ }
+
+ ips := make([]net.IP, 0, len(addrs))
+ for _, addr := range addrs {
+ ips = append(ips, addr.IP)
+ }
+ return ips, nil
+}
+
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
prefixSet := make(map[netip.Prefix]struct{})
for _, prefix := range oldPrefixes {
diff --git a/client/internal/routemanager/dynamic/route_generic.go b/client/internal/routemanager/dynamic/route_generic.go
index 56fd63fba..8bc2dd3df 100644
--- a/client/internal/routemanager/dynamic/route_generic.go
+++ b/client/internal/routemanager/dynamic/route_generic.go
@@ -3,11 +3,12 @@
package dynamic
import (
+ "context"
"net"
"github.com/netbirdio/netbird/shared/management/domain"
)
-func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
- return net.LookupIP(domain.PunycodeString())
+func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
+ return lookupHostIPs(ctx, domain)
}
diff --git a/client/internal/routemanager/dynamic/route_ios.go b/client/internal/routemanager/dynamic/route_ios.go
index 1ae281d56..6a3d262b8 100644
--- a/client/internal/routemanager/dynamic/route_ios.go
+++ b/client/internal/routemanager/dynamic/route_ios.go
@@ -3,6 +3,7 @@
package dynamic
import (
+ "context"
"fmt"
"net"
"time"
@@ -16,7 +17,7 @@ import (
const dialTimeout = 10 * time.Second
-func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
+func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
if err != nil {
return nil, fmt.Errorf("error while creating private client: %s", err)
@@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
msg := new(dns.Msg)
msg.SetQuestion(fqdn, qtype)
- response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
+ response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
if err != nil {
if queryErr == nil {
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
diff --git a/client/internal/routemanager/exit_node_selection_test.go b/client/internal/routemanager/exit_node_selection_test.go
new file mode 100644
index 000000000..28dd0a640
--- /dev/null
+++ b/client/internal/routemanager/exit_node_selection_test.go
@@ -0,0 +1,191 @@
+package routemanager
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/routeselector"
+ "github.com/netbirdio/netbird/route"
+)
+
+func newExitNodeTestManager() *DefaultManager {
+ return &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+}
+
+func exitRoute(netID, peer string, skipAutoApply bool) *route.Route {
+ return &route.Route{
+ NetID: route.NetID(netID),
+ Network: netip.MustParsePrefix("0.0.0.0/0"),
+ Peer: peer,
+ SkipAutoApply: skipAutoApply,
+ }
+}
+
+func TestPickPreferredExitNode(t *testing.T) {
+ tests := []struct {
+ name string
+ info exitNodeInfo
+ want route.NetID
+ }{
+ {
+ name: "persisted user selection wins over management",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"a", "b", "c"},
+ userSelected: []route.NetID{"b"},
+ selectedByManagement: []route.NetID{"a"},
+ },
+ want: "b",
+ },
+ {
+ name: "multiple user-selected self-heal to deterministic min",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"a", "b", "c"},
+ userSelected: []route.NetID{"c", "a"},
+ },
+ want: "a",
+ },
+ {
+ name: "explicit opt-out keeps none",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"a", "b"},
+ userDeselected: []route.NetID{"a", "b"},
+ },
+ want: "",
+ },
+ {
+ name: "fresh defaults to management auto-apply pick",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"a", "b", "c"},
+ selectedByManagement: []route.NetID{"b"},
+ },
+ want: "b",
+ },
+ {
+ name: "no user pick and no management auto-apply selects none",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"c", "a", "b"},
+ },
+ want: "",
+ },
+ {
+ name: "user-deselect does not block a management auto-apply sibling",
+ info: exitNodeInfo{
+ allIDs: []route.NetID{"a", "b"},
+ userDeselected: []route.NetID{"a"},
+ selectedByManagement: []route.NetID{"b"},
+ },
+ want: "b",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, pickPreferredExitNode(tt.info), "preferred exit node")
+ })
+ }
+}
+
+func TestEnforceSingleExitNode(t *testing.T) {
+ m := newExitNodeTestManager()
+ all := []route.NetID{"a", "b", "c"}
+
+ m.enforceSingleExitNode("b", all)
+ assert.False(t, m.routeSelector.IsSelected("a"), "a should be deselected")
+ assert.True(t, m.routeSelector.IsSelected("b"), "b should be the only selected exit node")
+ assert.False(t, m.routeSelector.IsSelected("c"), "c should be deselected")
+
+ // Switching the preferred node moves the single selection.
+ m.enforceSingleExitNode("c", all)
+ assert.False(t, m.routeSelector.IsSelected("a"), "a stays deselected")
+ assert.False(t, m.routeSelector.IsSelected("b"), "b should now be deselected")
+ assert.True(t, m.routeSelector.IsSelected("c"), "c should now be selected")
+
+ // Empty preferred turns every exit node off.
+ m.enforceSingleExitNode("", all)
+ for _, id := range all {
+ assert.False(t, m.routeSelector.IsSelected(id), "no exit node should be selected")
+ }
+}
+
+func TestEnforceSingleExitNode_RespectsDeselectAll(t *testing.T) {
+ m := newExitNodeTestManager()
+ m.routeSelector.DeselectAllRoutes()
+
+ m.enforceSingleExitNode("b", []route.NetID{"a", "b"})
+
+ assert.True(t, m.routeSelector.IsDeselectAll(), "global deselect-all must stay in effect")
+ assert.False(t, m.routeSelector.IsSelected("b"), "no exit node should be forced on while deselect-all is set")
+}
+
+func TestUpdateRouteSelectorFromManagement_FreshSelectsOne(t *testing.T) {
+ m := newExitNodeTestManager()
+ routes := route.HAMap{
+ "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
+ "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
+ "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
+ "exitC|0.0.0.0/0": {exitRoute("exitC", "p4", false)},
+ }
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ // Exactly one exit node (the deterministic first) is selected.
+ assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA is the deterministic default")
+ assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB must not also be selected")
+ assert.False(t, m.routeSelector.IsSelected("exitC"), "exitC must not also be selected")
+ // Non-exit routes are left at their default-on state.
+ assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
+}
+
+func TestUpdateRouteSelectorFromManagement_HonorsPersistedPick(t *testing.T) {
+ m := newExitNodeTestManager()
+ routes := route.HAMap{
+ "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
+ "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
+ }
+ all := []route.NetID{"exitA", "exitB"}
+
+ // Simulate the state the runtime select path leaves behind: exactly one
+ // exit node explicitly selected, its sibling deselected.
+ require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exitB"}, true, all))
+ require.NoError(t, m.routeSelector.DeselectRoutes([]route.NetID{"exitA"}, all))
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ assert.True(t, m.routeSelector.IsSelected("exitB"), "persisted pick must stay selected")
+ assert.False(t, m.routeSelector.IsSelected("exitA"), "the other exit node stays deselected")
+}
+
+func TestUpdateRouteSelectorFromManagement_OptOutKeepsNone(t *testing.T) {
+ m := newExitNodeTestManager()
+ routes := route.HAMap{
+ "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
+ "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
+ }
+ all := []route.NetID{"exitA", "exitB"}
+
+ // User deselected exit nodes and selected none.
+ require.NoError(t, m.routeSelector.DeselectRoutes(all, all))
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ assert.False(t, m.routeSelector.IsSelected("exitA"), "opt-out keeps exitA off")
+ assert.False(t, m.routeSelector.IsSelected("exitB"), "opt-out keeps exitB off")
+}
+
+func TestUpdateRouteSelectorFromManagement_NoAutoApplySelectsNone(t *testing.T) {
+ m := newExitNodeTestManager()
+ // SkipAutoApply=true: management offers the exit nodes but doesn't request
+ // auto-activation, so none should be selected until the user picks one.
+ routes := route.HAMap{
+ "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)},
+ "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)},
+ }
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ assert.False(t, m.routeSelector.IsSelected("exitA"), "no auto-apply keeps exitA off")
+ assert.False(t, m.routeSelector.IsSelected("exitB"), "no auto-apply keeps exitB off")
+}
diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go
index 22458d575..2ab7e2a85 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -12,6 +12,7 @@ import (
"strings"
"sync"
"sync/atomic"
+ "syscall"
"time"
"github.com/google/uuid"
@@ -51,6 +52,10 @@ 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
@@ -60,6 +65,7 @@ type Manager interface {
InitialRouteRange() []string
SetFirewall(firewall.Manager) error
SetDNSForwarderPort(port uint16)
+ ReconcilePeerAllowedIPs(peerKey string) error
Stop(stateManager *statemanager.Manager)
}
@@ -214,7 +220,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 +237,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 +294,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()
}
@@ -442,6 +476,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
@@ -582,6 +621,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
@@ -701,7 +744,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 +761,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 +796,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 +824,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 +841,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..cf761091d 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
@@ -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_ios.go b/client/internal/routemanager/notifier/notifier_ios.go
index d0888f3a1..c91a76551 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) {
@@ -59,44 +50,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/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..46b7c9fb7 100644
--- a/client/internal/routemanager/sysctl/sysctl_linux.go
+++ b/client/internal/routemanager/sysctl/sysctl_linux.go
@@ -20,6 +20,8 @@ const (
rpFilterPath = "net.ipv4.conf.all.rp_filter"
rpFilterInterfacePath = "net.ipv4.conf.%s.rp_filter"
srcValidMarkPath = "net.ipv4.conf.all.src_valid_mark"
+ percentEscape = "%25"
+ dotEscape = "%2E"
)
type iface interface {
@@ -56,7 +58,11 @@ func Setup(wgIface iface) (map[string]int, error) {
continue
}
- i := fmt.Sprintf(rpFilterInterfacePath, intf.Name)
+ // Escape '%' and '.' so they survive the dot-to-slash conversion in Set()
+ safeName := strings.ReplaceAll(intf.Name, "%", percentEscape)
+ safeName = strings.ReplaceAll(safeName, ".", dotEscape)
+
+ i := fmt.Sprintf(rpFilterInterfacePath, safeName)
oldVal, err := Set(i, 2, true)
if err != nil {
result = multierror.Append(result, err)
@@ -70,7 +76,11 @@ func Setup(wgIface iface) (map[string]int, error) {
// Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1
func Set(key string, desiredValue int, onlyIfOne bool) (int, error) {
- path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/"))
+ path := strings.ReplaceAll(key, ".", "/")
+ // Unescape interface dots and percent signs
+ path = strings.ReplaceAll(path, dotEscape, ".")
+ path = strings.ReplaceAll(path, percentEscape, "%")
+ path = fmt.Sprintf("/proc/sys/%s", path)
currentValue, err := os.ReadFile(path)
if err != nil {
return -1, fmt.Errorf("read sysctl %s: %w", key, err)
diff --git a/client/internal/routemanager/systemops/rt_tables_linux_test.go b/client/internal/routemanager/systemops/rt_tables_linux_test.go
new file mode 100644
index 000000000..bc9cca8b1
--- /dev/null
+++ b/client/internal/routemanager/systemops/rt_tables_linux_test.go
@@ -0,0 +1,69 @@
+//go:build linux && !android
+
+package systemops
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEntryExists(t *testing.T) {
+ tempDir := t.TempDir()
+ tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir)
+
+ content := []string{
+ "1000 reserved",
+ fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName),
+ "9999 other_table",
+ }
+ require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644))
+
+ file, err := os.Open(tempFilePath)
+ require.NoError(t, err)
+ defer func() {
+ assert.NoError(t, file.Close())
+ }()
+
+ tests := []struct {
+ name string
+ id int
+ shouldExist bool
+ err error
+ }{
+ {
+ name: "ExistsWithNetbirdPrefix",
+ id: 7120,
+ shouldExist: true,
+ err: nil,
+ },
+ {
+ name: "ExistsWithDifferentName",
+ id: 1000,
+ shouldExist: true,
+ err: ErrTableIDExists,
+ },
+ {
+ name: "DoesNotExist",
+ id: 1234,
+ shouldExist: false,
+ err: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ exists, err := entryExists(file, tc.id)
+ if tc.err != nil {
+ assert.ErrorIs(t, err, tc.err)
+ } else {
+ assert.NoError(t, err)
+ }
+ assert.Equal(t, tc.shouldExist, exists)
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
new file mode 100644
index 000000000..d45028c19
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
@@ -0,0 +1,191 @@
+//go:build (darwin || dragonfly || freebsd || netbsd || openbsd) && privileged
+
+package systemops
+
+import (
+ "fmt"
+ "net"
+ "net/netip"
+ "os/exec"
+ "regexp"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func init() {
+ testCases = append(testCases, []testCase{
+ {
+ name: "To more specific route without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
+ },
+ }...)
+}
+
+func TestConcurrentRoutes(t *testing.T) {
+ baseIP := netip.MustParseAddr("192.0.2.0")
+
+ var intf *net.Interface
+ var nexthop Nexthop
+
+ _, intf = setupDummyInterface(t)
+ nexthop = Nexthop{netip.Addr{}, intf}
+
+ r := New(nil, nil)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 1024; i++ {
+ wg.Add(1)
+ go func(ip netip.Addr) {
+ defer wg.Done()
+ prefix := netip.PrefixFrom(ip, 32)
+ if err := r.addToRouteTable(prefix, nexthop); err != nil {
+ t.Errorf("Failed to add route for %s: %v", prefix, err)
+ }
+ }(baseIP)
+ baseIP = baseIP.Next()
+ }
+
+ wg.Wait()
+
+ baseIP = netip.MustParseAddr("192.0.2.0")
+
+ for i := 0; i < 1024; i++ {
+ wg.Add(1)
+ go func(ip netip.Addr) {
+ defer wg.Done()
+ prefix := netip.PrefixFrom(ip, 32)
+ if err := r.removeFromRouteTable(prefix, nexthop); err != nil {
+ t.Errorf("Failed to remove route for %s: %v", prefix, err)
+ }
+ }(baseIP)
+ baseIP = baseIP.Next()
+ }
+
+ wg.Wait()
+}
+
+func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
+ t.Helper()
+
+ if runtime.GOOS == "darwin" {
+ err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run()
+ require.NoError(t, err, "Failed to create loopback alias")
+
+ t.Cleanup(func() {
+ err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run()
+ assert.NoError(t, err, "Failed to remove loopback alias")
+ })
+
+ return intf
+ }
+
+ prefix, err := netip.ParsePrefix(ipAddressCIDR)
+ require.NoError(t, err, "Failed to parse prefix")
+
+ netIntf, err := net.InterfaceByName(intf)
+ require.NoError(t, err, "Failed to get interface by name")
+
+ nexthop := Nexthop{netip.Addr{}, netIntf}
+
+ r := New(nil, nil)
+ err = r.addToRouteTable(prefix, nexthop)
+ require.NoError(t, err, "Failed to add route to table")
+
+ t.Cleanup(func() {
+ err := r.removeFromRouteTable(prefix, nexthop)
+ assert.NoError(t, err, "Failed to remove route from table")
+ })
+
+ return intf
+}
+
+func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) {
+ t.Helper()
+
+ var originalNexthop net.IP
+ if dstCIDR == "0.0.0.0/0" {
+ var err error
+ originalNexthop, err = fetchOriginalGateway()
+ if err != nil {
+ t.Logf("Failed to fetch original gateway: %v", err)
+ }
+
+ if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil {
+ t.Logf("Failed to delete route: %v, output: %s", err, output)
+ }
+ }
+
+ t.Cleanup(func() {
+ if originalNexthop != nil {
+ err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run()
+ assert.NoError(t, err, "Failed to restore original route")
+ }
+ })
+
+ err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run()
+ require.NoError(t, err, "Failed to add route")
+
+ t.Cleanup(func() {
+ err := exec.Command("route", "delete", "-net", dstCIDR).Run()
+ assert.NoError(t, err, "Failed to remove route")
+ })
+}
+
+func fetchOriginalGateway() (net.IP, error) {
+ output, err := exec.Command("route", "-n", "get", "default").CombinedOutput()
+ if err != nil {
+ return nil, err
+ }
+
+ matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output))
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("gateway not found")
+ }
+
+ return net.ParseIP(matches[1]), nil
+}
+
+// setupDummyInterface creates a dummy tun interface for FreeBSD route testing
+func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) {
+ t.Helper()
+
+ if runtime.GOOS == "darwin" {
+ return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"}
+ }
+
+ output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput()
+ require.NoError(t, err, "Failed to create tun interface: %s", string(output))
+
+ tunName := strings.TrimSpace(string(output))
+
+ output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput()
+ require.NoError(t, err, "Failed to configure tun interface: %s", string(output))
+
+ intf, err := net.InterfaceByName(tunName)
+ require.NoError(t, err, "Failed to get interface by name")
+
+ t.Cleanup(func() {
+ if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil {
+ t.Logf("Failed to destroy tun interface %s: %v", tunName, err)
+ }
+ })
+
+ return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf
+}
+
+func setupDummyInterfacesAndRoutes(t *testing.T) {
+ t.Helper()
+
+ defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
+ addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy)
+
+ otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
+ addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy)
+}
diff --git a/client/internal/routemanager/systemops/systemops_bsd_test.go b/client/internal/routemanager/systemops/systemops_bsd_test.go
index ec4fc406e..9650945b3 100644
--- a/client/internal/routemanager/systemops/systemops_bsd_test.go
+++ b/client/internal/routemanager/systemops/systemops_bsd_test.go
@@ -3,79 +3,24 @@
package systemops
import (
- "fmt"
- "net"
- "net/netip"
- "os/exec"
- "regexp"
- "runtime"
- "strings"
- "sync"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
"golang.org/x/net/route"
)
+// Interface names used by the shared routing test fixtures. Kept untagged (no
+// privileged build tag) so the non-privileged test files in this package compile.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedVPNint = "utun100"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedExternalInt = "lo0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedInternalInt = "lo0"
-func init() {
- testCases = append(testCases, []testCase{
- {
- name: "To more specific route without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
- },
- }...)
-}
-
-func TestConcurrentRoutes(t *testing.T) {
- baseIP := netip.MustParseAddr("192.0.2.0")
-
- var intf *net.Interface
- var nexthop Nexthop
-
- _, intf = setupDummyInterface(t)
- nexthop = Nexthop{netip.Addr{}, intf}
-
- r := New(nil, nil)
-
- var wg sync.WaitGroup
- for i := 0; i < 1024; i++ {
- wg.Add(1)
- go func(ip netip.Addr) {
- defer wg.Done()
- prefix := netip.PrefixFrom(ip, 32)
- if err := r.addToRouteTable(prefix, nexthop); err != nil {
- t.Errorf("Failed to add route for %s: %v", prefix, err)
- }
- }(baseIP)
- baseIP = baseIP.Next()
- }
-
- wg.Wait()
-
- baseIP = netip.MustParseAddr("192.0.2.0")
-
- for i := 0; i < 1024; i++ {
- wg.Add(1)
- go func(ip netip.Addr) {
- defer wg.Done()
- prefix := netip.PrefixFrom(ip, 32)
- if err := r.removeFromRouteTable(prefix, nexthop); err != nil {
- t.Errorf("Failed to remove route for %s: %v", prefix, err)
- }
- }(baseIP)
- baseIP = baseIP.Next()
- }
-
- wg.Wait()
-}
-
func TestBits(t *testing.T) {
tests := []struct {
name string
@@ -122,122 +67,3 @@ func TestBits(t *testing.T) {
})
}
}
-
-func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
- t.Helper()
-
- if runtime.GOOS == "darwin" {
- err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run()
- require.NoError(t, err, "Failed to create loopback alias")
-
- t.Cleanup(func() {
- err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run()
- assert.NoError(t, err, "Failed to remove loopback alias")
- })
-
- return intf
- }
-
- prefix, err := netip.ParsePrefix(ipAddressCIDR)
- require.NoError(t, err, "Failed to parse prefix")
-
- netIntf, err := net.InterfaceByName(intf)
- require.NoError(t, err, "Failed to get interface by name")
-
- nexthop := Nexthop{netip.Addr{}, netIntf}
-
- r := New(nil, nil)
- err = r.addToRouteTable(prefix, nexthop)
- require.NoError(t, err, "Failed to add route to table")
-
- t.Cleanup(func() {
- err := r.removeFromRouteTable(prefix, nexthop)
- assert.NoError(t, err, "Failed to remove route from table")
- })
-
- return intf
-}
-
-func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) {
- t.Helper()
-
- var originalNexthop net.IP
- if dstCIDR == "0.0.0.0/0" {
- var err error
- originalNexthop, err = fetchOriginalGateway()
- if err != nil {
- t.Logf("Failed to fetch original gateway: %v", err)
- }
-
- if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil {
- t.Logf("Failed to delete route: %v, output: %s", err, output)
- }
- }
-
- t.Cleanup(func() {
- if originalNexthop != nil {
- err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run()
- assert.NoError(t, err, "Failed to restore original route")
- }
- })
-
- err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run()
- require.NoError(t, err, "Failed to add route")
-
- t.Cleanup(func() {
- err := exec.Command("route", "delete", "-net", dstCIDR).Run()
- assert.NoError(t, err, "Failed to remove route")
- })
-}
-
-func fetchOriginalGateway() (net.IP, error) {
- output, err := exec.Command("route", "-n", "get", "default").CombinedOutput()
- if err != nil {
- return nil, err
- }
-
- matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output))
- if len(matches) == 0 {
- return nil, fmt.Errorf("gateway not found")
- }
-
- return net.ParseIP(matches[1]), nil
-}
-
-// setupDummyInterface creates a dummy tun interface for FreeBSD route testing
-func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) {
- t.Helper()
-
- if runtime.GOOS == "darwin" {
- return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"}
- }
-
- output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput()
- require.NoError(t, err, "Failed to create tun interface: %s", string(output))
-
- tunName := strings.TrimSpace(string(output))
-
- output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput()
- require.NoError(t, err, "Failed to configure tun interface: %s", string(output))
-
- intf, err := net.InterfaceByName(tunName)
- require.NoError(t, err, "Failed to get interface by name")
-
- t.Cleanup(func() {
- if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil {
- t.Logf("Failed to destroy tun interface %s: %v", tunName, err)
- }
- })
-
- return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf
-}
-
-func setupDummyInterfacesAndRoutes(t *testing.T) {
- t.Helper()
-
- defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
- addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy)
-
- otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
- addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy)
-}
diff --git a/client/internal/routemanager/systemops/systemops_dialer_test.go b/client/internal/routemanager/systemops/systemops_dialer_test.go
new file mode 100644
index 000000000..f00f9099c
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_dialer_test.go
@@ -0,0 +1,17 @@
+//go:build !android && !ios
+
+package systemops
+
+import (
+ "context"
+ "net"
+)
+
+// dialer is shared by the per-platform routing test cases. Kept untagged (no
+// privileged build tag) so the non-privileged test files compile on every platform.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
+type dialer interface {
+ Dial(network, address string) (net.Conn, error)
+ DialContext(ctx context.Context, network, address string) (net.Conn, error)
+}
diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go
index 5695c40c3..c4f739c30 100644
--- a/client/internal/routemanager/systemops/systemops_generic_test.go
+++ b/client/internal/routemanager/systemops/systemops_generic_test.go
@@ -1,4 +1,4 @@
-//go:build !android && !ios
+//go:build !android && !ios && privileged
package systemops
@@ -26,11 +26,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
-type dialer interface {
- Dial(network, address string) (net.Conn, error)
- DialContext(ctx context.Context, network, address string) (net.Conn, error)
-}
-
func TestAddVPNRoute(t *testing.T) {
testCases := []struct {
name string
@@ -515,125 +510,3 @@ func setupTestEnv(t *testing.T) {
// unique route in vpn table
setupRouteAndCleanup(t, r, netip.MustParsePrefix("172.16.0.0/12"), intf)
}
-
-func TestIsVpnRoute(t *testing.T) {
- tests := []struct {
- name string
- addr string
- vpnRoutes []string
- localRoutes []string
- expectedVpn bool
- expectedPrefix netip.Prefix
- }{
- {
- name: "Match in VPN routes",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Match in local routes",
- addr: "10.1.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"),
- },
- {
- name: "No match",
- addr: "172.16.0.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.Prefix{},
- },
- {
- name: "Default route ignored",
- addr: "192.168.1.1",
- vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Default route matches but ignored",
- addr: "172.16.1.1",
- vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.Prefix{},
- },
- {
- name: "Longest prefix match local",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.0.0/16"},
- localRoutes: []string{"192.168.1.0/24"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Longest prefix match local multiple",
- addr: "192.168.0.1",
- vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
- localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"),
- },
- {
- name: "Longest prefix match vpn",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"192.168.0.0/16"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Longest prefix match vpn multiple",
- addr: "192.168.0.1",
- vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
- localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"),
- },
- {
- name: "Duplicate prefix in both",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"192.168.1.0/24"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- addr, err := netip.ParseAddr(tt.addr)
- if err != nil {
- t.Fatalf("Failed to parse address %s: %v", tt.addr, err)
- }
-
- var vpnRoutes, localRoutes []netip.Prefix
- for _, route := range tt.vpnRoutes {
- prefix, err := netip.ParsePrefix(route)
- if err != nil {
- t.Fatalf("Failed to parse VPN route %s: %v", route, err)
- }
- vpnRoutes = append(vpnRoutes, prefix)
- }
-
- for _, route := range tt.localRoutes {
- prefix, err := netip.ParsePrefix(route)
- if err != nil {
- t.Fatalf("Failed to parse local route %s: %v", route, err)
- }
- localRoutes = append(localRoutes, prefix)
- }
-
- isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes)
- assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value")
- assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix")
- })
- }
-}
diff --git a/client/internal/routemanager/systemops/systemops_isvpnroute_test.go b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go
new file mode 100644
index 000000000..677fe1287
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go
@@ -0,0 +1,132 @@
+//go:build !android && !ios
+
+package systemops
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsVpnRoute(t *testing.T) {
+ tests := []struct {
+ name string
+ addr string
+ vpnRoutes []string
+ localRoutes []string
+ expectedVpn bool
+ expectedPrefix netip.Prefix
+ }{
+ {
+ name: "Match in VPN routes",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Match in local routes",
+ addr: "10.1.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"),
+ },
+ {
+ name: "No match",
+ addr: "172.16.0.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.Prefix{},
+ },
+ {
+ name: "Default route ignored",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Default route matches but ignored",
+ addr: "172.16.1.1",
+ vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.Prefix{},
+ },
+ {
+ name: "Longest prefix match local",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.0.0/16"},
+ localRoutes: []string{"192.168.1.0/24"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Longest prefix match local multiple",
+ addr: "192.168.0.1",
+ vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
+ localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"),
+ },
+ {
+ name: "Longest prefix match vpn",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"192.168.0.0/16"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Longest prefix match vpn multiple",
+ addr: "192.168.0.1",
+ vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
+ localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"),
+ },
+ {
+ name: "Duplicate prefix in both",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"192.168.1.0/24"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ addr, err := netip.ParseAddr(tt.addr)
+ if err != nil {
+ t.Fatalf("Failed to parse address %s: %v", tt.addr, err)
+ }
+
+ var vpnRoutes, localRoutes []netip.Prefix
+ for _, route := range tt.vpnRoutes {
+ prefix, err := netip.ParsePrefix(route)
+ if err != nil {
+ t.Fatalf("Failed to parse VPN route %s: %v", route, err)
+ }
+ vpnRoutes = append(vpnRoutes, prefix)
+ }
+
+ for _, route := range tt.localRoutes {
+ prefix, err := netip.ParsePrefix(route)
+ if err != nil {
+ t.Fatalf("Failed to parse local route %s: %v", route, err)
+ }
+ localRoutes = append(localRoutes, prefix)
+ }
+
+ isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes)
+ assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value")
+ assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix")
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_linux_test.go b/client/internal/routemanager/systemops/systemops_linux_test.go
index 880296d91..06c528ce5 100644
--- a/client/internal/routemanager/systemops/systemops_linux_test.go
+++ b/client/internal/routemanager/systemops/systemops_linux_test.go
@@ -1,13 +1,10 @@
-//go:build !android
+//go:build linux && !android && privileged
package systemops
import (
"errors"
- "fmt"
"net"
- "os"
- "strings"
"syscall"
"testing"
@@ -18,10 +15,6 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager/vars"
)
-var expectedVPNint = "wgtest0"
-var expectedExternalInt = "dummyext0"
-var expectedInternalInt = "dummyint0"
-
func init() {
testCases = append(testCases, []testCase{
{
@@ -33,62 +26,6 @@ func init() {
}...)
}
-func TestEntryExists(t *testing.T) {
- tempDir := t.TempDir()
- tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir)
-
- content := []string{
- "1000 reserved",
- fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName),
- "9999 other_table",
- }
- require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644))
-
- file, err := os.Open(tempFilePath)
- require.NoError(t, err)
- defer func() {
- assert.NoError(t, file.Close())
- }()
-
- tests := []struct {
- name string
- id int
- shouldExist bool
- err error
- }{
- {
- name: "ExistsWithNetbirdPrefix",
- id: 7120,
- shouldExist: true,
- err: nil,
- },
- {
- name: "ExistsWithDifferentName",
- id: 1000,
- shouldExist: true,
- err: ErrTableIDExists,
- },
- {
- name: "DoesNotExist",
- id: 1234,
- shouldExist: false,
- err: nil,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- exists, err := entryExists(file, tc.id)
- if tc.err != nil {
- assert.ErrorIs(t, err, tc.err)
- } else {
- assert.NoError(t, err)
- }
- assert.Equal(t, tc.shouldExist, exists)
- })
- }
-}
-
func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) string {
t.Helper()
diff --git a/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
new file mode 100644
index 000000000..9be267980
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
@@ -0,0 +1,15 @@
+//go:build linux && !android
+
+package systemops
+
+// Interface names used by the shared routing test fixtures. Kept untagged (no
+// privileged build tag) so the non-privileged test files in this package compile.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedVPNint = "wgtest0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedExternalInt = "dummyext0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedInternalInt = "dummyint0"
diff --git a/client/internal/routemanager/systemops/systemops_routing_data_test.go b/client/internal/routemanager/systemops/systemops_routing_data_test.go
new file mode 100644
index 000000000..16f17f5b9
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_routing_data_test.go
@@ -0,0 +1,83 @@
+//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+
+package systemops
+
+import (
+ "net"
+
+ nbnet "github.com/netbirdio/netbird/client/net"
+)
+
+// Shared, non-privileged routing test fixtures. The privileged TestRouting (and its
+// per-platform init() appenders) consume these; they live here so the unprivileged
+// BSD/darwin test files compile without the privileged build tag.
+
+type PacketExpectation struct {
+ SrcIP net.IP
+ DstIP net.IP
+ SrcPort int
+ DstPort int
+ UDP bool
+ TCP bool
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+type testCase struct {
+ name string
+ expectedInterface string
+ dialer dialer
+ expectedPacket PacketExpectation
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var testCases = []testCase{
+ {
+ name: "To external host without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
+ },
+ {
+ name: "To external host with custom dialer via physical interface",
+ expectedInterface: expectedExternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
+ },
+
+ {
+ name: "To duplicate internal route with custom dialer via physical interface",
+ expectedInterface: expectedInternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
+ },
+ {
+ name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
+ expectedInterface: expectedInternalInt,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
+ },
+
+ {
+ name: "To unique vpn route with custom dialer via physical interface",
+ expectedInterface: expectedExternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53),
+ },
+ {
+ name: "To unique vpn route without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
+ },
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation {
+ return PacketExpectation{
+ SrcIP: net.ParseIP(srcIP),
+ DstIP: net.ParseIP(dstIP),
+ SrcPort: srcPort,
+ DstPort: dstPort,
+ UDP: true,
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_unix_test.go b/client/internal/routemanager/systemops/systemops_unix_test.go
index 959c697e4..efb0ae4e4 100644
--- a/client/internal/routemanager/systemops/systemops_unix_test.go
+++ b/client/internal/routemanager/systemops/systemops_unix_test.go
@@ -1,4 +1,4 @@
-//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly) && privileged
package systemops
@@ -20,63 +20,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
-type PacketExpectation struct {
- SrcIP net.IP
- DstIP net.IP
- SrcPort int
- DstPort int
- UDP bool
- TCP bool
-}
-
-type testCase struct {
- name string
- expectedInterface string
- dialer dialer
- expectedPacket PacketExpectation
-}
-
-var testCases = []testCase{
- {
- name: "To external host without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
- },
- {
- name: "To external host with custom dialer via physical interface",
- expectedInterface: expectedExternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
- },
-
- {
- name: "To duplicate internal route with custom dialer via physical interface",
- expectedInterface: expectedInternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
- },
- {
- name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
- expectedInterface: expectedInternalInt,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
- },
-
- {
- name: "To unique vpn route with custom dialer via physical interface",
- expectedInterface: expectedExternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53),
- },
- {
- name: "To unique vpn route without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
- },
-}
-
func TestRouting(t *testing.T) {
nbnet.Init()
for _, tc := range testCases {
@@ -102,16 +45,6 @@ func TestRouting(t *testing.T) {
}
}
-func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation {
- return PacketExpectation{
- SrcIP: net.ParseIP(srcIP),
- DstIP: net.ParseIP(dstIP),
- SrcPort: srcPort,
- DstPort: dstPort,
- UDP: true,
- }
-}
-
func startPacketCapture(t *testing.T, intf, filter string) *pcap.Handle {
t.Helper()
diff --git a/client/internal/routemanager/systemops/systemops_windows_test.go b/client/internal/routemanager/systemops/systemops_windows_test.go
index 3561adec4..77e349bd6 100644
--- a/client/internal/routemanager/systemops/systemops_windows_test.go
+++ b/client/internal/routemanager/systemops/systemops_windows_test.go
@@ -1,3 +1,5 @@
+//go:build windows && privileged
+
package systemops
import (
diff --git a/client/internal/routemanager/systemops/v6route_bsd_test.go b/client/internal/routemanager/systemops/v6route_bsd_test.go
index 98ce29c6d..90e49f54e 100644
--- a/client/internal/routemanager/systemops/v6route_bsd_test.go
+++ b/client/internal/routemanager/systemops/v6route_bsd_test.go
@@ -11,6 +11,8 @@ import (
// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback
// interface so route lookups for global IPv6 prefixes resolve in environments
// without v6 connectivity. If a default already exists it is left alone.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
func ensureIPv6DefaultRoute(t *testing.T) {
t.Helper()
diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go
index 0b17cefff..449d4cbd2 100644
--- a/client/internal/routemanager/systemops/v6route_linux_test.go
+++ b/client/internal/routemanager/systemops/v6route_linux_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package systemops
diff --git a/client/internal/routemanager/systemops/v6route_windows_test.go b/client/internal/routemanager/systemops/v6route_windows_test.go
index f79277b87..2c813a790 100644
--- a/client/internal/routemanager/systemops/v6route_windows_test.go
+++ b/client/internal/routemanager/systemops/v6route_windows_test.go
@@ -8,11 +8,14 @@ import (
"testing"
)
+//nolint:unused // consumed by the privileged-tagged routing tests
const loopbackIfaceWindows = "Loopback Pseudo-Interface 1"
// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback
// interface so route lookups for global IPv6 prefixes resolve in environments
// without v6 connectivity. If a default already exists it is left alone.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
func ensureIPv6DefaultRoute(t *testing.T) {
t.Helper()
diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go
index 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/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/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go
index f9542fc3a..99bd5a392 100644
--- a/client/ios/NetBirdSDK/client.go
+++ b/client/ios/NetBirdSDK/client.go
@@ -13,7 +13,6 @@ import (
"time"
log "github.com/sirupsen/logrus"
- "golang.org/x/exp/maps"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
@@ -161,13 +160,19 @@ 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) {}
@@ -236,6 +241,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
}
@@ -263,7 +271,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)
}
@@ -637,23 +645,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 {
@@ -667,21 +670,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/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 432133999..6cba0c411 100644
--- a/client/ios/NetBirdSDK/login.go
+++ b/client/ios/NetBirdSDK/login.go
@@ -44,10 +44,25 @@ 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
}
@@ -207,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 {
@@ -226,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 != "" {
@@ -240,10 +274,13 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
}
defer authClient.Close()
- // check if we need to generate JWT token
- needsLogin, err := authClient.IsLoginRequired(ctx)
- if err != nil {
- return fmt.Errorf("failed to check login requirement: %v", err)
+ // check if we need to generate JWT token (skipped when the caller already knows)
+ needsLogin := true
+ if !skipLoginCheck {
+ needsLogin, err = authClient.IsLoginRequired(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to check login requirement: %v", err)
+ }
}
jwtToken := ""
diff --git a/client/ios/NetBirdSDK/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/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 488b0186c..d4deeb8ec 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -192,7 +192,7 @@ func (x SystemEvent_Severity) Number() protoreflect.EnumNumber {
// Deprecated: Use SystemEvent_Severity.Descriptor instead.
func (SystemEvent_Severity) EnumDescriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{51, 0}
+ return file_daemon_proto_rawDescGZIP(), []int{53, 0}
}
type SystemEvent_Category int32
@@ -247,7 +247,7 @@ func (x SystemEvent_Category) Number() protoreflect.EnumNumber {
// Deprecated: Use SystemEvent_Category.Descriptor instead.
func (SystemEvent_Category) EnumDescriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{51, 1}
+ return file_daemon_proto_rawDescGZIP(), []int{53, 1}
}
type EmptyRequest struct {
@@ -823,9 +823,15 @@ func (x *WaitSSOLoginResponse) GetEmail() string {
}
type UpRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
- Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
+ Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
+ // async instructs the daemon to start the connection attempt and return
+ // immediately without waiting for the engine to become ready. Status updates
+ // are delivered via the SubscribeStatus stream. When false (the default) the
+ // RPC blocks until the engine is running or gives up, which is the behaviour
+ // needed by the CLI.
+ Async bool `protobuf:"varint,4,opt,name=async,proto3" json:"async,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -874,6 +880,13 @@ func (x *UpRequest) GetUsername() string {
return ""
}
+func (x *UpRequest) GetAsync() bool {
+ if x != nil {
+ return x.Async
+ }
+ return false
+}
+
type UpResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -978,8 +991,12 @@ type StatusResponse struct {
FullStatus *FullStatus `protobuf:"bytes,2,opt,name=fullStatus,proto3" json:"fullStatus,omitempty"`
// NetBird daemon version
DaemonVersion string `protobuf:"bytes,3,opt,name=daemonVersion,proto3" json:"daemonVersion,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // Absolute UTC instant at which the peer's SSO session expires.
+ // Unset when the peer is not SSO-registered or login expiration is disabled.
+ // The UI derives "warning active" from this value and its own clock.
+ SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *StatusResponse) Reset() {
@@ -1033,6 +1050,13 @@ func (x *StatusResponse) GetDaemonVersion() string {
return ""
}
+func (x *StatusResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.SessionExpiresAt
+ }
+ return nil
+}
+
type DownRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -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,18 @@ func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule {
// DebugBundler
type DebugBundleRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"`
- SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
- UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
- LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
- 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"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *DebugBundleRequest) Reset() {
@@ -2810,6 +2850,13 @@ func (x *DebugBundleRequest) GetCliVersion() string {
return ""
}
+func (x *DebugBundleRequest) GetUploadInsecure() bool {
+ if x != nil {
+ return x.UploadInsecure
+ }
+ return false
+}
+
type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -3030,6 +3077,86 @@ func (*SetLogLevelResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{36}
}
+type RegisterUILogRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RegisterUILogRequest) Reset() {
+ *x = RegisterUILogRequest{}
+ mi := &file_daemon_proto_msgTypes[37]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RegisterUILogRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RegisterUILogRequest) ProtoMessage() {}
+
+func (x *RegisterUILogRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[37]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RegisterUILogRequest.ProtoReflect.Descriptor instead.
+func (*RegisterUILogRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{37}
+}
+
+func (x *RegisterUILogRequest) GetPath() string {
+ if x != nil {
+ return x.Path
+ }
+ return ""
+}
+
+type RegisterUILogResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RegisterUILogResponse) Reset() {
+ *x = RegisterUILogResponse{}
+ mi := &file_daemon_proto_msgTypes[38]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RegisterUILogResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RegisterUILogResponse) ProtoMessage() {}
+
+func (x *RegisterUILogResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[38]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RegisterUILogResponse.ProtoReflect.Descriptor instead.
+func (*RegisterUILogResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{38}
+}
+
// State represents a daemon state entry
type State struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -3040,7 +3167,7 @@ type State struct {
func (x *State) Reset() {
*x = State{}
- mi := &file_daemon_proto_msgTypes[37]
+ mi := &file_daemon_proto_msgTypes[39]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3052,7 +3179,7 @@ func (x *State) String() string {
func (*State) ProtoMessage() {}
func (x *State) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[37]
+ mi := &file_daemon_proto_msgTypes[39]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3065,7 +3192,7 @@ func (x *State) ProtoReflect() protoreflect.Message {
// Deprecated: Use State.ProtoReflect.Descriptor instead.
func (*State) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{37}
+ return file_daemon_proto_rawDescGZIP(), []int{39}
}
func (x *State) GetName() string {
@@ -3084,7 +3211,7 @@ type ListStatesRequest struct {
func (x *ListStatesRequest) Reset() {
*x = ListStatesRequest{}
- mi := &file_daemon_proto_msgTypes[38]
+ mi := &file_daemon_proto_msgTypes[40]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3096,7 +3223,7 @@ func (x *ListStatesRequest) String() string {
func (*ListStatesRequest) ProtoMessage() {}
func (x *ListStatesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[38]
+ mi := &file_daemon_proto_msgTypes[40]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3109,7 +3236,7 @@ func (x *ListStatesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListStatesRequest.ProtoReflect.Descriptor instead.
func (*ListStatesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{38}
+ return file_daemon_proto_rawDescGZIP(), []int{40}
}
// ListStatesResponse contains a list of states
@@ -3122,7 +3249,7 @@ type ListStatesResponse struct {
func (x *ListStatesResponse) Reset() {
*x = ListStatesResponse{}
- mi := &file_daemon_proto_msgTypes[39]
+ mi := &file_daemon_proto_msgTypes[41]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3134,7 +3261,7 @@ func (x *ListStatesResponse) String() string {
func (*ListStatesResponse) ProtoMessage() {}
func (x *ListStatesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[39]
+ mi := &file_daemon_proto_msgTypes[41]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3147,7 +3274,7 @@ func (x *ListStatesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListStatesResponse.ProtoReflect.Descriptor instead.
func (*ListStatesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{39}
+ return file_daemon_proto_rawDescGZIP(), []int{41}
}
func (x *ListStatesResponse) GetStates() []*State {
@@ -3168,7 +3295,7 @@ type CleanStateRequest struct {
func (x *CleanStateRequest) Reset() {
*x = CleanStateRequest{}
- mi := &file_daemon_proto_msgTypes[40]
+ mi := &file_daemon_proto_msgTypes[42]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3180,7 +3307,7 @@ func (x *CleanStateRequest) String() string {
func (*CleanStateRequest) ProtoMessage() {}
func (x *CleanStateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[40]
+ mi := &file_daemon_proto_msgTypes[42]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3193,7 +3320,7 @@ func (x *CleanStateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CleanStateRequest.ProtoReflect.Descriptor instead.
func (*CleanStateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{40}
+ return file_daemon_proto_rawDescGZIP(), []int{42}
}
func (x *CleanStateRequest) GetStateName() string {
@@ -3220,7 +3347,7 @@ type CleanStateResponse struct {
func (x *CleanStateResponse) Reset() {
*x = CleanStateResponse{}
- mi := &file_daemon_proto_msgTypes[41]
+ mi := &file_daemon_proto_msgTypes[43]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3232,7 +3359,7 @@ func (x *CleanStateResponse) String() string {
func (*CleanStateResponse) ProtoMessage() {}
func (x *CleanStateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[41]
+ mi := &file_daemon_proto_msgTypes[43]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3245,7 +3372,7 @@ func (x *CleanStateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CleanStateResponse.ProtoReflect.Descriptor instead.
func (*CleanStateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{41}
+ return file_daemon_proto_rawDescGZIP(), []int{43}
}
func (x *CleanStateResponse) GetCleanedStates() int32 {
@@ -3266,7 +3393,7 @@ type DeleteStateRequest struct {
func (x *DeleteStateRequest) Reset() {
*x = DeleteStateRequest{}
- mi := &file_daemon_proto_msgTypes[42]
+ mi := &file_daemon_proto_msgTypes[44]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3278,7 +3405,7 @@ func (x *DeleteStateRequest) String() string {
func (*DeleteStateRequest) ProtoMessage() {}
func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[42]
+ mi := &file_daemon_proto_msgTypes[44]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3291,7 +3418,7 @@ func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteStateRequest.ProtoReflect.Descriptor instead.
func (*DeleteStateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{42}
+ return file_daemon_proto_rawDescGZIP(), []int{44}
}
func (x *DeleteStateRequest) GetStateName() string {
@@ -3318,7 +3445,7 @@ type DeleteStateResponse struct {
func (x *DeleteStateResponse) Reset() {
*x = DeleteStateResponse{}
- mi := &file_daemon_proto_msgTypes[43]
+ mi := &file_daemon_proto_msgTypes[45]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3330,7 +3457,7 @@ func (x *DeleteStateResponse) String() string {
func (*DeleteStateResponse) ProtoMessage() {}
func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[43]
+ mi := &file_daemon_proto_msgTypes[45]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3343,7 +3470,7 @@ func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteStateResponse.ProtoReflect.Descriptor instead.
func (*DeleteStateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{43}
+ return file_daemon_proto_rawDescGZIP(), []int{45}
}
func (x *DeleteStateResponse) GetDeletedStates() int32 {
@@ -3362,7 +3489,7 @@ type SetSyncResponsePersistenceRequest struct {
func (x *SetSyncResponsePersistenceRequest) Reset() {
*x = SetSyncResponsePersistenceRequest{}
- mi := &file_daemon_proto_msgTypes[44]
+ mi := &file_daemon_proto_msgTypes[46]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3374,7 +3501,7 @@ func (x *SetSyncResponsePersistenceRequest) String() string {
func (*SetSyncResponsePersistenceRequest) ProtoMessage() {}
func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[44]
+ mi := &file_daemon_proto_msgTypes[46]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3387,7 +3514,7 @@ func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message
// Deprecated: Use SetSyncResponsePersistenceRequest.ProtoReflect.Descriptor instead.
func (*SetSyncResponsePersistenceRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{44}
+ return file_daemon_proto_rawDescGZIP(), []int{46}
}
func (x *SetSyncResponsePersistenceRequest) GetEnabled() bool {
@@ -3405,7 +3532,7 @@ type SetSyncResponsePersistenceResponse struct {
func (x *SetSyncResponsePersistenceResponse) Reset() {
*x = SetSyncResponsePersistenceResponse{}
- mi := &file_daemon_proto_msgTypes[45]
+ mi := &file_daemon_proto_msgTypes[47]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3417,7 +3544,7 @@ func (x *SetSyncResponsePersistenceResponse) String() string {
func (*SetSyncResponsePersistenceResponse) ProtoMessage() {}
func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[45]
+ mi := &file_daemon_proto_msgTypes[47]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3430,7 +3557,7 @@ func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message
// Deprecated: Use SetSyncResponsePersistenceResponse.ProtoReflect.Descriptor instead.
func (*SetSyncResponsePersistenceResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{45}
+ return file_daemon_proto_rawDescGZIP(), []int{47}
}
type TCPFlags struct {
@@ -3447,7 +3574,7 @@ type TCPFlags struct {
func (x *TCPFlags) Reset() {
*x = TCPFlags{}
- mi := &file_daemon_proto_msgTypes[46]
+ mi := &file_daemon_proto_msgTypes[48]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3459,7 +3586,7 @@ func (x *TCPFlags) String() string {
func (*TCPFlags) ProtoMessage() {}
func (x *TCPFlags) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[46]
+ mi := &file_daemon_proto_msgTypes[48]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3472,7 +3599,7 @@ func (x *TCPFlags) ProtoReflect() protoreflect.Message {
// Deprecated: Use TCPFlags.ProtoReflect.Descriptor instead.
func (*TCPFlags) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{46}
+ return file_daemon_proto_rawDescGZIP(), []int{48}
}
func (x *TCPFlags) GetSyn() bool {
@@ -3534,7 +3661,7 @@ type TracePacketRequest struct {
func (x *TracePacketRequest) Reset() {
*x = TracePacketRequest{}
- mi := &file_daemon_proto_msgTypes[47]
+ mi := &file_daemon_proto_msgTypes[49]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3546,7 +3673,7 @@ func (x *TracePacketRequest) String() string {
func (*TracePacketRequest) ProtoMessage() {}
func (x *TracePacketRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[47]
+ mi := &file_daemon_proto_msgTypes[49]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3559,7 +3686,7 @@ func (x *TracePacketRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use TracePacketRequest.ProtoReflect.Descriptor instead.
func (*TracePacketRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{47}
+ return file_daemon_proto_rawDescGZIP(), []int{49}
}
func (x *TracePacketRequest) GetSourceIp() string {
@@ -3637,7 +3764,7 @@ type TraceStage struct {
func (x *TraceStage) Reset() {
*x = TraceStage{}
- mi := &file_daemon_proto_msgTypes[48]
+ mi := &file_daemon_proto_msgTypes[50]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3649,7 +3776,7 @@ func (x *TraceStage) String() string {
func (*TraceStage) ProtoMessage() {}
func (x *TraceStage) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[48]
+ mi := &file_daemon_proto_msgTypes[50]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3662,7 +3789,7 @@ func (x *TraceStage) ProtoReflect() protoreflect.Message {
// Deprecated: Use TraceStage.ProtoReflect.Descriptor instead.
func (*TraceStage) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{48}
+ return file_daemon_proto_rawDescGZIP(), []int{50}
}
func (x *TraceStage) GetName() string {
@@ -3703,7 +3830,7 @@ type TracePacketResponse struct {
func (x *TracePacketResponse) Reset() {
*x = TracePacketResponse{}
- mi := &file_daemon_proto_msgTypes[49]
+ mi := &file_daemon_proto_msgTypes[51]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3715,7 +3842,7 @@ func (x *TracePacketResponse) String() string {
func (*TracePacketResponse) ProtoMessage() {}
func (x *TracePacketResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[49]
+ mi := &file_daemon_proto_msgTypes[51]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3728,7 +3855,7 @@ func (x *TracePacketResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use TracePacketResponse.ProtoReflect.Descriptor instead.
func (*TracePacketResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{49}
+ return file_daemon_proto_rawDescGZIP(), []int{51}
}
func (x *TracePacketResponse) GetStages() []*TraceStage {
@@ -3753,7 +3880,7 @@ type SubscribeRequest struct {
func (x *SubscribeRequest) Reset() {
*x = SubscribeRequest{}
- mi := &file_daemon_proto_msgTypes[50]
+ mi := &file_daemon_proto_msgTypes[52]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3765,7 +3892,7 @@ func (x *SubscribeRequest) String() string {
func (*SubscribeRequest) ProtoMessage() {}
func (x *SubscribeRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[50]
+ mi := &file_daemon_proto_msgTypes[52]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3778,7 +3905,7 @@ func (x *SubscribeRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead.
func (*SubscribeRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{50}
+ return file_daemon_proto_rawDescGZIP(), []int{52}
}
type SystemEvent struct {
@@ -3796,7 +3923,7 @@ type SystemEvent struct {
func (x *SystemEvent) Reset() {
*x = SystemEvent{}
- mi := &file_daemon_proto_msgTypes[51]
+ mi := &file_daemon_proto_msgTypes[53]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3808,7 +3935,7 @@ func (x *SystemEvent) String() string {
func (*SystemEvent) ProtoMessage() {}
func (x *SystemEvent) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[51]
+ mi := &file_daemon_proto_msgTypes[53]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3821,7 +3948,7 @@ func (x *SystemEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use SystemEvent.ProtoReflect.Descriptor instead.
func (*SystemEvent) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{51}
+ return file_daemon_proto_rawDescGZIP(), []int{53}
}
func (x *SystemEvent) GetId() string {
@@ -3881,7 +4008,7 @@ type GetEventsRequest struct {
func (x *GetEventsRequest) Reset() {
*x = GetEventsRequest{}
- mi := &file_daemon_proto_msgTypes[52]
+ mi := &file_daemon_proto_msgTypes[54]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3893,7 +4020,7 @@ func (x *GetEventsRequest) String() string {
func (*GetEventsRequest) ProtoMessage() {}
func (x *GetEventsRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[52]
+ mi := &file_daemon_proto_msgTypes[54]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3906,7 +4033,7 @@ func (x *GetEventsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetEventsRequest.ProtoReflect.Descriptor instead.
func (*GetEventsRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{52}
+ return file_daemon_proto_rawDescGZIP(), []int{54}
}
type GetEventsResponse struct {
@@ -3918,7 +4045,7 @@ type GetEventsResponse struct {
func (x *GetEventsResponse) Reset() {
*x = GetEventsResponse{}
- mi := &file_daemon_proto_msgTypes[53]
+ mi := &file_daemon_proto_msgTypes[55]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3930,7 +4057,7 @@ func (x *GetEventsResponse) String() string {
func (*GetEventsResponse) ProtoMessage() {}
func (x *GetEventsResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[53]
+ mi := &file_daemon_proto_msgTypes[55]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3943,7 +4070,7 @@ func (x *GetEventsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetEventsResponse.ProtoReflect.Descriptor instead.
func (*GetEventsResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{53}
+ return file_daemon_proto_rawDescGZIP(), []int{55}
}
func (x *GetEventsResponse) GetEvents() []*SystemEvent {
@@ -3965,7 +4092,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 +4104,7 @@ func (x *SwitchProfileRequest) String() string {
func (*SwitchProfileRequest) ProtoMessage() {}
func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[54]
+ mi := &file_daemon_proto_msgTypes[56]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3990,7 +4117,7 @@ func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SwitchProfileRequest.ProtoReflect.Descriptor instead.
func (*SwitchProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{54}
+ return file_daemon_proto_rawDescGZIP(), []int{56}
}
func (x *SwitchProfileRequest) GetProfileName() string {
@@ -4019,7 +4146,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 +4158,7 @@ func (x *SwitchProfileResponse) String() string {
func (*SwitchProfileResponse) ProtoMessage() {}
func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[55]
+ mi := &file_daemon_proto_msgTypes[57]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4044,7 +4171,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 +4227,7 @@ type SetConfigRequest struct {
func (x *SetConfigRequest) Reset() {
*x = SetConfigRequest{}
- mi := &file_daemon_proto_msgTypes[56]
+ mi := &file_daemon_proto_msgTypes[58]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4112,7 +4239,7 @@ func (x *SetConfigRequest) String() string {
func (*SetConfigRequest) ProtoMessage() {}
func (x *SetConfigRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[56]
+ mi := &file_daemon_proto_msgTypes[58]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4125,7 +4252,7 @@ func (x *SetConfigRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetConfigRequest.ProtoReflect.Descriptor instead.
func (*SetConfigRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{56}
+ return file_daemon_proto_rawDescGZIP(), []int{58}
}
func (x *SetConfigRequest) GetUsername() string {
@@ -4381,7 +4508,7 @@ type SetConfigResponse struct {
func (x *SetConfigResponse) Reset() {
*x = SetConfigResponse{}
- mi := &file_daemon_proto_msgTypes[57]
+ mi := &file_daemon_proto_msgTypes[59]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4393,7 +4520,7 @@ func (x *SetConfigResponse) String() string {
func (*SetConfigResponse) ProtoMessage() {}
func (x *SetConfigResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[57]
+ mi := &file_daemon_proto_msgTypes[59]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4406,7 +4533,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 +4548,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 +4560,7 @@ func (x *AddProfileRequest) String() string {
func (*AddProfileRequest) ProtoMessage() {}
func (x *AddProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[58]
+ mi := &file_daemon_proto_msgTypes[60]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4446,7 +4573,7 @@ func (x *AddProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use AddProfileRequest.ProtoReflect.Descriptor instead.
func (*AddProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{58}
+ return file_daemon_proto_rawDescGZIP(), []int{60}
}
func (x *AddProfileRequest) GetUsername() string {
@@ -4474,7 +4601,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 +4613,7 @@ func (x *AddProfileResponse) String() string {
func (*AddProfileResponse) ProtoMessage() {}
func (x *AddProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[59]
+ mi := &file_daemon_proto_msgTypes[61]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4499,7 +4626,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 +4649,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 +4661,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 +4674,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 +4708,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 +4720,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 +4733,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 +4755,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 +4767,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 +4780,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead.
func (*RemoveProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{62}
+ return file_daemon_proto_rawDescGZIP(), []int{64}
}
func (x *RemoveProfileRequest) GetUsername() string {
@@ -4681,7 +4808,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 +4820,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 +4833,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 +4852,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 +4864,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 +4877,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead.
func (*ListProfilesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{64}
+ return file_daemon_proto_rawDescGZIP(), []int{66}
}
func (x *ListProfilesRequest) GetUsername() string {
@@ -4769,7 +4896,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 +4908,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 +4921,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead.
func (*ListProfilesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{65}
+ return file_daemon_proto_rawDescGZIP(), []int{67}
}
func (x *ListProfilesResponse) GetProfiles() []*Profile {
@@ -4815,7 +4942,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 +4954,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 +4967,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message {
// Deprecated: Use Profile.ProtoReflect.Descriptor instead.
func (*Profile) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{66}
+ return file_daemon_proto_rawDescGZIP(), []int{68}
}
func (x *Profile) GetName() string {
@@ -4872,7 +4999,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 +5011,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 +5024,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 +5038,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 +5050,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 +5063,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead.
func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{68}
+ return file_daemon_proto_rawDescGZIP(), []int{70}
}
func (x *GetActiveProfileResponse) GetProfileName() string {
@@ -4970,7 +5097,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 +5109,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 +5122,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead.
func (*LogoutRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{69}
+ return file_daemon_proto_rawDescGZIP(), []int{71}
}
func (x *LogoutRequest) GetProfileName() string {
@@ -5020,7 +5147,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 +5159,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 +5172,79 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead.
func (*LogoutResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{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 +5255,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 +5267,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 +5280,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead.
func (*GetFeaturesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{71}
+ return file_daemon_proto_rawDescGZIP(), []int{75}
}
type GetFeaturesResponse struct {
@@ -5089,13 +5288,19 @@ type GetFeaturesResponse struct {
DisableProfiles bool `protobuf:"varint,1,opt,name=disable_profiles,json=disableProfiles,proto3" json:"disable_profiles,omitempty"`
DisableUpdateSettings bool `protobuf:"varint,2,opt,name=disable_update_settings,json=disableUpdateSettings,proto3" json:"disable_update_settings,omitempty"`
DisableNetworks bool `protobuf:"varint,3,opt,name=disable_networks,json=disableNetworks,proto3" json:"disable_networks,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // disableAdvancedView gates the upcoming UI revision's advanced
+ // section. Tristate: unset = no MDM directive, the UI applies its
+ // own default; true = MDM enforces disable; false = MDM enforces
+ // enable. Sourced exclusively from the MDM policy — no CLI /
+ // config flag backs this value.
+ DisableAdvancedView *bool `protobuf:"varint,4,opt,name=disable_advanced_view,json=disableAdvancedView,proto3,oneof" json:"disable_advanced_view,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *GetFeaturesResponse) Reset() {
*x = GetFeaturesResponse{}
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5107,7 +5312,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 +5325,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead.
func (*GetFeaturesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{72}
+ return file_daemon_proto_rawDescGZIP(), []int{76}
}
func (x *GetFeaturesResponse) GetDisableProfiles() bool {
@@ -5144,6 +5349,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 +5370,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 +5382,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 +5395,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 +5413,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 +5425,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 +5438,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead.
func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{74}
+ return file_daemon_proto_rawDescGZIP(), []int{78}
}
type TriggerUpdateResponse struct {
@@ -5239,7 +5451,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 +5463,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 +5476,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead.
func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{75}
+ return file_daemon_proto_rawDescGZIP(), []int{79}
}
func (x *TriggerUpdateResponse) GetSuccess() bool {
@@ -5292,7 +5504,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 +5516,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 +5529,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{76}
+ return file_daemon_proto_rawDescGZIP(), []int{80}
}
func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string {
@@ -5344,7 +5556,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 +5568,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 +5581,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{77}
+ return file_daemon_proto_rawDescGZIP(), []int{81}
}
func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte {
@@ -5411,7 +5623,7 @@ type RequestJWTAuthRequest struct {
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 +5635,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 +5648,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{78}
+ return file_daemon_proto_rawDescGZIP(), []int{82}
}
func (x *RequestJWTAuthRequest) GetHint() string {
@@ -5469,7 +5681,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 +5693,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 +5706,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{79}
+ return file_daemon_proto_rawDescGZIP(), []int{83}
}
func (x *RequestJWTAuthResponse) GetVerificationURI() string {
@@ -5559,7 +5771,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 +5783,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 +5796,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{80}
+ return file_daemon_proto_rawDescGZIP(), []int{84}
}
func (x *WaitJWTTokenRequest) GetDeviceCode() string {
@@ -5616,7 +5828,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 +5840,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 +5853,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{81}
+ return file_daemon_proto_rawDescGZIP(), []int{85}
}
func (x *WaitJWTTokenResponse) GetToken() string {
@@ -5665,6 +5877,318 @@ func (x *WaitJWTTokenResponse) GetExpiresIn() int64 {
return 0
}
+// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow.
+type RequestExtendAuthSessionRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Optional OIDC login_hint (typically the user's email) to pre-fill the
+ // IdP login form.
+ Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RequestExtendAuthSessionRequest) Reset() {
+ *x = RequestExtendAuthSessionRequest{}
+ mi := &file_daemon_proto_msgTypes[86]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RequestExtendAuthSessionRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RequestExtendAuthSessionRequest) ProtoMessage() {}
+
+func (x *RequestExtendAuthSessionRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[86]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RequestExtendAuthSessionRequest.ProtoReflect.Descriptor instead.
+func (*RequestExtendAuthSessionRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{86}
+}
+
+func (x *RequestExtendAuthSessionRequest) GetHint() string {
+ if x != nil && x.Hint != nil {
+ return *x.Hint
+ }
+ return ""
+}
+
+// RequestExtendAuthSessionResponse carries the verification URI the UI
+// should open in a browser. The daemon retains the flow state and resolves
+// it via WaitExtendAuthSession.
+type RequestExtendAuthSessionResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // verification URI for the user to open in the browser
+ VerificationURI string `protobuf:"bytes,1,opt,name=verificationURI,proto3" json:"verificationURI,omitempty"`
+ // complete verification URI (with embedded user code)
+ VerificationURIComplete string `protobuf:"bytes,2,opt,name=verificationURIComplete,proto3" json:"verificationURIComplete,omitempty"`
+ // user code to enter on verification URI (for device-code flows)
+ UserCode string `protobuf:"bytes,3,opt,name=userCode,proto3" json:"userCode,omitempty"`
+ // device code for matching the WaitExtendAuthSession call to this flow
+ DeviceCode string `protobuf:"bytes,4,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"`
+ // expiration time in seconds for the device code / PKCE flow
+ ExpiresIn int64 `protobuf:"varint,5,opt,name=expiresIn,proto3" json:"expiresIn,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RequestExtendAuthSessionResponse) Reset() {
+ *x = RequestExtendAuthSessionResponse{}
+ mi := &file_daemon_proto_msgTypes[87]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RequestExtendAuthSessionResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RequestExtendAuthSessionResponse) ProtoMessage() {}
+
+func (x *RequestExtendAuthSessionResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[87]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RequestExtendAuthSessionResponse.ProtoReflect.Descriptor instead.
+func (*RequestExtendAuthSessionResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{87}
+}
+
+func (x *RequestExtendAuthSessionResponse) GetVerificationURI() string {
+ if x != nil {
+ return x.VerificationURI
+ }
+ return ""
+}
+
+func (x *RequestExtendAuthSessionResponse) GetVerificationURIComplete() string {
+ if x != nil {
+ return x.VerificationURIComplete
+ }
+ return ""
+}
+
+func (x *RequestExtendAuthSessionResponse) GetUserCode() string {
+ if x != nil {
+ return x.UserCode
+ }
+ return ""
+}
+
+func (x *RequestExtendAuthSessionResponse) GetDeviceCode() string {
+ if x != nil {
+ return x.DeviceCode
+ }
+ return ""
+}
+
+func (x *RequestExtendAuthSessionResponse) GetExpiresIn() int64 {
+ if x != nil {
+ return x.ExpiresIn
+ }
+ return 0
+}
+
+// WaitExtendAuthSessionRequest is sent by the UI after it opens the
+// verification URI. The daemon blocks on this call until the user
+// completes (or aborts) the SSO step.
+type WaitExtendAuthSessionRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // device code returned by RequestExtendAuthSession
+ DeviceCode string `protobuf:"bytes,1,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"`
+ // user code for verification
+ UserCode string `protobuf:"bytes,2,opt,name=userCode,proto3" json:"userCode,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *WaitExtendAuthSessionRequest) Reset() {
+ *x = WaitExtendAuthSessionRequest{}
+ mi := &file_daemon_proto_msgTypes[88]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *WaitExtendAuthSessionRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*WaitExtendAuthSessionRequest) ProtoMessage() {}
+
+func (x *WaitExtendAuthSessionRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[88]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use WaitExtendAuthSessionRequest.ProtoReflect.Descriptor instead.
+func (*WaitExtendAuthSessionRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{88}
+}
+
+func (x *WaitExtendAuthSessionRequest) GetDeviceCode() string {
+ if x != nil {
+ return x.DeviceCode
+ }
+ return ""
+}
+
+func (x *WaitExtendAuthSessionRequest) GetUserCode() string {
+ if x != nil {
+ return x.UserCode
+ }
+ return ""
+}
+
+// WaitExtendAuthSessionResponse carries the refreshed deadline returned
+// by the management server. Unset when the management server reports the
+// peer is not eligible for session extension.
+type WaitExtendAuthSessionResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *WaitExtendAuthSessionResponse) Reset() {
+ *x = WaitExtendAuthSessionResponse{}
+ mi := &file_daemon_proto_msgTypes[89]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *WaitExtendAuthSessionResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*WaitExtendAuthSessionResponse) ProtoMessage() {}
+
+func (x *WaitExtendAuthSessionResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[89]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use WaitExtendAuthSessionResponse.ProtoReflect.Descriptor instead.
+func (*WaitExtendAuthSessionResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{89}
+}
+
+func (x *WaitExtendAuthSessionResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.SessionExpiresAt
+ }
+ return nil
+}
+
+// DismissSessionWarningRequest is sent by the UI when the user clicks
+// "Dismiss" on the T-WarningLead notification.
+type DismissSessionWarningRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DismissSessionWarningRequest) Reset() {
+ *x = DismissSessionWarningRequest{}
+ mi := &file_daemon_proto_msgTypes[90]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DismissSessionWarningRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DismissSessionWarningRequest) ProtoMessage() {}
+
+func (x *DismissSessionWarningRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[90]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DismissSessionWarningRequest.ProtoReflect.Descriptor instead.
+func (*DismissSessionWarningRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{90}
+}
+
+// DismissSessionWarningResponse acknowledges the dismissal. Carries no
+// payload — the daemon's only obligation is to silence the upcoming
+// T-FinalWarningLead fallback for the current deadline.
+type DismissSessionWarningResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DismissSessionWarningResponse) Reset() {
+ *x = DismissSessionWarningResponse{}
+ mi := &file_daemon_proto_msgTypes[91]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DismissSessionWarningResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DismissSessionWarningResponse) ProtoMessage() {}
+
+func (x *DismissSessionWarningResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[91]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DismissSessionWarningResponse.ProtoReflect.Descriptor instead.
+func (*DismissSessionWarningResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{91}
+}
+
// StartCPUProfileRequest for starting CPU profiling
type StartCPUProfileRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -5674,7 +6198,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 +6210,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 +6223,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{82}
+ return file_daemon_proto_rawDescGZIP(), []int{92}
}
// StartCPUProfileResponse confirms CPU profiling has started
@@ -5711,7 +6235,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 +6247,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 +6260,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{83}
+ return file_daemon_proto_rawDescGZIP(), []int{93}
}
// StopCPUProfileRequest for stopping CPU profiling
@@ -5748,7 +6272,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 +6284,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 +6297,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{84}
+ return file_daemon_proto_rawDescGZIP(), []int{94}
}
// StopCPUProfileResponse confirms CPU profiling has stopped
@@ -5785,7 +6309,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 +6321,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 +6334,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{85}
+ return file_daemon_proto_rawDescGZIP(), []int{95}
}
type InstallerResultRequest struct {
@@ -5821,7 +6345,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 +6357,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 +6370,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead.
func (*InstallerResultRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{86}
+ return file_daemon_proto_rawDescGZIP(), []int{96}
}
type InstallerResultResponse struct {
@@ -5859,7 +6383,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 +6395,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 +6408,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead.
func (*InstallerResultResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{87}
+ return file_daemon_proto_rawDescGZIP(), []int{97}
}
func (x *InstallerResultResponse) GetSuccess() bool {
@@ -5917,7 +6441,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 +6453,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 +6466,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead.
func (*ExposeServiceRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{88}
+ return file_daemon_proto_rawDescGZIP(), []int{98}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -6013,7 +6537,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 +6549,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 +6562,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead.
func (*ExposeServiceEvent) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{89}
+ return file_daemon_proto_rawDescGZIP(), []int{99}
}
func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event {
@@ -6079,7 +6603,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 +6615,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 +6628,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead.
func (*ExposeServiceReady) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{90}
+ return file_daemon_proto_rawDescGZIP(), []int{100}
}
func (x *ExposeServiceReady) GetServiceName() string {
@@ -6149,7 +6673,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 +6685,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 +6698,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{91}
+ return file_daemon_proto_rawDescGZIP(), []int{101}
}
func (x *StartCaptureRequest) GetTextOutput() bool {
@@ -6228,7 +6752,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 +6764,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 +6777,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message {
// Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead.
func (*CapturePacket) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{92}
+ return file_daemon_proto_rawDescGZIP(), []int{102}
}
func (x *CapturePacket) GetData() []byte {
@@ -6274,7 +6798,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 +6810,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 +6823,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{93}
+ return file_daemon_proto_rawDescGZIP(), []int{103}
}
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
@@ -6317,7 +6841,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 +6853,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 +6866,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{94}
+ return file_daemon_proto_rawDescGZIP(), []int{104}
}
type StopBundleCaptureRequest struct {
@@ -6353,7 +6877,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 +6889,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 +6902,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{95}
+ return file_daemon_proto_rawDescGZIP(), []int{105}
}
type StopBundleCaptureResponse struct {
@@ -6389,7 +6913,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 +6925,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 +6938,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{96}
+ return file_daemon_proto_rawDescGZIP(), []int{106}
}
type PortInfo_Range struct {
@@ -6427,7 +6951,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 +6963,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 +7081,11 @@ const file_daemon_proto_rawDesc = "" +
"\buserCode\x18\x01 \x01(\tR\buserCode\x12\x1a\n" +
"\bhostname\x18\x02 \x01(\tR\bhostname\",\n" +
"\x14WaitSSOLoginResponse\x12\x14\n" +
- "\x05email\x18\x01 \x01(\tR\x05email\"v\n" +
+ "\x05email\x18\x01 \x01(\tR\x05email\"\x8c\x01\n" +
"\tUpRequest\x12%\n" +
"\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" +
- "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" +
+ "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01\x12\x14\n" +
+ "\x05async\x18\x04 \x01(\bR\x05asyncB\x0e\n" +
"\f_profileNameB\v\n" +
"\t_usernameJ\x04\b\x03\x10\x04\"\f\n" +
"\n" +
@@ -6569,13 +7094,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 +7202,7 @@ const file_daemon_proto_rawDesc = "" +
"\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" +
"\x0eSSHServerState\x12\x18\n" +
"\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" +
- "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xaf\x04\n" +
+ "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdb\x04\n" +
"\n" +
"FullStatus\x12A\n" +
"\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" +
@@ -6690,7 +7216,8 @@ const file_daemon_proto_rawDesc = "" +
"\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" +
"\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" +
"\x0esshServerState\x18\n" +
- " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\"\x15\n" +
+ " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\x12*\n" +
+ "\x10networksRevision\x18\v \x01(\x04R\x10networksRevision\"\x15\n" +
"\x13ListNetworksRequest\"?\n" +
"\x14ListNetworksResponse\x12'\n" +
"\x06routes\x18\x01 \x03(\v2\x0f.daemon.NetworkR\x06routes\"a\n" +
@@ -6726,7 +7253,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\"\xdc\x01\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
@@ -6736,7 +7263,8 @@ 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\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
@@ -6746,7 +7274,10 @@ const file_daemon_proto_rawDesc = "" +
"\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"<\n" +
"\x12SetLogLevelRequest\x12&\n" +
"\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"\x15\n" +
- "\x13SetLogLevelResponse\"\x1b\n" +
+ "\x13SetLogLevelResponse\"*\n" +
+ "\x14RegisterUILogRequest\x12\x12\n" +
+ "\x04path\x18\x01 \x01(\tR\x04path\"\x17\n" +
+ "\x15RegisterUILogResponse\"\x1b\n" +
"\x05State\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\"\x13\n" +
"\x11ListStatesRequest\";\n" +
@@ -6935,12 +7466,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" +
@@ -6977,7 +7512,27 @@ const file_daemon_proto_rawDesc = "" +
"\x14WaitJWTTokenResponse\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\x12\x1c\n" +
"\ttokenType\x18\x02 \x01(\tR\ttokenType\x12\x1c\n" +
- "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"\x18\n" +
+ "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"C\n" +
+ "\x1fRequestExtendAuthSessionRequest\x12\x17\n" +
+ "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" +
+ "\x05_hint\"\xe0\x01\n" +
+ " RequestExtendAuthSessionResponse\x12(\n" +
+ "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" +
+ "\x17verificationURIComplete\x18\x02 \x01(\tR\x17verificationURIComplete\x12\x1a\n" +
+ "\buserCode\x18\x03 \x01(\tR\buserCode\x12\x1e\n" +
+ "\n" +
+ "deviceCode\x18\x04 \x01(\tR\n" +
+ "deviceCode\x12\x1c\n" +
+ "\texpiresIn\x18\x05 \x01(\x03R\texpiresIn\"Z\n" +
+ "\x1cWaitExtendAuthSessionRequest\x12\x1e\n" +
+ "\n" +
+ "deviceCode\x18\x01 \x01(\tR\n" +
+ "deviceCode\x12\x1a\n" +
+ "\buserCode\x18\x02 \x01(\tR\buserCode\"g\n" +
+ "\x1dWaitExtendAuthSessionResponse\x12F\n" +
+ "\x10sessionExpiresAt\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\x1e\n" +
+ "\x1cDismissSessionWarningRequest\"\x1f\n" +
+ "\x1dDismissSessionWarningResponse\"\x18\n" +
"\x16StartCPUProfileRequest\"\x19\n" +
"\x17StartCPUProfileResponse\"\x17\n" +
"\x15StopCPUProfileRequest\"\x18\n" +
@@ -7040,12 +7595,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 +7623,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 +7637,15 @@ const file_daemon_proto_rawDesc = "" +
"\rTriggerUpdate\x12\x1c.daemon.TriggerUpdateRequest\x1a\x1d.daemon.TriggerUpdateResponse\"\x00\x12Z\n" +
"\x11GetPeerSSHHostKey\x12 .daemon.GetPeerSSHHostKeyRequest\x1a!.daemon.GetPeerSSHHostKeyResponse\"\x00\x12Q\n" +
"\x0eRequestJWTAuth\x12\x1d.daemon.RequestJWTAuthRequest\x1a\x1e.daemon.RequestJWTAuthResponse\"\x00\x12K\n" +
- "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12T\n" +
+ "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12o\n" +
+ "\x18RequestExtendAuthSession\x12'.daemon.RequestExtendAuthSessionRequest\x1a(.daemon.RequestExtendAuthSessionResponse\"\x00\x12f\n" +
+ "\x15WaitExtendAuthSession\x12$.daemon.WaitExtendAuthSessionRequest\x1a%.daemon.WaitExtendAuthSessionResponse\"\x00\x12f\n" +
+ "\x15DismissSessionWarning\x12$.daemon.DismissSessionWarningRequest\x1a%.daemon.DismissSessionWarningResponse\"\x00\x12T\n" +
"\x0fStartCPUProfile\x12\x1e.daemon.StartCPUProfileRequest\x1a\x1f.daemon.StartCPUProfileResponse\"\x00\x12Q\n" +
"\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12W\n" +
"\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" +
- "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01B\bZ\x06/protob\x06proto3"
+ "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01\x12K\n" +
+ "\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00B\bZ\x06/protob\x06proto3"
var (
file_daemon_proto_rawDescOnce sync.Once
@@ -7099,7 +7660,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 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 +7703,219 @@ var file_daemon_proto_goTypes = []any{
(*GetLogLevelResponse)(nil), // 38: daemon.GetLogLevelResponse
(*SetLogLevelRequest)(nil), // 39: daemon.SetLogLevelRequest
(*SetLogLevelResponse)(nil), // 40: daemon.SetLogLevelResponse
- (*State)(nil), // 41: daemon.State
- (*ListStatesRequest)(nil), // 42: daemon.ListStatesRequest
- (*ListStatesResponse)(nil), // 43: daemon.ListStatesResponse
- (*CleanStateRequest)(nil), // 44: daemon.CleanStateRequest
- (*CleanStateResponse)(nil), // 45: daemon.CleanStateResponse
- (*DeleteStateRequest)(nil), // 46: daemon.DeleteStateRequest
- (*DeleteStateResponse)(nil), // 47: daemon.DeleteStateResponse
- (*SetSyncResponsePersistenceRequest)(nil), // 48: daemon.SetSyncResponsePersistenceRequest
- (*SetSyncResponsePersistenceResponse)(nil), // 49: daemon.SetSyncResponsePersistenceResponse
- (*TCPFlags)(nil), // 50: daemon.TCPFlags
- (*TracePacketRequest)(nil), // 51: daemon.TracePacketRequest
- (*TraceStage)(nil), // 52: daemon.TraceStage
- (*TracePacketResponse)(nil), // 53: daemon.TracePacketResponse
- (*SubscribeRequest)(nil), // 54: daemon.SubscribeRequest
- (*SystemEvent)(nil), // 55: daemon.SystemEvent
- (*GetEventsRequest)(nil), // 56: daemon.GetEventsRequest
- (*GetEventsResponse)(nil), // 57: daemon.GetEventsResponse
- (*SwitchProfileRequest)(nil), // 58: daemon.SwitchProfileRequest
- (*SwitchProfileResponse)(nil), // 59: daemon.SwitchProfileResponse
- (*SetConfigRequest)(nil), // 60: daemon.SetConfigRequest
- (*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse
- (*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest
- (*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse
- (*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 +7930,15 @@ func file_daemon_proto_init() {
(*PortInfo_Port)(nil),
(*PortInfo_Range_)(nil),
}
- file_daemon_proto_msgTypes[47].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[48].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[54].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[49].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[50].OneofWrappers = []any{}
file_daemon_proto_msgTypes[56].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[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 +7947,7 @@ func file_daemon_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 4,
- NumMessages: 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..3c31156ec 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -24,6 +24,12 @@ service DaemonService {
// Status of the service.
rpc Status(StatusRequest) returns (StatusResponse) {}
+ // SubscribeStatus pushes a fresh StatusResponse on connection state
+ // changes (Connected / Disconnected / Connecting / address change /
+ // peers list change). The first message on the stream is the current
+ // snapshot, so a freshly-subscribed UI doesn't need to also call Status.
+ rpc SubscribeStatus(StatusRequest) returns (stream StatusResponse) {}
+
// Down stops engine work in the daemon.
rpc Down(DownRequest) returns (DownResponse) {}
@@ -79,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,10 @@ 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;
}
message DebugBundleResponse {
@@ -518,6 +573,13 @@ message SetLogLevelRequest {
message SetLogLevelResponse {
}
+message RegisterUILogRequest {
+ string path = 1;
+}
+
+message RegisterUILogResponse {
+}
+
// State represents a daemon state entry
message State {
string name = 1;
@@ -771,12 +833,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
@@ -855,6 +927,55 @@ message WaitJWTTokenResponse {
int64 expiresIn = 3;
}
+// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow.
+message RequestExtendAuthSessionRequest {
+ // Optional OIDC login_hint (typically the user's email) to pre-fill the
+ // IdP login form.
+ optional string hint = 1;
+}
+
+// RequestExtendAuthSessionResponse carries the verification URI the UI
+// should open in a browser. The daemon retains the flow state and resolves
+// it via WaitExtendAuthSession.
+message RequestExtendAuthSessionResponse {
+ // verification URI for the user to open in the browser
+ string verificationURI = 1;
+ // complete verification URI (with embedded user code)
+ string verificationURIComplete = 2;
+ // user code to enter on verification URI (for device-code flows)
+ string userCode = 3;
+ // device code for matching the WaitExtendAuthSession call to this flow
+ string deviceCode = 4;
+ // expiration time in seconds for the device code / PKCE flow
+ int64 expiresIn = 5;
+}
+
+// WaitExtendAuthSessionRequest is sent by the UI after it opens the
+// verification URI. The daemon blocks on this call until the user
+// completes (or aborts) the SSO step.
+message WaitExtendAuthSessionRequest {
+ // device code returned by RequestExtendAuthSession
+ string deviceCode = 1;
+ // user code for verification
+ string userCode = 2;
+}
+
+// WaitExtendAuthSessionResponse carries the refreshed deadline returned
+// by the management server. Unset when the management server reports the
+// peer is not eligible for session extension.
+message WaitExtendAuthSessionResponse {
+ google.protobuf.Timestamp sessionExpiresAt = 1;
+}
+
+// DismissSessionWarningRequest is sent by the UI when the user clicks
+// "Dismiss" on the T-WarningLead notification.
+message DismissSessionWarningRequest {}
+
+// DismissSessionWarningResponse acknowledges the dismissal. Carries no
+// payload — the daemon's only obligation is to silence the upcoming
+// T-FinalWarningLead fallback for the current deadline.
+message DismissSessionWarningResponse {}
+
// StartCPUProfileRequest for starting CPU profiling
message StartCPUProfileRequest {}
diff --git a/client/proto/daemon_gateway_test.go b/client/proto/daemon_gateway_test.go
new file mode 100644
index 000000000..20031e9d9
--- /dev/null
+++ b/client/proto/daemon_gateway_test.go
@@ -0,0 +1,80 @@
+package proto
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ gatewayruntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/test/bufconn"
+)
+
+func TestGatewayServerRoutesCoverDaemonRPCs(t *testing.T) {
+ mux := gatewayruntime.NewServeMux()
+ if err := RegisterDaemonServiceHandlerServer(context.Background(), mux, UnimplementedDaemonServiceServer{}); err != nil {
+ t.Fatalf("register daemon gateway server handlers: %v", err)
+ }
+
+ assertAllDaemonGatewayRoutesRegistered(t, mux)
+}
+
+func TestGatewayClientRoutesCoverDaemonRPCs(t *testing.T) {
+ listener := bufconn.Listen(1024 * 1024)
+ server := grpc.NewServer()
+ RegisterDaemonServiceServer(server, UnimplementedDaemonServiceServer{})
+ go func() {
+ if err := server.Serve(listener); err != nil && err != grpc.ErrServerStopped {
+ t.Errorf("serve bufconn gRPC server: %v", err)
+ }
+ }()
+ t.Cleanup(func() {
+ server.Stop()
+ _ = listener.Close()
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ mux := gatewayruntime.NewServeMux()
+ opts := []grpc.DialOption{
+ grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
+ return listener.Dial()
+ }),
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+ }
+ if err := RegisterDaemonServiceHandlerFromEndpoint(ctx, mux, "passthrough:///bufnet", opts); err != nil {
+ t.Fatalf("register daemon gateway client handlers: %v", err)
+ }
+
+ assertAllDaemonGatewayRoutesRegistered(t, mux)
+}
+
+func assertAllDaemonGatewayRoutesRegistered(t *testing.T, mux http.Handler) {
+ t.Helper()
+ for _, method := range DaemonService_ServiceDesc.Methods {
+ assertGatewayRouteRegistered(t, mux, method.MethodName)
+ }
+ for _, stream := range DaemonService_ServiceDesc.Streams {
+ assertGatewayRouteRegistered(t, mux, stream.StreamName)
+ }
+}
+
+func assertGatewayRouteRegistered(t *testing.T, mux http.Handler, methodName string) {
+ t.Helper()
+
+ path := "/daemon.DaemonService/" + methodName
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("{}"))
+ req.Header.Set("Content-Type", "application/json")
+ res := httptest.NewRecorder()
+
+ mux.ServeHTTP(res, req)
+
+ if res.Code == http.StatusNotFound {
+ t.Fatalf("gateway route for %s is not registered", methodName)
+ }
+}
diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go
index 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..60a401b0e 100644
--- a/client/server/debug.go
+++ b/client/server/debug.go
@@ -7,18 +7,62 @@ import (
"context"
"errors"
"fmt"
+ "path/filepath"
"runtime/pprof"
+ "strings"
+ "time"
log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/debug"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/version"
)
// DebugBundle creates a debug bundle and returns the location.
-func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
+func (s *Server) DebugBundle(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
+ if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure()); err != nil {
+ return nil, err
+ }
+
+ // The UI log is opened as whoever asked for this bundle, so a caller only
+ // collects a log it owns (privileged callers excepted). ok is false on a
+ // socket that carries no identity, which skips the UI log.
+ callerID, callerIdentified := ipcauth.CallerIdentity(callerCtx)
+
+ path, managementURL, err := s.generateDebugBundle(req, uiLogOpener(callerID, callerIdentified))
+ if err != nil {
+ return nil, err
+ }
+
+ if req.GetUploadURL() == "" {
+ return &proto.DebugBundleResponse{Path: path}, nil
+ }
+
+ // The upload runs without s.mutex held: it does network I/O to a possibly
+ // slow destination and must not block the other RPCs that take the lock. The
+ // bounded context is a backstop against a hung connection.
+ uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ key, err := debug.UploadDebugBundle(uploadCtx, req.GetUploadURL(), managementURL, path, req.GetUploadInsecure())
+ if err != nil {
+ log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
+ return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
+ }
+
+ log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key)
+
+ return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
+}
+
+// generateDebugBundle builds the bundle under s.mutex and returns its path plus
+// the management URL captured under the lock, so the caller can run the upload
+// without holding the lock.
+func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener debug.LogOpener) (path string, managementURL string, err error) {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -53,7 +97,10 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
if engine != nil {
refreshStatus = func() {
log.Debug("refreshing system health status for debug bundle")
- engine.RunHealthProbes(true)
+ // Background ctx: the bundle wants a full, fresh probe regardless
+ // of the DebugBundle RPC client's lifetime. The engine's own ctx
+ // still aborts it on shutdown.
+ engine.RunHealthProbes(context.Background(), true)
}
}
}
@@ -64,6 +111,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,
@@ -78,23 +127,16 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
},
)
- path, err := bundleGenerator.Generate()
+ path, err = bundleGenerator.Generate()
if err != nil {
- return nil, fmt.Errorf("generate debug bundle: %w", err)
+ return "", "", fmt.Errorf("generate debug bundle: %w", err)
}
- if req.GetUploadURL() == "" {
- return &proto.DebugBundleResponse{Path: path}, nil
- }
- key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path)
- if err != nil {
- log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
- return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
+ if s.config != nil && s.config.ManagementURL != nil {
+ managementURL = s.config.ManagementURL.String()
}
- log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key)
-
- return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
+ return path, managementURL, nil
}
// GetLogLevel gets the current logging level for the server.
@@ -124,9 +166,48 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (
log.Infof("Log level set to %s", level.String())
+ // Signal the desktop UI so it can attach/detach its gui-client.log. Rides
+ // the SubscribeEvents stream as a marked event (see publishLogLevelChanged).
+ s.publishLogLevelChanged(level.String())
+
return &proto.SetLogLevelResponse{}, nil
}
+// RegisterUILog records the desktop UI's absolute log path so DebugBundle can
+// collect the GUI log. The daemon runs as root and can't resolve the user's
+// config dir, so the UI reports it. Last-writer-wins (one UI per socket).
+//
+// The path arrives over an IPC any local user can reach and is later opened by
+// a root daemon, so it is constrained to the file name the UI writes and to a
+// local absolute path. Authorization happens when DebugBundle opens it: the
+// bundle refuses a file its requester does not own. A caller the daemon cannot
+// identify cannot register a path at all.
+func (s *Server) RegisterUILog(callerCtx context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
+ if _, ok := ipcauth.CallerIdentity(callerCtx); !ok {
+ return nil, gstatus.Error(codes.PermissionDenied,
+ "registering a UI log path requires a control channel that carries the caller's identity")
+ }
+
+ path := filepath.Clean(req.GetPath())
+ if !filepath.IsAbs(path) || filepath.Base(path) != uiLogFileName {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "UI log path must be an absolute path ending in %s", uiLogFileName)
+ }
+ // filepath.IsAbs accepts a Windows UNC path (\\host\share\...) and a device
+ // path (\\.\, \\?\); opening one would make the root daemon reach a remote
+ // or device namespace. Require a plain local path.
+ if strings.HasPrefix(path, `\\`) {
+ return nil, gstatus.Error(codes.InvalidArgument, "UI log path must be a local path, not a UNC or device path")
+ }
+
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ s.uiLogPath = path
+ log.Infof("registered UI log path %s", s.uiLogPath)
+
+ return &proto.RegisterUILogResponse{}, nil
+}
+
// SetSyncResponsePersistence sets the sync response persistence for the server.
func (s *Server) SetSyncResponsePersistence(_ context.Context, req *proto.SetSyncResponsePersistenceRequest) (*proto.SetSyncResponsePersistenceResponse, error) {
s.mutex.Lock()
diff --git a/client/server/debug_gate.go b/client/server/debug_gate.go
new file mode 100644
index 000000000..983a13aaf
--- /dev/null
+++ b/client/server/debug_gate.go
@@ -0,0 +1,99 @@
+//go:build !android && !ios
+
+package server
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "strings"
+
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/configs"
+ "github.com/netbirdio/netbird/client/internal/debug"
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/upload-server/types"
+)
+
+// uiLogFileName is the only file name the daemon accepts as a UI log path. The
+// UI (writer), this validation, and the bundle collector all read it from
+// configs so they cannot drift.
+const uiLogFileName = configs.UILogFile
+
+// uiLogOpener opens the registered UI log, and its rotated siblings, on behalf
+// of the caller requesting the bundle: OpenOwnedFile then collects the log only
+// when that caller owns it (or is privileged). identified is false on a socket
+// that carries no caller identity, in which case nothing is opened.
+func uiLogOpener(id ipcauth.Identity, identified bool) debug.LogOpener {
+ return func(path string) (*os.File, error) {
+ if !identified {
+ return nil, fmt.Errorf("bundle requester has no verified identity")
+ }
+ return ipcauth.OpenOwnedFile(id, path)
+ }
+}
+
+// requirePrivilegeForUploadURL restricts where the daemon may send a debug
+// bundle. The bundle holds the daemon's own logs and state, and the daemon
+// fetches the upload URL itself, so an unrestricted endpoint turns the daemon
+// into both an exfiltration channel and a request forwarder that reaches
+// services only it can talk to.
+//
+// The upload service NetBird publishes is open to any caller, since that is what
+// the CLI and the desktop UI use. Any other endpoint, self-hosted upload servers
+// included, requires a privileged caller. Plaintext is refused for everyone: the
+// daemon fetches the URL and then PUTs the bundle to whatever that fetch returns,
+// so an http hop is a place to intercept the bundle or the redirect.
+//
+// insecure relaxes transport security (http, or an untrusted TLS certificate)
+// for a self-hosted server. It weakens a root-privileged upload, so it is
+// refused for an unprivileged caller regardless of the host.
+func requirePrivilegeForUploadURL(ctx context.Context, rawURL string, insecure bool) error {
+ if rawURL == "" {
+ return nil
+ }
+
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return gstatus.Errorf(codes.InvalidArgument, "parse upload URL: %v", err)
+ }
+
+ // --insecure relaxes https to http or an untrusted certificate; it does not
+ // widen the URL to arbitrary schemes, so a host and http/https are required
+ // before the insecure branch takes over.
+ if parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
+ return gstatus.Errorf(codes.InvalidArgument, "upload URL must be http or https with a host")
+ }
+
+ if insecure {
+ return denyPrivileged(ctx,
+ "uploading a debug bundle without transport security (--upload-bundle-insecure)",
+ ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-insecure --upload-bundle-url "))
+ }
+
+ if parsed.Scheme != "https" {
+ return gstatus.Errorf(codes.InvalidArgument, "upload URL must use https, got scheme %q", parsed.Scheme)
+ }
+
+ if isDefaultUploadService(parsed) {
+ return nil
+ }
+
+ return denyPrivileged(ctx,
+ "uploading a debug bundle to an upload service other than the default one",
+ ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-url "))
+}
+
+// isDefaultUploadService reports whether the URL points at the upload service
+// NetBird runs. Only the host is compared: the service's path may differ between
+// releases, and the host is what decides who receives the bundle.
+func isDefaultUploadService(parsed *url.URL) bool {
+ defaultURL, err := url.Parse(types.DefaultBundleURL)
+ if err != nil {
+ return false
+ }
+ return parsed.Scheme == defaultURL.Scheme && strings.EqualFold(parsed.Host, defaultURL.Host)
+}
diff --git a/client/server/debug_gate_test.go b/client/server/debug_gate_test.go
new file mode 100644
index 000000000..e958fc581
--- /dev/null
+++ b/client/server/debug_gate_test.go
@@ -0,0 +1,157 @@
+//go:build !android && !ios
+
+package server
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/upload-server/types"
+)
+
+func TestRegisterUILogRefusesUnidentifiedCaller(t *testing.T) {
+ s := &Server{}
+
+ _, err := s.RegisterUILog(noIdentityCtx(), &proto.RegisterUILogRequest{
+ Path: filepath.Join(t.TempDir(), uiLogFileName),
+ })
+
+ if gstatus.Code(err) != codes.PermissionDenied {
+ t.Fatalf("code = %v, want PermissionDenied", gstatus.Code(err))
+ }
+}
+
+func TestRegisterUILogRefusesForeignPath(t *testing.T) {
+ secret := "/etc/shadow"
+ if runtime.GOOS == "windows" {
+ secret = `C:\Windows\System32\config\SAM`
+ }
+
+ tests := []struct {
+ name string
+ path string
+ }{
+ {"empty", ""},
+ {"relative", filepath.Join("netbird", uiLogFileName)},
+ {"another file", secret},
+ {"directory of the log", t.TempDir()},
+ {"unc path", `\\attacker\share\` + uiLogFileName},
+ {"device path", `\\.\C:\` + uiLogFileName},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ s := &Server{}
+
+ _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: tc.path})
+
+ if gstatus.Code(err) != codes.InvalidArgument {
+ t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
+ }
+ if s.uiLogPath != "" {
+ t.Fatalf("path %q was recorded despite the refusal", s.uiLogPath)
+ }
+ })
+ }
+}
+
+func TestRegisterUILogRecordsPath(t *testing.T) {
+ s := &Server{}
+ path := filepath.Join(t.TempDir(), uiLogFileName)
+
+ if _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: path}); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+
+ if s.uiLogPath != path {
+ t.Fatalf("path = %q, want %q", s.uiLogPath, path)
+ }
+}
+
+// The UI log is opened as the bundle requester, so a second local user cannot
+// collect a log they do not own, and an unidentified requester collects nothing.
+func TestUILogOpenerBindsToRequester(t *testing.T) {
+ path := filepath.Join(t.TempDir(), uiLogFileName)
+ if err := os.WriteFile(path, []byte("log line"), 0600); err != nil {
+ t.Fatalf("write log: %v", err)
+ }
+
+ // A different unprivileged user than the file's owner: refused.
+ if _, err := uiLogOpener(unprivilegedIdentity(), true)(path); err == nil {
+ t.Fatal("expected a file the requester does not own to be refused")
+ }
+
+ // No verified identity: refused.
+ if _, err := uiLogOpener(ipcauth.Identity{}, false)(path); err == nil {
+ t.Fatal("expected an unidentified requester to be refused")
+ }
+
+ // The requester that owns the file: allowed. The test process created it, so
+ // its own identity is the owner (and a privileged runner is exempt anyway).
+ owner, err := ipcauth.CurrentProcessIdentity()
+ if err != nil {
+ t.Fatalf("current identity: %v", err)
+ }
+ f, err := uiLogOpener(owner, true)(path)
+ if err != nil {
+ t.Fatalf("expected the owning requester to be allowed, got %v", err)
+ }
+ _ = f.Close()
+}
+
+func TestRequirePrivilegeForUploadURL(t *testing.T) {
+ tests := []struct {
+ name string
+ url string
+ insecure bool
+ unprivOK bool
+ invalid bool
+ rootAlso bool
+ }{
+ {name: "no upload", url: "", unprivOK: true},
+ {name: "default service", url: types.DefaultBundleURL, unprivOK: true},
+ {name: "default service, other path", url: "https://upload.debug.netbird.io/other", unprivOK: true},
+ {name: "loopback exfiltration endpoint", url: "https://127.0.0.1:8080/upload-url", rootAlso: true},
+ {name: "custom upload service", url: "https://attacker.example/upload-url", rootAlso: true},
+ {name: "plaintext default host", url: "http://upload.debug.netbird.io/upload-url", invalid: true},
+ {name: "plaintext custom host", url: "http://attacker.example/upload-url", invalid: true},
+ {name: "unsupported scheme", url: "file:///etc/shadow", invalid: true},
+ // insecure relaxes transport security; privileged only, whatever the host.
+ {name: "insecure http custom", url: "http://selfhosted.local/upload-url", insecure: true, rootAlso: true},
+ {name: "insecure https custom", url: "https://selfhosted.local/upload-url", insecure: true, rootAlso: true},
+ {name: "insecure default host", url: types.DefaultBundleURL, insecure: true, rootAlso: true},
+ // --insecure must not widen the URL to non-http(s) schemes or a hostless URL.
+ {name: "insecure file scheme", url: "file:///etc/shadow", insecure: true, invalid: true},
+ {name: "insecure hostless", url: "https:///upload-url", insecure: true, invalid: true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := requirePrivilegeForUploadURL(userCtx(), tc.url, tc.insecure)
+
+ switch {
+ case tc.invalid:
+ if gstatus.Code(err) != codes.InvalidArgument {
+ t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
+ }
+ return
+ case tc.unprivOK:
+ assertAllowed(t, err)
+ return
+ default:
+ assertDenied(t, err)
+ }
+
+ if tc.rootAlso {
+ assertAllowed(t, requirePrivilegeForUploadURL(rootCtx(), tc.url, tc.insecure))
+ }
+ })
+ }
+}
diff --git a/client/server/event.go b/client/server/event.go
index d93151c96..753a051e7 100644
--- a/client/server/event.go
+++ b/client/server/event.go
@@ -1,7 +1,9 @@
package server
import (
+ "github.com/google/uuid"
log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/client/proto"
)
@@ -16,6 +18,15 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
log.Debug("client subscribed to events")
s.startUpdateManagerForGUI()
+ // Replay the current log level to this subscriber so a freshly-connected UI
+ // learns it even when the daemon was already started with --log-level debug
+ // (the change-driven publishLogLevelChanged only fires on SetLogLevel). Sent
+ // directly on this stream rather than via PublishEvent so it reaches only
+ // the new subscriber, not every connected client.
+ if err := s.sendCurrentLogLevel(stream); err != nil {
+ return err
+ }
+
for {
select {
case event := <-subscription.Events():
@@ -28,3 +39,24 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
}
}
}
+
+// sendCurrentLogLevel sends a marked log-level-changed SystemEvent carrying the
+// daemon's current level directly to one subscriber. Mirrors the shape
+// publishLogLevelChanged emits so the UI's dispatchSystemEvent handles both the
+// same way.
+func (s *Server) sendCurrentLogLevel(stream proto.DaemonService_SubscribeEventsServer) error {
+ level := log.GetLevel().String()
+ event := &proto.SystemEvent{
+ Id: uuid.New().String(),
+ Severity: proto.SystemEvent_INFO,
+ Category: proto.SystemEvent_SYSTEM,
+ Message: "Log level changed",
+ Metadata: map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level},
+ Timestamp: timestamppb.Now(),
+ }
+ if err := stream.Send(event); err != nil {
+ log.Warnf("error sending initial log level event: %v", err)
+ return err
+ }
+ return nil
+}
diff --git a/client/server/extend_authsession_test.go b/client/server/extend_authsession_test.go
new file mode 100644
index 000000000..a1a048a7c
--- /dev/null
+++ b/client/server/extend_authsession_test.go
@@ -0,0 +1,42 @@
+package server
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+)
+
+func TestInnermostStatus(t *testing.T) {
+ t.Run("wrapped gRPC status", func(t *testing.T) {
+ inner := gstatus.Error(codes.PermissionDenied, "peer is already registered by a different User or a Setup Key")
+ // Mirror the daemon wrap chain: engine wraps with %w, mgm error is the inner status.
+ wrapped := fmt.Errorf("extend auth session on management: %w", inner)
+
+ st := innermostStatus(wrapped)
+ require.NotNil(t, st)
+ require.Equal(t, codes.PermissionDenied, st.Code())
+ require.Equal(t, "peer is already registered by a different User or a Setup Key", st.Message())
+ })
+
+ t.Run("deepest status wins over an outer one", func(t *testing.T) {
+ inner := gstatus.Error(codes.PermissionDenied, "deepest")
+ chain := fmt.Errorf("outer: %w", fmt.Errorf("mid: %w", inner))
+
+ st := innermostStatus(chain)
+ require.NotNil(t, st)
+ require.Equal(t, codes.PermissionDenied, st.Code())
+ require.Equal(t, "deepest", st.Message())
+ })
+
+ t.Run("no status in chain", func(t *testing.T) {
+ require.Nil(t, innermostStatus(errors.New("plain error")))
+ })
+
+ t.Run("nil error", func(t *testing.T) {
+ require.Nil(t, innermostStatus(nil))
+ })
+}
diff --git a/client/server/lock_order_test.go b/client/server/lock_order_test.go
new file mode 100644
index 000000000..457e3db34
--- /dev/null
+++ b/client/server/lock_order_test.go
@@ -0,0 +1,51 @@
+package server
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// The daemon takes guardedConfigMu before s.mutex. authorizeAndPrepareLogin
+// takes s.mutex while holding guardedConfigMu, so a SetConfig that grabbed
+// s.mutex first and then waited for guardedConfigMu would deadlock the daemon
+// against a concurrent login: two unprivileged IPC calls are enough.
+//
+// The held guardedConfigMu below stands in for that login. While SetConfig waits
+// for it, s.mutex must stay free, otherwise the login waiting for s.mutex could
+// never release guardedConfigMu.
+func TestSetConfig_TakesGuardedConfigMuBeforeServerMutex(t *testing.T) {
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ s.guardedConfigMu.Lock()
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ })
+ done <- err
+ }()
+
+ require.Never(t, func() bool {
+ if !s.mutex.TryLock() {
+ return true
+ }
+ s.mutex.Unlock()
+ return false
+ }, 500*time.Millisecond, 10*time.Millisecond,
+ "SetConfig held s.mutex while waiting for guardedConfigMu, which deadlocks against a concurrent login")
+
+ s.guardedConfigMu.Unlock()
+
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("SetConfig did not finish after guardedConfigMu was released")
+ }
+}
diff --git a/client/server/login_gate_test.go b/client/server/login_gate_test.go
new file mode 100644
index 000000000..de62a8180
--- /dev/null
+++ b/client/server/login_gate_test.go
@@ -0,0 +1,127 @@
+package server
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// A refused login must not leave the profile switched. Login can both switch
+// profiles and carry the guarded config fields, so the gate has to run before the
+// switch: otherwise a caller whose change is refused still gets the side effect of
+// activating whichever profile the request named.
+func TestLogin_RefusedChangeLeavesTheProfileAlone(t *testing.T) {
+ s, _, activeProfile, username, _ := setupServerWithProfile(t)
+
+ // Login reads process state off the daemon's root context.
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ // A second profile that runs the SSH server, which is what makes repointing
+ // its management binding a privileged change.
+ target := "ssh-enabled"
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
+ ManagementURL: "https://api.netbird.io:443",
+ ServerSSHAllowed: boolPtr(true),
+ })
+ require.NoError(t, err)
+
+ _, err = s.Login(userCtx(), &proto.LoginRequest{
+ ProfileName: &target,
+ Username: &username,
+ ManagementUrl: "https://mgmt.attacker.example:443",
+ })
+ require.Error(t, err, "an unprivileged caller must not move the management URL of an SSH-enabled profile")
+ require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
+
+ active, err := s.profileManager.GetActiveProfileState()
+ require.NoError(t, err)
+ require.Equal(t, profilemanager.ID(activeProfile), active.ID,
+ "the refused login switched the active profile anyway")
+}
+
+// A caller whose change becomes privileged only after its first check must be
+// refused without having cancelled a login or switched profiles: the first check is
+// unsynchronized, so the SSH server can be enabled by a concurrent privileged
+// request in between, and the authoritative check happens before any side effect.
+func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing.T) {
+ s, _, activeProfile, username, _ := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ // The target profile has SSH off, so the first check lets the request through.
+ target := "ssh-later"
+ targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json")
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: targetPath,
+ ManagementURL: "https://api.netbird.io:443",
+ ServerSSHAllowed: boolPtr(false),
+ })
+ require.NoError(t, err)
+
+ cancelled := false
+ s.actCancel = func() { cancelled = true }
+
+ // Stand in for a privileged SetConfig that enables the SSH server between the
+ // two checks, which is the interleaving the lock has to make safe.
+ afterLoginPreCheck = func() {
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: targetPath,
+ ServerSSHAllowed: boolPtr(true),
+ })
+ require.NoError(t, err)
+ }
+ t.Cleanup(func() { afterLoginPreCheck = nil })
+
+ _, err = s.Login(userCtx(), &proto.LoginRequest{
+ ProfileName: &target,
+ Username: &username,
+ ManagementUrl: "https://mgmt.attacker.example:443",
+ })
+ require.Error(t, err)
+ require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
+ require.False(t, cancelled, "the refused login cancelled the login already in progress")
+
+ active, err := s.profileManager.GetActiveProfileState()
+ require.NoError(t, err)
+ require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway")
+
+ stored, err := profilemanager.ReadConfig(targetPath)
+ require.NoError(t, err)
+ require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL")
+}
+
+// Login cancels whatever login is already in progress before starting its own. A
+// refused caller must not get that far, otherwise anyone able to reach the socket
+// can abort someone else's login by sending a request that is denied.
+func TestLogin_RefusedChangeLeavesAnInProgressLoginAlone(t *testing.T) {
+ s, _, _, username, _ := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ target := "ssh-enabled"
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
+ ManagementURL: "https://api.netbird.io:443",
+ ServerSSHAllowed: boolPtr(true),
+ })
+ require.NoError(t, err)
+
+ cancelled := false
+ s.actCancel = func() { cancelled = true }
+
+ _, err = s.Login(userCtx(), &proto.LoginRequest{
+ ProfileName: &target,
+ Username: &username,
+ ManagementUrl: "https://mgmt.attacker.example:443",
+ })
+ require.Error(t, err)
+ require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
+ require.False(t, cancelled, "the refused login cancelled the login already in progress")
+}
diff --git a/client/server/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/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 3f6dabc56..aaab5cc02 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
@@ -135,6 +157,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 +176,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 +247,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 +268,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 +285,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 +327,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
}
@@ -351,6 +396,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 +430,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 +488,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 +526,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 +564,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 +591,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 +614,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)
@@ -577,8 +628,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
return &proto.LoginResponse{}, nil
}
- state.Set(internal.StatusConnecting)
-
if msg.SetupKey == "" {
hint := ""
if msg.Hint != nil {
@@ -593,6 +642,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,6 +679,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}, 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.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
state.Set(loginStatus)
return nil, err
@@ -637,8 +692,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 +736,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 +776,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 +807,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
}
@@ -712,6 +844,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
return nil, err
}
+ log.Infof("SSO login flow finished, returning success to caller")
return &proto.WaitSSOLoginResponse{
Email: tokenInfo.Email,
}, nil
@@ -719,6 +852,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 +874,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 +889,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 +967,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 +996,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 +1139,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 +1154,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
}
@@ -999,7 +1230,7 @@ func (s *Server) cleanupConnection() error {
// 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)
}
}
@@ -1039,6 +1270,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)
}
@@ -1160,6 +1397,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)
@@ -1176,7 +1420,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
@@ -1229,9 +1485,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() {
@@ -1242,15 +1513,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
}
@@ -1471,6 +1747,151 @@ 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")
+ }
+
+ hint := ""
+ if msg.Hint != nil {
+ hint = *msg.Hint
+ }
+ if hint == "" {
+ hint = profilemanager.GetLoginHint()
+ }
+
+ isDesktop := isUnixRunningDesktop()
+ oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint)
+ if err != nil {
+ return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
+ }
+
+ authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
+ if err != nil {
+ return nil, gstatus.Errorf(codes.Internal, "failed to request auth info: %v", err)
+ }
+
+ s.extendAuthSessionFlow.Set(oAuthFlow, authInfo)
+
+ return &proto.RequestExtendAuthSessionResponse{
+ VerificationURI: authInfo.VerificationURI,
+ VerificationURIComplete: authInfo.VerificationURIComplete,
+ UserCode: authInfo.UserCode,
+ DeviceCode: authInfo.DeviceCode,
+ ExpiresIn: int64(authInfo.ExpiresIn),
+ }, nil
+}
+
+// WaitExtendAuthSession blocks until the user completes the SSO step
+// initiated by RequestExtendAuthSession, then forwards the resulting JWT
+// to the management server's ExtendAuthSession RPC. The returned deadline
+// is also applied locally via the engine so SubscribeStatus consumers see
+// the refreshed state.
+func (s *Server) WaitExtendAuthSession(
+ ctx context.Context,
+ req *proto.WaitExtendAuthSessionRequest,
+) (*proto.WaitExtendAuthSessionResponse, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ oAuthFlow, authInfo, ok := s.extendAuthSessionFlow.Get()
+
+ s.mutex.Lock()
+ connectClient := s.connectClient
+ s.mutex.Unlock()
+
+ if !ok || authInfo.DeviceCode != req.DeviceCode {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "invalid device code or no active extend-session flow")
+ }
+
+ // Preempt a previous WaitExtendAuthSession (e.g. when the tray
+ // notification and the about-to-expire dialog both start a flow on
+ // the same deadline). The older waiter exits via context.Canceled;
+ // the new one takes over the IdP poll.
+ s.extendAuthSessionFlow.CancelWait()
+
+ waitCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ s.extendAuthSessionFlow.SetWaitCancel(cancel)
+
+ tokenInfo, err := oAuthFlow.WaitToken(waitCtx, authInfo)
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ return nil, gstatus.Errorf(codes.Canceled, "extend-session flow preempted")
+ }
+ return nil, gstatus.Errorf(codes.Internal, "failed to obtain JWT token: %v", err)
+ }
+
+ // Clear pending flow before talking to mgm so a retry can re-initiate.
+ s.extendAuthSessionFlow.Clear()
+
+ if connectClient == nil {
+ return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
+ }
+ engine := connectClient.Engine()
+ if engine == nil {
+ return nil, gstatus.Errorf(codes.FailedPrecondition, "engine is not initialised")
+ }
+
+ deadline, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse())
+ if err != nil {
+ // Log the full wrapped chain, but return only the innermost gRPC
+ // status (code + clean desc) so the UI shows the root cause, not
+ // the daemon's wrapping layers.
+ log.Errorf("management ExtendAuthSession failed: %v", err)
+ if st := innermostStatus(err); st != nil {
+ return nil, gstatus.Error(st.Code(), st.Message())
+ }
+ return nil, gstatus.Errorf(codes.Internal, "%v", err)
+ }
+
+ resp := &proto.WaitExtendAuthSessionResponse{}
+ if !deadline.IsZero() {
+ resp.SessionExpiresAt = timestamppb.New(deadline)
+ }
+ return resp, nil
+}
+
+// DismissSessionWarning forwards the user's "Dismiss" click on the
+// T-WarningLead notification down to the engine's sessionWatcher so the
+// T-FinalWarningLead fallback is suppressed for the current deadline.
+// Best-effort: when the client/engine is not yet running the call is a
+// successful no-op (the watcher has no deadline to dismiss anyway).
+func (s *Server) DismissSessionWarning(
+ _ context.Context,
+ _ *proto.DismissSessionWarningRequest,
+) (*proto.DismissSessionWarningResponse, error) {
+ s.mutex.Lock()
+ connectClient := s.connectClient
+ s.mutex.Unlock()
+ if connectClient == nil {
+ return &proto.DismissSessionWarningResponse{}, nil
+ }
+ if engine := connectClient.Engine(); engine != nil {
+ engine.DismissSessionWarning()
+ }
+ return &proto.DismissSessionWarningResponse{}, nil
+}
+
// ExposeService exposes a local port via the NetBird reverse proxy.
func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error {
s.mutex.Lock()
@@ -1537,7 +1958,7 @@ func isUnixRunningDesktop() bool {
return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
}
-func (s *Server) runProbes(waitForProbeResult bool) {
+func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) {
if s.connectClient == nil {
return
}
@@ -1547,15 +1968,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.
@@ -1647,7 +2060,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,
@@ -1685,6 +2097,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
}
@@ -1711,6 +2125,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
}
@@ -1733,7 +2149,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 {
@@ -1741,9 +2160,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()
@@ -1815,11 +2276,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)
@@ -1966,6 +2449,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
@@ -1989,3 +2535,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..8b6f78f04
--- /dev/null
+++ b/client/server/server_privileged_test.go
@@ -0,0 +1,252 @@
+//go:build privileged
+
+package server
+
+import (
+ "context"
+ "net"
+ "os/user"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
+
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server/job"
+
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ "github.com/netbirdio/netbird/management/server/groups"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/management/server"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbcache "github.com/netbirdio/netbird/management/server/cache"
+ "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/telemetry"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ signalServer "github.com/netbirdio/netbird/signal/server"
+)
+
+var (
+ kaep = keepalive.EnforcementPolicy{
+ MinTime: 15 * time.Second,
+ PermitWithoutStream: true,
+ }
+
+ kasp = keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Second,
+ MaxConnectionAgeGrace: 5 * time.Second,
+ Time: 5 * time.Second,
+ Timeout: 2 * time.Second,
+ }
+)
+
+// TestConnectStopsRetryOnPermissionDenied verifies connectWithRetryRuns stops after a single login
+// attempt on PermissionDenied, despite the fast retry config that would otherwise drive several.
+func TestConnectStopsRetryOnPermissionDenied(t *testing.T) {
+ // Redirect profile paths to a temp dir so the test does not need root.
+ tempDir := t.TempDir()
+ origDefaultProfileDir := profilemanager.DefaultConfigPathDir
+ origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
+ origDefaultConfigPath := profilemanager.DefaultConfigPath
+ profilemanager.ConfigDirOverride = tempDir
+ profilemanager.DefaultConfigPathDir = tempDir
+ profilemanager.ActiveProfileStatePath = filepath.Join(tempDir, "active_profile.json")
+ profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
+ t.Cleanup(func() {
+ profilemanager.DefaultConfigPathDir = origDefaultProfileDir
+ profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
+ profilemanager.DefaultConfigPath = origDefaultConfigPath
+ profilemanager.ConfigDirOverride = ""
+ })
+
+ // start the signal server
+ _, signalAddr, err := startSignal(t)
+ if err != nil {
+ t.Fatalf("failed to start signal server: %v", err)
+ }
+
+ counter := 0
+ // start the management server
+ _, mgmtAddr, err := startManagement(t, signalAddr, &counter)
+ if err != nil {
+ t.Fatalf("failed to start management server: %v", err)
+ }
+
+ ctx := internal.CtxInitState(context.Background())
+
+ ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second))
+ defer cancel()
+ // create new server
+ ic := profilemanager.ConfigInput{
+ ManagementURL: "http://" + mgmtAddr,
+ ConfigPath: t.TempDir() + "/test-profile.json",
+ }
+
+ config, err := profilemanager.UpdateOrCreateConfig(ic)
+ if err != nil {
+ t.Fatalf("failed to create config: %v", err)
+ }
+
+ currUser, err := user.Current()
+ require.NoError(t, err)
+
+ pm := profilemanager.ServiceManager{}
+ err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
+ ID: "test-profile",
+ Username: currUser.Username,
+ })
+ if err != nil {
+ t.Fatalf("failed to set active profile state: %v", err)
+ }
+
+ s := New(ctx, "debug", "", false, false, false, false)
+
+ s.config = config
+
+ s.statusRecorder = peer.NewRecorder(config.ManagementURL.String())
+ t.Setenv(retryInitialIntervalVar, "1s")
+ t.Setenv(maxRetryIntervalVar, "2s")
+ t.Setenv(maxRetryTimeVar, "5s")
+ t.Setenv(retryMultiplierVar, "1")
+
+ s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
+ if counter != 1 {
+ t.Fatalf("expected exactly 1 login attempt (PermissionDenied must stop the retry loop), got %d", counter)
+ }
+}
+
+type mockServer struct {
+ mgmtProto.ManagementServiceServer
+ counter *int
+}
+
+func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
+ *m.counter++
+ return m.ManagementServiceServer.Login(ctx, req)
+}
+
+func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) {
+ t.Helper()
+ dataDir := t.TempDir()
+
+ config := &config.Config{
+ Stuns: []*config.Host{},
+ TURNConfig: &config.TURNConfig{},
+ Signal: &config.Host{
+ Proto: "http",
+ URI: signalAddr,
+ },
+ Datadir: dataDir,
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+ store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir)
+ if err != nil {
+ return nil, "", err
+ }
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+ if err != nil {
+ return nil, "", err
+ }
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ permissionsManagerMock := permissions.NewMockManager(ctrl)
+ peersManager := peers.NewManager(store, permissionsManagerMock)
+ settingsManagerMock := settings.NewMockManager(ctrl)
+
+ jobManager := job.NewJobManager(nil, store, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
+
+ metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+ require.NoError(t, err)
+
+ settingsMockManager := settings.NewMockManager(ctrl)
+ groupsManager := groups.NewManagerMock()
+
+ requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
+ peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
+ networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
+ if err != nil {
+ return nil, "", err
+ }
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ mock := &mockServer{
+ ManagementServiceServer: mgmtServer,
+ counter: counter,
+ }
+ mgmtProto.RegisterManagementServiceServer(s, mock)
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+func startSignal(t *testing.T) (*grpc.Server, string, error) {
+ t.Helper()
+
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+
+ srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ proto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
diff --git a/client/server/server_test.go b/client/server/server_test.go
index 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..4180849cd 100644
--- a/client/ssh/client/client.go
+++ b/client/ssh/client/client.go
@@ -9,7 +9,6 @@ import (
"path/filepath"
"runtime"
"strconv"
- "strings"
"time"
log "github.com/sirupsen/logrus"
@@ -17,7 +16,6 @@ import (
"golang.org/x/crypto/ssh/knownhosts"
"golang.org/x/term"
"google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -32,7 +30,7 @@ const (
// DefaultDaemonAddr is the default address for the NetBird daemon
DefaultDaemonAddr = "unix:///var/run/netbird.sock"
// DefaultDaemonAddrWindows is the default address for the NetBird daemon on Windows
- DefaultDaemonAddrWindows = "tcp://127.0.0.1:41731"
+ DefaultDaemonAddrWindows = daemonaddr.WindowsPipeAddr
)
// Client wraps crypto/ssh Client for simplified SSH operations
@@ -268,7 +266,7 @@ func getDefaultDaemonAddr() string {
return addr
}
if runtime.GOOS == "windows" {
- return DefaultDaemonAddrWindows
+ return daemonaddr.ResolveDaemonAddr(DefaultDaemonAddrWindows)
}
return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr)
}
@@ -410,12 +408,9 @@ func verifyHostKeyViaDaemon(hostname string, remote net.Addr, key ssh.PublicKey,
}
func connectToDaemon(daemonAddr string) (*grpc.ClientConn, error) {
- addr := strings.TrimPrefix(daemonAddr, "tcp://")
+ target, opts := daemonaddr.DialTarget(daemonAddr)
- conn, err := grpc.NewClient(
- addr,
- grpc.WithTransportCredentials(insecure.NewCredentials()),
- )
+ conn, err := grpc.NewClient(target, opts...)
if err != nil {
log.Debugf("failed to create gRPC client for NetBird daemon at %s: %v", daemonAddr, err)
return nil, fmt.Errorf("failed to connect to NetBird daemon: %w", err)
diff --git a/client/ssh/client/client_privileged_test.go b/client/ssh/client/client_privileged_test.go
new file mode 100644
index 000000000..12edbbc06
--- /dev/null
+++ b/client/ssh/client/client_privileged_test.go
@@ -0,0 +1,118 @@
+//go:build privileged
+
+package client
+
+import (
+ "context"
+ "errors"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ cryptossh "golang.org/x/crypto/ssh"
+
+ "github.com/netbirdio/netbird/client/ssh/testutil"
+)
+
+func TestSSHClient_CommandExecution(t *testing.T) {
+ if runtime.GOOS == "windows" && testutil.IsCI() {
+ t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues")
+ }
+
+ server, _, client := setupTestSSHServerAndClient(t)
+ defer func() {
+ err := server.Stop()
+ require.NoError(t, err)
+ }()
+ defer func() {
+ err := client.Close()
+ assert.NoError(t, err)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ t.Run("ExecuteCommand captures output", func(t *testing.T) {
+ output, err := client.ExecuteCommand(ctx, "echo hello")
+ assert.NoError(t, err)
+ assert.Contains(t, string(output), "hello")
+ })
+
+ t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) {
+ err := client.ExecuteCommandWithIO(ctx, "echo world")
+ assert.NoError(t, err)
+ })
+
+ t.Run("commands with flags work", func(t *testing.T) {
+ output, err := client.ExecuteCommand(ctx, "echo -n test_flag")
+ assert.NoError(t, err)
+ assert.Equal(t, "test_flag", strings.TrimSpace(string(output)))
+ })
+
+ t.Run("non-zero exit codes don't return errors", func(t *testing.T) {
+ var testCmd string
+ if runtime.GOOS == "windows" {
+ testCmd = "echo hello | Select-String notfound"
+ } else {
+ testCmd = "echo 'hello' | grep 'notfound'"
+ }
+ _, err := client.ExecuteCommand(ctx, testCmd)
+ assert.NoError(t, err)
+ })
+}
+
+func TestSSHClient_ContextCancellation(t *testing.T) {
+ server, serverAddr, _ := setupTestSSHServerAndClient(t)
+ defer func() {
+ err := server.Stop()
+ require.NoError(t, err)
+ }()
+
+ t.Run("connection with short timeout", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
+ defer cancel()
+
+ currentUser := testutil.GetTestUsername(t)
+ _, err := Dial(ctx, serverAddr, currentUser, DialOptions{
+ InsecureSkipVerify: true,
+ })
+ if err != nil {
+ // Check for actual timeout-related errors rather than string matching
+ assert.True(t,
+ errors.Is(err, context.DeadlineExceeded) ||
+ errors.Is(err, context.Canceled) ||
+ strings.Contains(err.Error(), "timeout"),
+ "Expected timeout-related error, got: %v", err)
+ }
+ })
+
+ t.Run("command execution cancellation", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ currentUser := testutil.GetTestUsername(t)
+ client, err := Dial(ctx, serverAddr, currentUser, DialOptions{
+ InsecureSkipVerify: true,
+ })
+ require.NoError(t, err)
+ defer func() {
+ if err := client.Close(); err != nil {
+ t.Logf("client close error: %v", err)
+ }
+ }()
+
+ cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cmdCancel()
+
+ err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10")
+ if err != nil {
+ var exitMissingErr *cryptossh.ExitMissingError
+ isValidCancellation := errors.Is(err, context.DeadlineExceeded) ||
+ errors.Is(err, context.Canceled) ||
+ errors.As(err, &exitMissingErr)
+ assert.True(t, isValidCancellation, "Should handle command cancellation properly")
+ }
+ })
+}
diff --git a/client/ssh/client/client_test.go b/client/ssh/client/client_test.go
index e38e02a86..191362940 100644
--- a/client/ssh/client/client_test.go
+++ b/client/ssh/client/client_test.go
@@ -15,7 +15,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- cryptossh "golang.org/x/crypto/ssh"
"github.com/netbirdio/netbird/client/ssh"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
@@ -78,53 +77,6 @@ func TestSSHClient_DialWithKey(t *testing.T) {
assert.NotNil(t, client.client)
}
-func TestSSHClient_CommandExecution(t *testing.T) {
- if runtime.GOOS == "windows" && testutil.IsCI() {
- t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues")
- }
-
- server, _, client := setupTestSSHServerAndClient(t)
- defer func() {
- err := server.Stop()
- require.NoError(t, err)
- }()
- defer func() {
- err := client.Close()
- assert.NoError(t, err)
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- defer cancel()
-
- t.Run("ExecuteCommand captures output", func(t *testing.T) {
- output, err := client.ExecuteCommand(ctx, "echo hello")
- assert.NoError(t, err)
- assert.Contains(t, string(output), "hello")
- })
-
- t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) {
- err := client.ExecuteCommandWithIO(ctx, "echo world")
- assert.NoError(t, err)
- })
-
- t.Run("commands with flags work", func(t *testing.T) {
- output, err := client.ExecuteCommand(ctx, "echo -n test_flag")
- assert.NoError(t, err)
- assert.Equal(t, "test_flag", strings.TrimSpace(string(output)))
- })
-
- t.Run("non-zero exit codes don't return errors", func(t *testing.T) {
- var testCmd string
- if runtime.GOOS == "windows" {
- testCmd = "echo hello | Select-String notfound"
- } else {
- testCmd = "echo 'hello' | grep 'notfound'"
- }
- _, err := client.ExecuteCommand(ctx, testCmd)
- assert.NoError(t, err)
- })
-}
-
func TestSSHClient_ConnectionHandling(t *testing.T) {
server, serverAddr, _ := setupTestSSHServerAndClient(t)
defer func() {
@@ -154,59 +106,6 @@ func TestSSHClient_ConnectionHandling(t *testing.T) {
}
}
-func TestSSHClient_ContextCancellation(t *testing.T) {
- server, serverAddr, _ := setupTestSSHServerAndClient(t)
- defer func() {
- err := server.Stop()
- require.NoError(t, err)
- }()
-
- t.Run("connection with short timeout", func(t *testing.T) {
- ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
- defer cancel()
-
- currentUser := testutil.GetTestUsername(t)
- _, err := Dial(ctx, serverAddr, currentUser, DialOptions{
- InsecureSkipVerify: true,
- })
- if err != nil {
- // Check for actual timeout-related errors rather than string matching
- assert.True(t,
- errors.Is(err, context.DeadlineExceeded) ||
- errors.Is(err, context.Canceled) ||
- strings.Contains(err.Error(), "timeout"),
- "Expected timeout-related error, got: %v", err)
- }
- })
-
- t.Run("command execution cancellation", func(t *testing.T) {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- currentUser := testutil.GetTestUsername(t)
- client, err := Dial(ctx, serverAddr, currentUser, DialOptions{
- InsecureSkipVerify: true,
- })
- require.NoError(t, err)
- defer func() {
- if err := client.Close(); err != nil {
- t.Logf("client close error: %v", err)
- }
- }()
-
- cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
- defer cmdCancel()
-
- err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10")
- if err != nil {
- var exitMissingErr *cryptossh.ExitMissingError
- isValidCancellation := errors.Is(err, context.DeadlineExceeded) ||
- errors.Is(err, context.Canceled) ||
- errors.As(err, &exitMissingErr)
- assert.True(t, isValidCancellation, "Should handle command cancellation properly")
- }
- })
-}
-
func TestSSHClient_NoAuthMode(t *testing.T) {
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go
index 20695cb4d..e15330739 100644
--- a/client/ssh/config/manager.go
+++ b/client/ssh/config/manager.go
@@ -14,6 +14,7 @@ import (
log "github.com/sirupsen/logrus"
nbssh "github.com/netbirdio/netbird/client/ssh"
+ "github.com/netbirdio/netbird/shared/management/domain"
)
const (
@@ -218,11 +219,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string {
if peer.IPv6.IsValid() {
hostPatterns = append(hostPatterns, peer.IPv6.String())
}
- if peer.FQDN != "" {
+ // Peer FQDNs and hostnames originate from remote peers, so they must be
+ // validated as plain DNS names before being embedded in the ssh_config
+ // "Match host" pattern list. This prevents injection of arbitrary
+ // ssh_config directives via embedded quotes, whitespace, newlines, the
+ // comma pattern separator, or the "*"/"?" pattern metacharacters.
+ if domain.IsValidDomainNoWildcard(peer.FQDN) {
hostPatterns = append(hostPatterns, peer.FQDN)
+ } else if peer.FQDN != "" {
+ log.Warnf("skipping peer FQDN with invalid characters in SSH config: %q", peer.FQDN)
}
- if peer.Hostname != "" && peer.Hostname != peer.FQDN {
+ if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) {
hostPatterns = append(hostPatterns, peer.Hostname)
+ } else if peer.Hostname != "" && peer.Hostname != peer.FQDN {
+ log.Warnf("skipping peer hostname with invalid characters in SSH config: %q", peer.Hostname)
}
return hostPatterns
}
diff --git a/client/ssh/config/manager_test.go b/client/ssh/config/manager_test.go
index 8e6be40a3..f65d0ba6d 100644
--- a/client/ssh/config/manager_test.go
+++ b/client/ssh/config/manager_test.go
@@ -148,6 +148,45 @@ func TestManager_MatchHostFormat(t *testing.T) {
"should use Match host with comma-separated patterns")
}
+func TestManager_HostPatternInjection(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test")
+ require.NoError(t, err)
+ defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }()
+
+ manager := &Manager{
+ sshConfigDir: filepath.Join(tempDir, "ssh_config.d"),
+ sshConfigFile: "99-netbird.conf",
+ }
+
+ // A malicious peer FQDN/hostname attempts to break out of the Match host
+ // directive and inject arbitrary ssh_config (a ProxyCommand executing a
+ // command). It must be rejected, not written to the config.
+ peers := []PeerSSHInfo{
+ {
+ Hostname: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x",
+ IP: netip.MustParseAddr("100.125.1.1"),
+ FQDN: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x.nb.internal",
+ },
+ {Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"},
+ }
+
+ err = manager.SetupSSHClientConfig(peers)
+ require.NoError(t, err)
+
+ configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile)
+ content, err := os.ReadFile(configPath)
+ require.NoError(t, err)
+ configStr := string(content)
+
+ assert.NotContains(t, configStr, "ProxyCommand touch /tmp/pwned",
+ "injected directive must not appear in generated config")
+ assert.NotContains(t, configStr, "evil",
+ "malicious pattern must be dropped entirely")
+ // The valid peer must still be present, on a single Match host line.
+ assert.Contains(t, configStr, "Match host \"100.125.1.1,100.125.1.2,peer2.nb.internal,peer2\"",
+ "valid peers must survive, injected patterns dropped")
+}
+
func TestManager_ForcedSSHConfig(t *testing.T) {
// Set force environment variable
t.Setenv(EnvForceSSHConfig, "true")
diff --git a/client/ssh/proxy/proxy.go b/client/ssh/proxy/proxy.go
index 73b50122c..721810edb 100644
--- a/client/ssh/proxy/proxy.go
+++ b/client/ssh/proxy/proxy.go
@@ -9,7 +9,6 @@ import (
"net"
"os"
"strconv"
- "strings"
"sync"
"time"
@@ -17,8 +16,8 @@ import (
log "github.com/sirupsen/logrus"
cryptossh "golang.org/x/crypto/ssh"
"google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
+ "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
nbssh "github.com/netbirdio/netbird/client/ssh"
@@ -55,8 +54,8 @@ type SSHProxy struct {
}
func New(daemonAddr, targetHost string, targetPort int, stderr io.Writer, browserOpener func(string) error) (*SSHProxy, error) {
- grpcAddr := strings.TrimPrefix(daemonAddr, "tcp://")
- grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ target, opts := daemonaddr.DialTarget(daemonAddr)
+ grpcConn, err := grpc.NewClient(target, opts...)
if err != nil {
return nil, fmt.Errorf("connect to daemon: %w", err)
}
diff --git a/client/ssh/proxy/proxy_privileged_test.go b/client/ssh/proxy/proxy_privileged_test.go
new file mode 100644
index 000000000..94495a3ae
--- /dev/null
+++ b/client/ssh/proxy/proxy_privileged_test.go
@@ -0,0 +1,423 @@
+//go:build privileged
+
+package proxy
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "math/big"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "runtime"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ cryptossh "golang.org/x/crypto/ssh"
+
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ sshauth "github.com/netbirdio/netbird/client/ssh/auth"
+ "github.com/netbirdio/netbird/client/ssh/server"
+ "github.com/netbirdio/netbird/client/ssh/testutil"
+ nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
+ sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
+)
+
+func (m *mockDaemon) setJWTToken(token string) {
+ m.impl.jwtToken = token
+}
+
+func TestSSHProxy_Connect(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ // TODO: Windows test times out - user switching and command execution tested on Linux
+ if runtime.GOOS == "windows" {
+ t.Skip("Skipping on Windows - covered by Linux tests")
+ }
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ audience = "test-audience"
+ )
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+ defer jwksServer.Close()
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+ hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
+ require.NoError(t, err)
+
+ serverConfig := &server.Config{
+ HostKeyPEM: hostKey,
+ JWT: &server.JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{audience},
+ KeysLocation: jwksURL,
+ },
+ }
+ sshServer := server.New(serverConfig)
+ sshServer.SetAllowRootLogin(true)
+
+ // Configure SSH authorization for the test user
+ testUsername := testutil.GetTestUsername(t)
+ testJWTUser := "test-username"
+ testUserHash, err := sshuserhash.HashUserID(testJWTUser)
+ require.NoError(t, err)
+
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ testUsername: {0}, // Index 0 in AuthorizedUsers
+ },
+ }
+ sshServer.UpdateSSHAuth(authConfig)
+
+ sshServerAddr := server.StartTestServer(t, sshServer)
+ defer func() { _ = sshServer.Stop() }()
+
+ mockDaemon := startMockDaemon(t)
+ defer mockDaemon.stop()
+
+ host, portStr, err := net.SplitHostPort(sshServerAddr)
+ require.NoError(t, err)
+ port, err := strconv.Atoi(portStr)
+ require.NoError(t, err)
+
+ mockDaemon.setHostKey(host, hostPubKey)
+
+ validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
+ mockDaemon.setJWTToken(validToken)
+
+ proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
+ require.NoError(t, err)
+
+ clientConn, proxyConn := net.Pipe()
+ defer func() { _ = clientConn.Close() }()
+
+ origStdin := os.Stdin
+ origStdout := os.Stdout
+ defer func() {
+ os.Stdin = origStdin
+ os.Stdout = origStdout
+ }()
+
+ stdinReader, stdinWriter, err := os.Pipe()
+ require.NoError(t, err)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ require.NoError(t, err)
+
+ os.Stdin = stdinReader
+ os.Stdout = stdoutWriter
+
+ go func() {
+ _, _ = io.Copy(stdinWriter, proxyConn)
+ }()
+ go func() {
+ _, _ = io.Copy(proxyConn, stdoutReader)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ connectErrCh := make(chan error, 1)
+ go func() {
+ connectErrCh <- proxyInstance.Connect(ctx)
+ }()
+
+ sshConfig := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{},
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 3 * time.Second,
+ }
+
+ sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
+ require.NoError(t, err, "Should connect to proxy server")
+ defer func() { _ = sshClientConn.Close() }()
+
+ sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
+
+ session, err := sshClient.NewSession()
+ require.NoError(t, err, "Should create session through full proxy to backend")
+
+ outputCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ output, err := session.Output("echo hello-from-proxy")
+ outputCh <- output
+ errCh <- err
+ }()
+
+ select {
+ case output := <-outputCh:
+ err := <-errCh
+ require.NoError(t, err, "Command should execute successfully through proxy")
+ assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
+ case <-time.After(3 * time.Second):
+ t.Fatal("Command execution timed out")
+ }
+
+ _ = session.Close()
+ _ = sshClient.Close()
+ _ = clientConn.Close()
+ cancel()
+}
+
+// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
+// when forwarding commands to the backend. This is critical for tools like
+// Ansible that send commands such as:
+//
+// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
+//
+// The single quotes must be preserved so the backend shell receives the
+// subshell expression as a single argument to -c.
+func TestSSHProxy_CommandQuoting(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ sshClient, cleanup := setupProxySSHClient(t)
+ defer cleanup()
+
+ // These commands simulate what the SSH protocol delivers as exec payloads.
+ // When a user types: ssh host '/bin/sh -c "( echo hello )"'
+ // the local shell strips the outer single quotes, and the SSH exec request
+ // contains the raw string: /bin/sh -c "( echo hello )"
+ //
+ // The proxy must forward this string verbatim. Using session.Command()
+ // (shlex.Split + strings.Join) strips the inner double quotes, breaking
+ // the command on the backend.
+ tests := []struct {
+ name string
+ command string
+ expect string
+ }{
+ {
+ name: "subshell_in_double_quotes",
+ command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
+ expect: "from-subshell\nouter\n",
+ },
+ {
+ name: "printf_with_special_chars",
+ command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
+ expect: "hello world\n",
+ },
+ {
+ name: "nested_command_substitution",
+ command: `/bin/sh -c "echo $(echo nested)"`,
+ expect: "nested\n",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ session, err := sshClient.NewSession()
+ require.NoError(t, err)
+ defer func() { _ = session.Close() }()
+
+ var stderrBuf bytes.Buffer
+ session.Stderr = &stderrBuf
+
+ outputCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ output, err := session.Output(tc.command)
+ outputCh <- output
+ errCh <- err
+ }()
+
+ select {
+ case output := <-outputCh:
+ err := <-errCh
+ if stderrBuf.Len() > 0 {
+ t.Logf("stderr: %s", stderrBuf.String())
+ }
+ require.NoError(t, err, "command should succeed: %s", tc.command)
+ assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
+ case <-time.After(5 * time.Second):
+ t.Fatalf("command timed out: %s", tc.command)
+ }
+ })
+ }
+}
+
+// setupProxySSHClient creates a full proxy test environment and returns
+// an SSH client connected through the proxy to a backend NetBird SSH server.
+func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
+ t.Helper()
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ audience = "test-audience"
+ )
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+ hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
+ require.NoError(t, err)
+
+ serverConfig := &server.Config{
+ HostKeyPEM: hostKey,
+ JWT: &server.JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{audience},
+ KeysLocation: jwksURL,
+ },
+ }
+ sshServer := server.New(serverConfig)
+ sshServer.SetAllowRootLogin(true)
+
+ testUsername := testutil.GetTestUsername(t)
+ testJWTUser := "test-username"
+ testUserHash, err := sshuserhash.HashUserID(testJWTUser)
+ require.NoError(t, err)
+
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ testUsername: {0},
+ },
+ }
+ sshServer.UpdateSSHAuth(authConfig)
+
+ sshServerAddr := server.StartTestServer(t, sshServer)
+
+ mockDaemon := startMockDaemon(t)
+
+ host, portStr, err := net.SplitHostPort(sshServerAddr)
+ require.NoError(t, err)
+ port, err := strconv.Atoi(portStr)
+ require.NoError(t, err)
+
+ mockDaemon.setHostKey(host, hostPubKey)
+
+ validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
+ mockDaemon.setJWTToken(validToken)
+
+ proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
+ require.NoError(t, err)
+
+ origStdin := os.Stdin
+ origStdout := os.Stdout
+
+ stdinReader, stdinWriter, err := os.Pipe()
+ require.NoError(t, err)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ require.NoError(t, err)
+
+ os.Stdin = stdinReader
+ os.Stdout = stdoutWriter
+
+ clientConn, proxyConn := net.Pipe()
+
+ go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
+ go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
+ go func() {
+ _ = proxyInstance.Connect(ctx)
+ }()
+
+ sshConfig := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{},
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 5 * time.Second,
+ }
+
+ sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
+ require.NoError(t, err)
+
+ client := cryptossh.NewClient(sshClientConn, chans, reqs)
+
+ cleanupFn := func() {
+ _ = client.Close()
+ _ = clientConn.Close()
+ cancel()
+ os.Stdin = origStdin
+ os.Stdout = origStdout
+ _ = sshServer.Stop()
+ mockDaemon.stop()
+ jwksServer.Close()
+ }
+
+ return client, cleanupFn
+}
+
+func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
+ t.Helper()
+ privateKey, jwksJSON := generateTestJWKS(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(jwksJSON); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }))
+
+ return server, privateKey, server.URL
+}
+
+func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
+ t.Helper()
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ publicKey := &privateKey.PublicKey
+ n := publicKey.N.Bytes()
+ e := publicKey.E
+
+ jwk := nbjwt.JSONWebKey{
+ Kty: "RSA",
+ Kid: "test-key-id",
+ Use: "sig",
+ N: base64.RawURLEncoding.EncodeToString(n),
+ E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
+ }
+
+ jwks := nbjwt.Jwks{
+ Keys: []nbjwt.JSONWebKey{jwk},
+ }
+
+ jwksJSON, err := json.Marshal(jwks)
+ require.NoError(t, err)
+
+ return privateKey, jwksJSON
+}
+
+func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
+ t.Helper()
+ claims := jwt.MapClaims{
+ "iss": issuer,
+ "aud": audience,
+ "sub": user,
+ "exp": time.Now().Add(time.Hour).Unix(),
+ "iat": time.Now().Unix(),
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+ token.Header["kid"] = "test-key-id"
+
+ tokenString, err := token.SignedString(privateKey)
+ require.NoError(t, err)
+
+ return tokenString
+}
diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go
index b33d5f8f4..2795c786b 100644
--- a/client/ssh/proxy/proxy_test.go
+++ b/client/ssh/proxy/proxy_test.go
@@ -1,25 +1,12 @@
package proxy
import (
- "bytes"
"context"
- "crypto/rand"
- "crypto/rsa"
- "encoding/base64"
- "encoding/json"
"fmt"
- "io"
- "math/big"
"net"
- "net/http"
- "net/http/httptest"
"os"
- "runtime"
- "strconv"
"testing"
- "time"
- "github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
cryptossh "golang.org/x/crypto/ssh"
@@ -28,11 +15,7 @@ import (
"github.com/netbirdio/netbird/client/proto"
nbssh "github.com/netbirdio/netbird/client/ssh"
- sshauth "github.com/netbirdio/netbird/client/ssh/auth"
- "github.com/netbirdio/netbird/client/ssh/server"
"github.com/netbirdio/netbird/client/ssh/testutil"
- nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
- sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
)
func TestMain(m *testing.M) {
@@ -106,331 +89,6 @@ func TestSSHProxy_verifyHostKey(t *testing.T) {
})
}
-func TestSSHProxy_Connect(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping integration test in short mode")
- }
-
- // TODO: Windows test times out - user switching and command execution tested on Linux
- if runtime.GOOS == "windows" {
- t.Skip("Skipping on Windows - covered by Linux tests")
- }
-
- const (
- issuer = "https://test-issuer.example.com"
- audience = "test-audience"
- )
-
- jwksServer, privateKey, jwksURL := setupJWKSServer(t)
- defer jwksServer.Close()
-
- hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- require.NoError(t, err)
- hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
- require.NoError(t, err)
-
- serverConfig := &server.Config{
- HostKeyPEM: hostKey,
- JWT: &server.JWTConfig{
- Issuer: issuer,
- Audiences: []string{audience},
- KeysLocation: jwksURL,
- },
- }
- sshServer := server.New(serverConfig)
- sshServer.SetAllowRootLogin(true)
-
- // Configure SSH authorization for the test user
- testUsername := testutil.GetTestUsername(t)
- testJWTUser := "test-username"
- testUserHash, err := sshuserhash.HashUserID(testJWTUser)
- require.NoError(t, err)
-
- authConfig := &sshauth.Config{
- UserIDClaim: sshauth.DefaultUserIDClaim,
- AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
- MachineUsers: map[string][]uint32{
- testUsername: {0}, // Index 0 in AuthorizedUsers
- },
- }
- sshServer.UpdateSSHAuth(authConfig)
-
- sshServerAddr := server.StartTestServer(t, sshServer)
- defer func() { _ = sshServer.Stop() }()
-
- mockDaemon := startMockDaemon(t)
- defer mockDaemon.stop()
-
- host, portStr, err := net.SplitHostPort(sshServerAddr)
- require.NoError(t, err)
- port, err := strconv.Atoi(portStr)
- require.NoError(t, err)
-
- mockDaemon.setHostKey(host, hostPubKey)
-
- validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
- mockDaemon.setJWTToken(validToken)
-
- proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
- require.NoError(t, err)
-
- clientConn, proxyConn := net.Pipe()
- defer func() { _ = clientConn.Close() }()
-
- origStdin := os.Stdin
- origStdout := os.Stdout
- defer func() {
- os.Stdin = origStdin
- os.Stdout = origStdout
- }()
-
- stdinReader, stdinWriter, err := os.Pipe()
- require.NoError(t, err)
- stdoutReader, stdoutWriter, err := os.Pipe()
- require.NoError(t, err)
-
- os.Stdin = stdinReader
- os.Stdout = stdoutWriter
-
- go func() {
- _, _ = io.Copy(stdinWriter, proxyConn)
- }()
- go func() {
- _, _ = io.Copy(proxyConn, stdoutReader)
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- connectErrCh := make(chan error, 1)
- go func() {
- connectErrCh <- proxyInstance.Connect(ctx)
- }()
-
- sshConfig := &cryptossh.ClientConfig{
- User: testutil.GetTestUsername(t),
- Auth: []cryptossh.AuthMethod{},
- HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
- Timeout: 3 * time.Second,
- }
-
- sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
- require.NoError(t, err, "Should connect to proxy server")
- defer func() { _ = sshClientConn.Close() }()
-
- sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
-
- session, err := sshClient.NewSession()
- require.NoError(t, err, "Should create session through full proxy to backend")
-
- outputCh := make(chan []byte, 1)
- errCh := make(chan error, 1)
- go func() {
- output, err := session.Output("echo hello-from-proxy")
- outputCh <- output
- errCh <- err
- }()
-
- select {
- case output := <-outputCh:
- err := <-errCh
- require.NoError(t, err, "Command should execute successfully through proxy")
- assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
- case <-time.After(3 * time.Second):
- t.Fatal("Command execution timed out")
- }
-
- _ = session.Close()
- _ = sshClient.Close()
- _ = clientConn.Close()
- cancel()
-}
-
-// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
-// when forwarding commands to the backend. This is critical for tools like
-// Ansible that send commands such as:
-//
-// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
-//
-// The single quotes must be preserved so the backend shell receives the
-// subshell expression as a single argument to -c.
-func TestSSHProxy_CommandQuoting(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping integration test in short mode")
- }
-
- sshClient, cleanup := setupProxySSHClient(t)
- defer cleanup()
-
- // These commands simulate what the SSH protocol delivers as exec payloads.
- // When a user types: ssh host '/bin/sh -c "( echo hello )"'
- // the local shell strips the outer single quotes, and the SSH exec request
- // contains the raw string: /bin/sh -c "( echo hello )"
- //
- // The proxy must forward this string verbatim. Using session.Command()
- // (shlex.Split + strings.Join) strips the inner double quotes, breaking
- // the command on the backend.
- tests := []struct {
- name string
- command string
- expect string
- }{
- {
- name: "subshell_in_double_quotes",
- command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
- expect: "from-subshell\nouter\n",
- },
- {
- name: "printf_with_special_chars",
- command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
- expect: "hello world\n",
- },
- {
- name: "nested_command_substitution",
- command: `/bin/sh -c "echo $(echo nested)"`,
- expect: "nested\n",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- session, err := sshClient.NewSession()
- require.NoError(t, err)
- defer func() { _ = session.Close() }()
-
- var stderrBuf bytes.Buffer
- session.Stderr = &stderrBuf
-
- outputCh := make(chan []byte, 1)
- errCh := make(chan error, 1)
- go func() {
- output, err := session.Output(tc.command)
- outputCh <- output
- errCh <- err
- }()
-
- select {
- case output := <-outputCh:
- err := <-errCh
- if stderrBuf.Len() > 0 {
- t.Logf("stderr: %s", stderrBuf.String())
- }
- require.NoError(t, err, "command should succeed: %s", tc.command)
- assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
- case <-time.After(5 * time.Second):
- t.Fatalf("command timed out: %s", tc.command)
- }
- })
- }
-}
-
-// setupProxySSHClient creates a full proxy test environment and returns
-// an SSH client connected through the proxy to a backend NetBird SSH server.
-func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
- t.Helper()
-
- const (
- issuer = "https://test-issuer.example.com"
- audience = "test-audience"
- )
-
- jwksServer, privateKey, jwksURL := setupJWKSServer(t)
-
- hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- require.NoError(t, err)
- hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
- require.NoError(t, err)
-
- serverConfig := &server.Config{
- HostKeyPEM: hostKey,
- JWT: &server.JWTConfig{
- Issuer: issuer,
- Audiences: []string{audience},
- KeysLocation: jwksURL,
- },
- }
- sshServer := server.New(serverConfig)
- sshServer.SetAllowRootLogin(true)
-
- testUsername := testutil.GetTestUsername(t)
- testJWTUser := "test-username"
- testUserHash, err := sshuserhash.HashUserID(testJWTUser)
- require.NoError(t, err)
-
- authConfig := &sshauth.Config{
- UserIDClaim: sshauth.DefaultUserIDClaim,
- AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
- MachineUsers: map[string][]uint32{
- testUsername: {0},
- },
- }
- sshServer.UpdateSSHAuth(authConfig)
-
- sshServerAddr := server.StartTestServer(t, sshServer)
-
- mockDaemon := startMockDaemon(t)
-
- host, portStr, err := net.SplitHostPort(sshServerAddr)
- require.NoError(t, err)
- port, err := strconv.Atoi(portStr)
- require.NoError(t, err)
-
- mockDaemon.setHostKey(host, hostPubKey)
-
- validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
- mockDaemon.setJWTToken(validToken)
-
- proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
- require.NoError(t, err)
-
- origStdin := os.Stdin
- origStdout := os.Stdout
-
- stdinReader, stdinWriter, err := os.Pipe()
- require.NoError(t, err)
- stdoutReader, stdoutWriter, err := os.Pipe()
- require.NoError(t, err)
-
- os.Stdin = stdinReader
- os.Stdout = stdoutWriter
-
- clientConn, proxyConn := net.Pipe()
-
- go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
- go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-
- go func() {
- _ = proxyInstance.Connect(ctx)
- }()
-
- sshConfig := &cryptossh.ClientConfig{
- User: testutil.GetTestUsername(t),
- Auth: []cryptossh.AuthMethod{},
- HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
- Timeout: 5 * time.Second,
- }
-
- sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
- require.NoError(t, err)
-
- client := cryptossh.NewClient(sshClientConn, chans, reqs)
-
- cleanupFn := func() {
- _ = client.Close()
- _ = clientConn.Close()
- cancel()
- os.Stdin = origStdin
- os.Stdout = origStdout
- _ = sshServer.Stop()
- mockDaemon.stop()
- jwksServer.Close()
- }
-
- return client, cleanupFn
-}
-
type mockDaemonServer struct {
proto.UnimplementedDaemonServiceServer
hostKeys map[string][]byte
@@ -492,10 +150,6 @@ func (m *mockDaemon) setHostKey(addr string, pubKey []byte) {
m.impl.hostKeys[addr] = pubKey
}
-func (m *mockDaemon) setJWTToken(token string) {
- m.impl.jwtToken = token
-}
-
func (m *mockDaemon) stop() {
if m.server != nil {
m.server.Stop()
@@ -508,63 +162,3 @@ func mustParsePublicKey(t *testing.T, pubKeyBytes []byte) cryptossh.PublicKey {
require.NoError(t, err)
return pubKey
}
-
-func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
- t.Helper()
- privateKey, jwksJSON := generateTestJWKS(t)
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- if _, err := w.Write(jwksJSON); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
- }))
-
- return server, privateKey, server.URL
-}
-
-func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
- t.Helper()
- privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
- require.NoError(t, err)
-
- publicKey := &privateKey.PublicKey
- n := publicKey.N.Bytes()
- e := publicKey.E
-
- jwk := nbjwt.JSONWebKey{
- Kty: "RSA",
- Kid: "test-key-id",
- Use: "sig",
- N: base64.RawURLEncoding.EncodeToString(n),
- E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
- }
-
- jwks := nbjwt.Jwks{
- Keys: []nbjwt.JSONWebKey{jwk},
- }
-
- jwksJSON, err := json.Marshal(jwks)
- require.NoError(t, err)
-
- return privateKey, jwksJSON
-}
-
-func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
- t.Helper()
- claims := jwt.MapClaims{
- "iss": issuer,
- "aud": audience,
- "sub": user,
- "exp": time.Now().Add(time.Hour).Unix(),
- "iat": time.Now().Unix(),
- }
-
- token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
- token.Header["kid"] = "test-key-id"
-
- tokenString, err := token.SignedString(privateKey)
- require.NoError(t, err)
-
- return tokenString
-}
diff --git a/client/ssh/server/executor_unix_privileged_test.go b/client/ssh/server/executor_unix_privileged_test.go
new file mode 100644
index 000000000..f1b0805d9
--- /dev/null
+++ b/client/ssh/server/executor_unix_privileged_test.go
@@ -0,0 +1,66 @@
+//go:build unix && privileged
+
+package server
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
+ pd := NewPrivilegeDropper()
+
+ config := ExecutorConfig{
+ UID: 1000,
+ GID: 1000,
+ Groups: []uint32{1000, 1001},
+ WorkingDir: "/home/testuser",
+ Shell: "/bin/bash",
+ Command: "ls -la",
+ }
+
+ cmd, err := pd.CreateExecutorCommand(context.Background(), config)
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+
+ // Verify the command is calling netbird ssh exec
+ assert.Contains(t, cmd.Args, "ssh")
+ assert.Contains(t, cmd.Args, "exec")
+ assert.Contains(t, cmd.Args, "--uid")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "--gid")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "--groups")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "1001")
+ assert.Contains(t, cmd.Args, "--working-dir")
+ assert.Contains(t, cmd.Args, "/home/testuser")
+ assert.Contains(t, cmd.Args, "--shell")
+ assert.Contains(t, cmd.Args, "/bin/bash")
+ assert.Contains(t, cmd.Args, "--cmd")
+ assert.Contains(t, cmd.Args, "ls -la")
+}
+
+func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
+ pd := NewPrivilegeDropper()
+
+ config := ExecutorConfig{
+ UID: 1000,
+ GID: 1000,
+ Groups: []uint32{1000},
+ WorkingDir: "/home/testuser",
+ Shell: "/bin/bash",
+ Command: "",
+ }
+
+ cmd, err := pd.CreateExecutorCommand(context.Background(), config)
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+
+ // Verify no command mode (command is empty so no --cmd flag)
+ assert.NotContains(t, cmd.Args, "--cmd")
+ assert.NotContains(t, cmd.Args, "--interactive")
+}
diff --git a/client/ssh/server/executor_unix_test.go b/client/ssh/server/executor_unix_test.go
index 0c5108f57..171e78b83 100644
--- a/client/ssh/server/executor_unix_test.go
+++ b/client/ssh/server/executor_unix_test.go
@@ -73,61 +73,6 @@ func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) {
}
}
-func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
- pd := NewPrivilegeDropper()
-
- config := ExecutorConfig{
- UID: 1000,
- GID: 1000,
- Groups: []uint32{1000, 1001},
- WorkingDir: "/home/testuser",
- Shell: "/bin/bash",
- Command: "ls -la",
- }
-
- cmd, err := pd.CreateExecutorCommand(context.Background(), config)
- require.NoError(t, err)
- require.NotNil(t, cmd)
-
- // Verify the command is calling netbird ssh exec
- assert.Contains(t, cmd.Args, "ssh")
- assert.Contains(t, cmd.Args, "exec")
- assert.Contains(t, cmd.Args, "--uid")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "--gid")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "--groups")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "1001")
- assert.Contains(t, cmd.Args, "--working-dir")
- assert.Contains(t, cmd.Args, "/home/testuser")
- assert.Contains(t, cmd.Args, "--shell")
- assert.Contains(t, cmd.Args, "/bin/bash")
- assert.Contains(t, cmd.Args, "--cmd")
- assert.Contains(t, cmd.Args, "ls -la")
-}
-
-func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
- pd := NewPrivilegeDropper()
-
- config := ExecutorConfig{
- UID: 1000,
- GID: 1000,
- Groups: []uint32{1000},
- WorkingDir: "/home/testuser",
- Shell: "/bin/bash",
- Command: "",
- }
-
- cmd, err := pd.CreateExecutorCommand(context.Background(), config)
- require.NoError(t, err)
- require.NotNil(t, cmd)
-
- // Verify no command mode (command is empty so no --cmd flag)
- assert.NotContains(t, cmd.Args, "--cmd")
- assert.NotContains(t, cmd.Args, "--interactive")
-}
-
// TestPrivilegeDropper_ActualPrivilegeDrop tests actual privilege dropping
// This test requires root privileges and will be skipped if not running as root
func TestPrivilegeDropper_ActualPrivilegeDrop(t *testing.T) {
diff --git a/client/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go
index 18edb2fdf..a3a9641f8 100644
--- a/client/ssh/server/getent_unix.go
+++ b/client/ssh/server/getent_unix.go
@@ -69,7 +69,8 @@ func parseGetentPasswd(output string) (*user.User, string, error) {
// validateGetentInput checks that the input is safe to pass to getent or id.
// Allows POSIX usernames, numeric UIDs, and common NSS extensions
-// (@ for Kerberos, $ for Samba, + for NIS compat).
+// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is
+// rejected so the input can never be parsed as a command-line flag.
func validateGetentInput(input string) bool {
maxLen := 32
if runtime.GOOS == "linux" {
@@ -80,6 +81,10 @@ func validateGetentInput(input string) bool {
return false
}
+ if input[0] == '-' {
+ return false
+ }
+
for _, r := range input {
if isAllowedGetentChar(r) {
continue
diff --git a/client/ssh/server/getent_unix_test.go b/client/ssh/server/getent_unix_test.go
index e44563b79..a73214e17 100644
--- a/client/ssh/server/getent_unix_test.go
+++ b/client/ssh/server/getent_unix_test.go
@@ -157,6 +157,9 @@ func TestValidateGetentInput(t *testing.T) {
{"numeric UID", "1001", true},
{"dots and underscores", "alice.bob_test", true},
{"hyphen", "alice-bob", true},
+ {"leading hyphen rejected", "-i", false},
+ {"leading double hyphen rejected", "--no-idn", false},
+ {"lone hyphen rejected", "-", false},
{"kerberos principal", "user@REALM", true},
{"samba machine account", "MACHINE$", true},
{"NIS compat", "+user", true},
diff --git a/client/ssh/server/test.go b/client/ssh/server/test.go
index 454d3afa3..e2be0551c 100644
--- a/client/ssh/server/test.go
+++ b/client/ssh/server/test.go
@@ -1,3 +1,11 @@
+// This file is intentionally named test.go (not test_test.go) so the exported
+// StartTestServer helper is visible to the ssh/proxy and ssh/client external
+// test packages, not just this package's own tests. The //go:build !js tag
+// keeps its "testing" import — and the whole testing/flag/regexp transitive
+// chain it drags in — out of the wasm client, which links ssh/server through
+// the engine but never runs Go tests under GOOS=js.
+//go:build !js
+
package server
import (
diff --git a/client/status/status.go b/client/status/status.go
index 5b815aaa3..e8276d0fa 100644
--- a/client/status/status.go
+++ b/client/status/status.go
@@ -55,6 +55,10 @@ type ConvertOptions struct {
IPsFilter map[string]struct{}
ConnectionTypeFilter string
ProfileName string
+ // SessionExpiresAt is the absolute UTC instant at which the peer's SSO
+ // session expires. Zero when the peer is not SSO-tracked or login
+ // expiration is disabled. Sourced from StatusResponse.SessionExpiresAt.
+ SessionExpiresAt time.Time
}
type PeerStateDetailOutput struct {
@@ -155,6 +159,11 @@ type OutputOverview struct {
LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"`
ProfileName string `json:"profileName" yaml:"profileName"`
SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"`
+ // SessionExpiresAt is the absolute UTC instant at which the peer's SSO
+ // session expires. nil when the peer is not SSO-tracked or login
+ // expiration is disabled. Pointer (rather than zero-value time.Time) so
+ // JSON / YAML omit the field entirely with `,omitempty`.
+ SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty" yaml:"sessionExpiresAt,omitempty"`
}
// ConvertToStatusOutputOverview converts protobuf status to the output overview.
@@ -201,6 +210,10 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
ProfileName: opts.ProfileName,
SSHServerState: sshServerOverview,
}
+ if !opts.SessionExpiresAt.IsZero() {
+ t := opts.SessionExpiresAt
+ overview.SessionExpiresAt = &t
+ }
if opts.Anonymize {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
@@ -547,6 +560,15 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
+ var sessionExpiryString string
+ if o.SessionExpiresAt != nil && !o.SessionExpiresAt.IsZero() {
+ sessionExpiryString = fmt.Sprintf(
+ "Session expires: %s (in %s)\n",
+ o.SessionExpiresAt.Format(time.RFC3339),
+ FormatRemainingDuration(time.Until(*o.SessionExpiresAt)),
+ )
+ }
+
var forwardingRulesString string
if o.NumberOfForwardingRules > 0 {
forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules)
@@ -593,6 +615,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 +635,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
sshServerStatus,
networks,
forwardingRulesString,
+ sessionExpiryString,
peersCountString,
)
return summary
@@ -722,6 +746,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
}
+ pbFullStatus.Events = fullStatus.Events
+
return &pbFullStatus
}
@@ -1025,3 +1051,57 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command)
}
}
+
+// FormatRemainingDuration renders a time.Duration for the "Session expires"
+// line. Examples: "2h 15m", "47m 12s", "8s", "expired 3m ago".
+//
+// Granularity drops to seconds only under a minute, otherwise minutes are
+// the smallest unit shown — sub-minute precision is noise for a deadline
+// that's hours or days out.
+func FormatRemainingDuration(d time.Duration) string {
+ if d <= 0 {
+ return "expired " + HumaniseDuration(-d) + " ago"
+ }
+ return HumaniseDuration(d)
+}
+
+// HumaniseDuration renders a positive duration in compact form (e.g.
+// "2h 15m", "47m", "8s"). Exposed alongside FormatRemainingDuration so
+// callers that don't need the "expired … ago" wording can format
+// positive durations directly.
+func HumaniseDuration(d time.Duration) string {
+ if d < time.Minute {
+ s := int(d.Round(time.Second).Seconds())
+ if s < 1 {
+ s = 1
+ }
+ return fmt.Sprintf("%ds", s)
+ }
+
+ const (
+ day = 24 * time.Hour
+ hour = time.Hour
+ minute = time.Minute
+ )
+
+ days := int64(d / day)
+ d -= time.Duration(days) * day
+ hours := int64(d / hour)
+ d -= time.Duration(hours) * hour
+ minutes := int64(d / minute)
+
+ switch {
+ case days > 0:
+ if hours == 0 {
+ return fmt.Sprintf("%dd", days)
+ }
+ return fmt.Sprintf("%dd %dh", days, hours)
+ case hours > 0:
+ if minutes == 0 {
+ return fmt.Sprintf("%dh", hours)
+ }
+ return fmt.Sprintf("%dh %dm", hours, minutes)
+ default:
+ return fmt.Sprintf("%dm", minutes)
+ }
+}
diff --git a/client/status/status_test.go b/client/status/status_test.go
index 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..3c71573bb 100644
--- a/client/system/info_android.go
+++ b/client/system/info_android.go
@@ -50,7 +50,7 @@ func GetInfo(ctx context.Context) *Info {
}
// checkFileAndProcess checks if the file path exists and if a process is running at that path.
-func checkFileAndProcess(paths []string) ([]File, error) {
+func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) {
return []File{}, nil
}
diff --git a/client/system/info_darwin.go b/client/system/info_darwin.go
index 4a31920ec..e7bf367f6 100644
--- a/client/system/info_darwin.go
+++ b/client/system/info_darwin.go
@@ -32,7 +32,7 @@ func GetInfo(ctx context.Context) *Info {
sysName := string(bytes.Split(utsname.Sysname[:], []byte{0})[0])
machine := string(bytes.Split(utsname.Machine[:], []byte{0})[0])
release := string(bytes.Split(utsname.Release[:], []byte{0})[0])
- swVersion, err := exec.Command("sw_vers", "-productVersion").Output()
+ swVersion, err := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output()
if err != nil {
log.Warnf("got an error while retrieving macOS version with sw_vers, error: %s. Using darwin version instead.\n", err)
swVersion = []byte(release)
diff --git a/client/system/info_ios.go b/client/system/info_ios.go
index ad42b1edf..1b0c084b3 100644
--- a/client/system/info_ios.go
+++ b/client/system/info_ios.go
@@ -105,7 +105,7 @@ func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool {
}
// checkFileAndProcess checks if the file path exists and if a process is running at that path.
-func checkFileAndProcess(paths []string) ([]File, error) {
+func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) {
return []File{}, nil
}
diff --git a/client/system/info_js.go b/client/system/info_js.go
index 994d439a7..f32532881 100644
--- a/client/system/info_js.go
+++ b/client/system/info_js.go
@@ -103,7 +103,7 @@ func collectLocationInfo(info *Info) {
}
}
-func checkFileAndProcess(_ []string) ([]File, error) {
+func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) {
return []File{}, nil
}
diff --git a/client/system/info_test.go b/client/system/info_test.go
index 27821f3c5..a7fa02197 100644
--- a/client/system/info_test.go
+++ b/client/system/info_test.go
@@ -2,7 +2,9 @@ package system
import (
"context"
+ "net/netip"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/metadata"
@@ -34,6 +36,20 @@ func Test_CustomHostname(t *testing.T) {
assert.Equal(t, want, got.Hostname)
}
+func TestGetInfoWithChecksTimeout_Success(t *testing.T) {
+ info, ok := GetInfoWithChecksTimeout(context.Background(), 30*time.Second, nil)
+ assert.True(t, ok, "expected gathering to complete within the timeout")
+ assert.NotNil(t, info)
+}
+
+func TestGetInfoWithChecksTimeout_Timeout(t *testing.T) {
+ // A 1ns budget expires before the (real) system-info gathering can finish, so the
+ // caller must get (nil, false) instead of blocking on the in-flight goroutine.
+ info, ok := GetInfoWithChecksTimeout(context.Background(), time.Nanosecond, nil)
+ assert.False(t, ok, "expected timeout to be reported")
+ assert.Nil(t, info)
+}
+
func Test_NetAddresses(t *testing.T) {
addr, err := networkAddresses()
if err != nil {
@@ -43,3 +59,42 @@ func Test_NetAddresses(t *testing.T) {
t.Errorf("no network addresses found")
}
}
+
+func TestInfo_RemoveAddresses(t *testing.T) {
+ addr := func(cidr string) NetworkAddress {
+ return NetworkAddress{NetIP: netip.MustParsePrefix(cidr)}
+ }
+
+ info := &Info{
+ NetworkAddresses: []NetworkAddress{
+ addr("192.168.1.7/24"),
+ addr("100.76.70.97/32"), // overlay v4 (host mask /32)
+ addr("2001:818:c51b:4800:845:a65d:ae6f:623f/64"), // real global v6
+ addr("fd00:1234::1/64"), // overlay v6
+ },
+ }
+
+ // Overlay addresses as the engine knows them, with a different mask (/16, /64).
+ info.removeAddresses(
+ netip.MustParseAddr("100.76.70.97"),
+ netip.MustParseAddr("fd00:1234::1"),
+ )
+
+ want := []string{"192.168.1.7/24", "2001:818:c51b:4800:845:a65d:ae6f:623f/64"}
+ if len(info.NetworkAddresses) != len(want) {
+ t.Fatalf("got %d addresses, want %d: %v", len(info.NetworkAddresses), len(want), info.NetworkAddresses)
+ }
+ for i, w := range want {
+ if got := info.NetworkAddresses[i].NetIP.String(); got != w {
+ t.Errorf("address[%d] = %s, want %s", i, got, w)
+ }
+ }
+}
+
+func TestInfo_RemoveAddresses_NoOp(t *testing.T) {
+ info := &Info{NetworkAddresses: []NetworkAddress{{NetIP: netip.MustParsePrefix("10.0.0.1/24")}}}
+ info.removeAddresses()
+ if len(info.NetworkAddresses) != 1 {
+ t.Errorf("expected no change with empty input, got %v", info.NetworkAddresses)
+ }
+}
diff --git a/client/system/network_addr.go b/client/system/network_addr.go
index 5423cf8ad..44260a938 100644
--- a/client/system/network_addr.go
+++ b/client/system/network_addr.go
@@ -46,7 +46,9 @@ func toNetworkAddress(address net.Addr, mac string) (NetworkAddress, bool) {
if !ok {
return NetworkAddress{}, false
}
- if ipNet.IP.IsLoopback() {
+ // Skip link-local and multicast: they carry no routable peer info and the
+ // IPv6 link-local of a flapping NIC churns the meta on every up/down.
+ if ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() || ipNet.IP.IsMulticast() {
return NetworkAddress{}, false
}
prefix, err := netip.ParsePrefix(ipNet.String())
diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go
new file mode 100644
index 000000000..a5f9c4279
--- /dev/null
+++ b/client/system/network_addr_test.go
@@ -0,0 +1,45 @@
+//go:build !ios
+
+package system
+
+import (
+ "net"
+ "testing"
+)
+
+func mustIPNet(t *testing.T, cidr string) *net.IPNet {
+ t.Helper()
+ ip, ipNet, err := net.ParseCIDR(cidr)
+ if err != nil {
+ t.Fatalf("parse %q: %v", cidr, err)
+ }
+ ipNet.IP = ip
+ return ipNet
+}
+
+func TestToNetworkAddress_Filtering(t *testing.T) {
+ const mac = "c8:4b:d6:b6:04:ac"
+
+ tests := []struct {
+ name string
+ cidr string
+ want bool
+ }{
+ {"ipv4 global", "10.65.16.181/23", true},
+ {"ipv6 global", "2620:52:0:4110:102d:6a98:ee75:8b92/64", true},
+ {"ipv4 loopback", "127.0.0.1/8", false},
+ {"ipv6 loopback", "::1/128", false},
+ {"ipv6 link-local", "fe80::871:4c25:23d7:2529/64", false},
+ {"ipv4 link-local", "169.254.1.2/16", false},
+ {"ipv6 multicast", "ff02::1/128", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, got := toNetworkAddress(mustIPNet(t, tt.cidr), mac)
+ if got != tt.want {
+ t.Errorf("toNetworkAddress(%s) ok = %v, want %v", tt.cidr, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/client/system/process.go b/client/system/process.go
index 87e21eb9d..fefa7d913 100644
--- a/client/system/process.go
+++ b/client/system/process.go
@@ -3,24 +3,30 @@
package system
import (
+ "context"
"os"
"slices"
- "github.com/shirou/gopsutil/v3/process"
+ "github.com/shirou/gopsutil/v4/process"
)
-// getRunningProcesses returns a list of running process paths.
-func getRunningProcesses() ([]string, error) {
- processIDs, err := process.Pids()
+// getRunningProcesses returns a list of running process paths. The context bounds the work:
+// the per-PID loop bails as soon as ctx is done, and the gopsutil calls honor it where they
+// can, so a stuck enumeration cannot run unbounded.
+func getRunningProcesses(ctx context.Context) ([]string, error) {
+ processIDs, err := process.PidsWithContext(ctx)
if err != nil {
return nil, err
}
processMap := make(map[string]bool)
for _, pID := range processIDs {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
p := &process.Process{Pid: pID}
- path, _ := p.Exe()
+ path, _ := p.ExeWithContext(ctx)
if path != "" {
processMap[path] = false
}
@@ -35,18 +41,21 @@ func getRunningProcesses() ([]string, error) {
}
// checkFileAndProcess checks if the file path exists and if a process is running at that path.
-func checkFileAndProcess(paths []string) ([]File, error) {
+func checkFileAndProcess(ctx context.Context, paths []string) ([]File, error) {
files := make([]File, len(paths))
if len(paths) == 0 {
return files, nil
}
- runningProcesses, err := getRunningProcesses()
+ runningProcesses, err := getRunningProcesses(ctx)
if err != nil {
return nil, err
}
for i, path := range paths {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
file := File{Path: path}
_, err := os.Stat(path)
diff --git a/client/system/process_test.go b/client/system/process_test.go
index 505808a9e..9d0a6b935 100644
--- a/client/system/process_test.go
+++ b/client/system/process_test.go
@@ -1,15 +1,16 @@
package system
import (
+ "context"
"testing"
- "github.com/shirou/gopsutil/v3/process"
+ "github.com/shirou/gopsutil/v4/process"
)
func Benchmark_getRunningProcesses(b *testing.B) {
b.Run("getRunningProcesses new", func(b *testing.B) {
for i := 0; i < b.N; i++ {
- ps, err := getRunningProcesses()
+ ps, err := getRunningProcesses(context.Background())
if err != nil {
b.Fatalf("unexpected error: %v", err)
}
@@ -29,12 +30,38 @@ func Benchmark_getRunningProcesses(b *testing.B) {
}
}
})
- s, _ := getRunningProcesses()
+ s, _ := getRunningProcesses(context.Background())
b.Logf("getRunningProcesses returned %d processes", len(s))
s, _ = getRunningProcessesOld()
b.Logf("getRunningProcessesOld returned %d processes", len(s))
}
+func TestCheckFileAndProcess_ContextCanceled(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ // With a canceled context and non-empty paths the gathering must bail with an error
+ // instead of running the (potentially blocking) process scan / stat loop.
+ if _, err := checkFileAndProcess(ctx, []string{"/does/not/exist"}); err == nil {
+ t.Fatal("expected error on canceled context, got nil")
+ }
+}
+
+func TestCheckFileAndProcess_EmptyPaths(t *testing.T) {
+ // No check paths means no work to do: it must return immediately with no error,
+ // even on a canceled context (nothing to scan or stat).
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ files, err := checkFileAndProcess(ctx, nil)
+ if err != nil {
+ t.Fatalf("unexpected error for empty paths: %v", err)
+ }
+ if len(files) != 0 {
+ t.Fatalf("expected no files, got %d", len(files))
+ }
+}
+
func getRunningProcessesOld() ([]string, error) {
processes, err := process.Processes()
if err != nil {
diff --git a/client/test/json-socket-docker.sh b/client/test/json-socket-docker.sh
new file mode 100755
index 000000000..a878f13d6
--- /dev/null
+++ b/client/test/json-socket-docker.sh
@@ -0,0 +1,223 @@
+#!/usr/bin/env bash
+set -eEuo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: client/test/json-socket-docker.sh [tcp|unix|both]
+
+Builds the NetBird client Docker image from the local source tree, starts
+`netbird service run` in a container with --enable-json-socket, and verifies
+that the HTTP/JSON daemon gateway responds to Status requests.
+
+Modes:
+ tcp Validate tcp://0.0.0.0:8080 via a published localhost port (default)
+ unix Validate unix:///sock/netbird-http.sock via a bind-mounted socket dir
+ both Run both validations
+
+Environment:
+ CONTAINER_RUNTIME docker or podman. Auto-detected if unset.
+ IMAGE Image tag to build. Default: netbird-json-socket-test:local
+ TARGETARCH Go/Docker target arch. Default: `go env GOARCH`
+ PLATFORM Docker platform. Default: linux/$TARGETARCH
+ WAIT_TIMEOUT Seconds to wait for the JSON socket. Default: 30
+EOF
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
+ usage
+ exit 0
+fi
+
+MODE="${1:-tcp}"
+case "${MODE}" in
+ tcp|unix|both) ;;
+ *)
+ usage >&2
+ echo "invalid mode: ${MODE}" >&2
+ exit 2
+ ;;
+esac
+
+RUNTIME="${CONTAINER_RUNTIME:-}"
+if [[ -z "${RUNTIME}" ]]; then
+ if command -v docker >/dev/null 2>&1; then
+ RUNTIME=docker
+ elif command -v podman >/dev/null 2>&1; then
+ RUNTIME=podman
+ else
+ echo "docker or podman is required" >&2
+ exit 127
+ fi
+fi
+if ! command -v "${RUNTIME}" >/dev/null 2>&1; then
+ echo "container runtime not found: ${RUNTIME}" >&2
+ exit 127
+fi
+
+if ! command -v curl >/dev/null 2>&1; then
+ echo "curl is required" >&2
+ exit 127
+fi
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+IMAGE="${IMAGE:-netbird-json-socket-test:local}"
+TARGETARCH="${TARGETARCH:-$(go env GOARCH)}"
+PLATFORM="${PLATFORM:-linux/${TARGETARCH}}"
+WAIT_TIMEOUT="${WAIT_TIMEOUT:-30}"
+TMP_DIR="$(mktemp -d)"
+CONTAINERS=()
+
+cleanup() {
+ local status=$?
+ for container in "${CONTAINERS[@]:-}"; do
+ "${RUNTIME}" rm -f "${container}" >/dev/null 2>&1 || true
+ done
+ rm -rf "${TMP_DIR}"
+ exit "${status}"
+}
+trap cleanup EXIT
+
+build_image() {
+ echo "==> Building Linux ${TARGETARCH} netbird binary"
+ mkdir -p "${TMP_DIR}/context/client"
+ cp "${ROOT_DIR}/client/Dockerfile" "${TMP_DIR}/context/Dockerfile"
+ cp "${ROOT_DIR}/client/netbird-entrypoint.sh" "${TMP_DIR}/context/client/netbird-entrypoint.sh"
+
+ (cd "${ROOT_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" go build -o "${TMP_DIR}/context/netbird" ./client)
+
+ echo "==> Building ${IMAGE} for ${PLATFORM}"
+ "${RUNTIME}" build \
+ --platform "${PLATFORM}" \
+ --build-arg NETBIRD_BINARY=netbird \
+ -t "${IMAGE}" \
+ -f "${TMP_DIR}/context/Dockerfile" \
+ "${TMP_DIR}/context"
+}
+
+pick_port() {
+ python3 - <<'PY'
+import socket
+sock = socket.socket()
+sock.bind(("127.0.0.1", 0))
+print(sock.getsockname()[1])
+sock.close()
+PY
+}
+
+assert_status_json() {
+ local response_file="$1"
+ if command -v python3 >/dev/null 2>&1; then
+ python3 - "${response_file}" <<'PY'
+import json
+import sys
+with open(sys.argv[1], encoding="utf-8") as fh:
+ data = json.load(fh)
+if not data.get("status"):
+ raise SystemExit("missing non-empty status field")
+if "daemonVersion" not in data:
+ raise SystemExit("missing daemonVersion field")
+print(f"status={data['status']} daemonVersion={data['daemonVersion']}")
+PY
+ else
+ grep -q '"status"' "${response_file}"
+ grep -q '"daemonVersion"' "${response_file}"
+ cat "${response_file}"
+ fi
+}
+
+container_logs() {
+ local container="$1"
+ echo "---- ${container} logs ----" >&2
+ "${RUNTIME}" logs "${container}" >&2 || true
+ echo "--------------------------" >&2
+}
+
+wait_for_http_status() {
+ local container="$1"
+ local response="${TMP_DIR}/${container}.json"
+ local curl_err="${TMP_DIR}/${container}.curl.err"
+ shift
+ local deadline=$((SECONDS + WAIT_TIMEOUT))
+
+ while (( SECONDS < deadline )); do
+ if curl -fsS "$@" \
+ -X POST \
+ -H 'Content-Type: application/json' \
+ -d '{}' \
+ -o "${response}" \
+ 2>"${curl_err}"; then
+ assert_status_json "${response}"
+ return 0
+ fi
+
+ if ! "${RUNTIME}" ps --format '{{.Names}}' | grep -Fxq "${container}"; then
+ echo "container exited before JSON socket became ready" >&2
+ container_logs "${container}"
+ return 1
+ fi
+ sleep 1
+ done
+
+ echo "timed out waiting for JSON socket after ${WAIT_TIMEOUT}s" >&2
+ cat "${curl_err}" >&2 || true
+ container_logs "${container}"
+ return 1
+}
+
+run_netbird_container() {
+ local container="$1"
+ local json_socket="$2"
+ shift 2
+
+ CONTAINERS+=("${container}")
+ "${RUNTIME}" run --rm -d \
+ --name "${container}" \
+ -e NB_STATE_DIR=/tmp/netbird-state \
+ --entrypoint /usr/local/bin/netbird \
+ "$@" \
+ "${IMAGE}" \
+ --log-file console \
+ --daemon-addr unix:///tmp/netbird.sock \
+ service run \
+ --enable-json-socket \
+ --json-socket "${json_socket}" >/dev/null
+}
+
+run_tcp_test() {
+ local port container
+ port="$(pick_port)"
+ container="nb-json-socket-tcp-$RANDOM-$RANDOM"
+
+ echo "==> Validating TCP JSON socket on 127.0.0.1:${port}"
+ run_netbird_container "${container}" "tcp://0.0.0.0:8080" -p "127.0.0.1:${port}:8080"
+ wait_for_http_status "${container}" "http://127.0.0.1:${port}/daemon.DaemonService/Status"
+}
+
+run_unix_test() {
+ local sock_dir sock_path container
+ sock_dir="${TMP_DIR}/sock"
+ sock_path="${sock_dir}/netbird-http.sock"
+ container="nb-json-socket-unix-$RANDOM-$RANDOM"
+ mkdir -p "${sock_dir}"
+
+ echo "==> Validating Unix JSON socket at ${sock_path}"
+ run_netbird_container "${container}" "unix:///sock/netbird-http.sock" -v "${sock_dir}:/sock"
+ wait_for_http_status "${container}" --unix-socket "${sock_path}" "http://unix/daemon.DaemonService/Status"
+}
+
+build_image
+
+case "${MODE}" in
+ tcp)
+ run_tcp_test
+ ;;
+ unix)
+ run_unix_test
+ ;;
+ both)
+ run_tcp_test
+ run_unix_test
+ ;;
+esac
+
+echo "==> Docker JSON socket validation passed (${MODE})"
diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go
new file mode 100644
index 000000000..d1945894d
--- /dev/null
+++ b/client/testutil/privileged/runner_test.go
@@ -0,0 +1,196 @@
+//go:build privileged && (linux || darwin)
+
+// Package privileged provides a self-hosting harness that runs the repo's
+// privileged-tagged test suite inside a --privileged --cap-add=NET_ADMIN
+// container, so developers can exercise the root/system-mutating tests on a
+// non-root host with a single `go test` invocation.
+package privileged
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/moby/moby/api/types/container"
+ "github.com/ory/dockertest/v4"
+)
+
+// containerImage / containerTag match the image used by the CI privileged job
+// (.github/workflows/golang-test-linux.yml, test_client_on_docker).
+const (
+ containerImage = "golang"
+ containerTag = "1.25-alpine"
+)
+
+const (
+ containerWorkdir = "/app"
+ containerGoCache = "/root/.cache/go-build"
+ containerGoModCache = "/go/pkg/mod"
+)
+
+// alpinePackages are the build/runtime deps the privileged tests need, mirroring
+// the CI container setup.
+const alpinePackages = "ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base"
+
+// privilegedTestPackages is the package list the suite runs, excluding the
+// server-side trees and UI/upload helpers, matching the CI Docker job's filter.
+const privilegedTestPackages = `go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server`
+
+// testWriter forwards container output to the test log line by line.
+type testWriter struct{ t *testing.T }
+
+func (w testWriter) Write(p []byte) (int, error) {
+ for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
+ w.t.Log(line)
+ }
+ return len(p), nil
+}
+
+// TestRunPrivilegedSuiteInDocker spins up a privileged container, mounts the repo,
+// and runs `go test -tags 'devcert privileged'` inside it. When already running
+// inside that container (DOCKER_CI=true) it returns immediately so the real
+// privileged tests in the suite execute in place instead of recursing.
+func TestRunPrivilegedSuiteInDocker(t *testing.T) {
+ if os.Getenv("DOCKER_CI") == "true" {
+ t.Skip("inside privileged container, skipping container spawn; privileged tests run in place")
+ }
+
+ repoRoot, err := findRepoRoot()
+ if err != nil {
+ t.Fatalf("locate repo root: %v", err)
+ }
+ goCache, goModCache := hostGoCaches(t)
+
+ // dockertest reads DOCKER_HOST; point it at the active context's socket when
+ // the default one is absent (macOS Docker Desktop, Colima, OrbStack).
+ if host := dockerHost(); host != "" {
+ t.Setenv("DOCKER_HOST", host)
+ }
+
+ // NewPoolT registers container cleanup via t.Cleanup automatically.
+ pool := dockertest.NewPoolT(t, "", dockertest.WithMaxWait(30*time.Minute))
+
+ // Keep the container alive so the suite runs via Exec, which yields a clean
+ // exit code (the v4 Resource API exposes no container wait/exit-code).
+ resource := pool.RunT(t, containerImage,
+ dockertest.WithTag(containerTag),
+ dockertest.WithWorkingDir(containerWorkdir),
+ dockertest.WithMounts([]string{
+ repoRoot + ":" + containerWorkdir,
+ goCache + ":" + containerGoCache,
+ goModCache + ":" + containerGoModCache,
+ }),
+ dockertest.WithEnv([]string{
+ "CGO_ENABLED=1",
+ "CI=true",
+ "DOCKER_CI=true",
+ "CONTAINER=true",
+ "GOCACHE=" + containerGoCache,
+ "GOMODCACHE=" + containerGoModCache,
+ }),
+ dockertest.WithCmd([]string{"sleep", "infinity"}),
+ dockertest.WithHostConfig(func(hc *container.HostConfig) {
+ hc.Privileged = true
+ hc.CapAdd = []string{"NET_ADMIN"}
+ }),
+ dockertest.WithoutReuse(),
+ )
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+ defer cancel()
+
+ result, err := resource.Exec(ctx, []string{"sh", "-c", buildTestScript()})
+ if err != nil {
+ t.Fatalf("run privileged suite in container: %v", err)
+ }
+
+ w := testWriter{t}
+ _, _ = w.Write([]byte(result.StdOut))
+ _, _ = w.Write([]byte(result.StdErr))
+
+ if result.ExitCode != 0 {
+ t.Fatalf("privileged test suite failed in container (exit code %d)", result.ExitCode)
+ }
+}
+
+// findRepoRoot walks up from the test's working directory to the module root.
+func findRepoRoot() (string, error) {
+ dir, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ for {
+ if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil {
+ return dir, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ return "", fmt.Errorf("go.mod not found above %s", dir)
+ }
+ dir = parent
+ }
+}
+
+// dockerHost returns a DOCKER_HOST override when the default socket is missing.
+// An empty result means the caller should leave DOCKER_HOST untouched (it is
+// already set, or the default unix socket exists). When neither is present
+// (common on macOS Docker Desktop, Colima and OrbStack, which use a per-user
+// socket), it resolves the active docker context's endpoint.
+func dockerHost() string {
+ if os.Getenv("DOCKER_HOST") != "" {
+ return ""
+ }
+ if _, err := os.Stat("/var/run/docker.sock"); err == nil {
+ return ""
+ }
+
+ out, err := exec.Command("docker", "context", "inspect", "-f", "{{.Endpoints.docker.Host}}").Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// hostGoCaches resolves the host GOCACHE/GOMODCACHE so the container reuses the
+// existing build/module cache for speed.
+func hostGoCaches(t *testing.T) (string, string) {
+ t.Helper()
+ return goEnv(t, "GOCACHE"), goEnv(t, "GOMODCACHE")
+}
+
+func goEnv(t *testing.T, key string) string {
+ t.Helper()
+ var out bytes.Buffer
+ cmd := exec.Command("go", "env", key)
+ cmd.Stdout = &out
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("go env %s: %v", key, err)
+ }
+ return strings.TrimSpace(out.String())
+}
+
+// buildTestScript builds the in-container command. PRIV_PKGS overrides the package
+// list (default: the full filtered set); PRIV_RUN adds a -run test-name filter.
+// Both empty reproduces the full privileged suite.
+func buildTestScript() string {
+ pkgs := privilegedTestPackages + " | xargs"
+ if p := os.Getenv("PRIV_PKGS"); p != "" {
+ pkgs = "echo " + p + " | xargs"
+ }
+
+ runFilter := ""
+ if r := os.Getenv("PRIV_RUN"); r != "" {
+ runFilter = "-run '" + r + "' "
+ }
+
+ return fmt.Sprintf(
+ "apk update >/dev/null && apk add --no-cache %s >/dev/null && %s go test -buildvcs=false -tags 'devcert privileged' %s-v -timeout 20m -p 1",
+ alpinePackages, pkgs, runFilter,
+ )
+}
diff --git a/client/ui/.gitignore b/client/ui/.gitignore
new file mode 100644
index 000000000..9f233d8b6
--- /dev/null
+++ b/client/ui/.gitignore
@@ -0,0 +1,8 @@
+.task
+bin
+frontend/dist
+frontend/node_modules
+frontend/bindings
+frontend/.vite
+build/linux/appimage/build
+build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
diff --git a/client/ui/Netbird.icns b/client/ui/Netbird.icns
deleted file mode 100644
index 20af72825..000000000
Binary files a/client/ui/Netbird.icns and /dev/null differ
diff --git a/client/ui/Taskfile.yml b/client/ui/Taskfile.yml
new file mode 100644
index 000000000..2d0af9018
--- /dev/null
+++ b/client/ui/Taskfile.yml
@@ -0,0 +1,58 @@
+version: '3'
+
+includes:
+ common: ./build/Taskfile.yml
+ windows: ./build/windows/Taskfile.yml
+ darwin: ./build/darwin/Taskfile.yml
+ linux: ./build/linux/Taskfile.yml
+
+vars:
+ APP_NAME: "netbird-ui"
+ BIN_DIR: "bin"
+ VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
+
+tasks:
+ build:
+ summary: Builds the application
+ cmds:
+ - task: "{{OS}}:build"
+
+ package:
+ summary: Packages a production build of the application
+ cmds:
+ - task: "{{OS}}:package"
+
+ run:
+ summary: Runs the application
+ cmds:
+ - task: "{{OS}}:run"
+
+ dev:
+ summary: Runs the application in development mode
+ cmds:
+ - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
+
+ setup:docker:
+ summary: Builds Docker image for cross-compilation (~800MB download)
+ cmds:
+ - task: common:setup:docker
+
+ build:server:
+ summary: Builds the application in server mode (no GUI, HTTP server only)
+ cmds:
+ - task: common:build:server
+
+ run:server:
+ summary: Runs the application in server mode
+ cmds:
+ - task: common:run:server
+
+ build:docker:
+ summary: Builds a Docker image for server mode deployment
+ cmds:
+ - task: common:build:docker
+
+ run:docker:
+ summary: Builds and runs the Docker image
+ cmds:
+ - task: common:run:docker
diff --git a/client/ui/assets/connected.png b/client/ui/assets/connected.png
deleted file mode 100644
index 7dd2ab01a..000000000
Binary files a/client/ui/assets/connected.png and /dev/null differ
diff --git a/client/ui/assets/disconnected.png b/client/ui/assets/disconnected.png
deleted file mode 100644
index 421632b52..000000000
Binary files a/client/ui/assets/disconnected.png and /dev/null differ
diff --git a/client/ui/assets/netbird-disconnected.ico b/client/ui/assets/netbird-disconnected.ico
deleted file mode 100644
index 812e9d283..000000000
Binary files a/client/ui/assets/netbird-disconnected.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-disconnected.png b/client/ui/assets/netbird-disconnected.png
deleted file mode 100644
index 79d4775ea..000000000
Binary files a/client/ui/assets/netbird-disconnected.png and /dev/null differ
diff --git a/client/ui/assets/netbird-menu-16.png b/client/ui/assets/netbird-menu-16.png
new file mode 100644
index 000000000..d5dcab446
Binary files /dev/null and b/client/ui/assets/netbird-menu-16.png differ
diff --git a/client/ui/assets/netbird-menu-24.png b/client/ui/assets/netbird-menu-24.png
new file mode 100644
index 000000000..087c1c2ae
Binary files /dev/null and b/client/ui/assets/netbird-menu-24.png differ
diff --git a/client/ui/assets/netbird-menu-about-18.png b/client/ui/assets/netbird-menu-about-18.png
new file mode 100644
index 000000000..bb12c6367
Binary files /dev/null and b/client/ui/assets/netbird-menu-about-18.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connected-16.png b/client/ui/assets/netbird-menu-dot-connected-16.png
new file mode 100644
index 000000000..3a7fa31a4
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected-16.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connected-22.png b/client/ui/assets/netbird-menu-dot-connected-22.png
new file mode 100644
index 000000000..78b068748
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected-22.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connected.png b/client/ui/assets/netbird-menu-dot-connected.png
new file mode 100644
index 000000000..fc8ce4d85
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connecting-16.png b/client/ui/assets/netbird-menu-dot-connecting-16.png
new file mode 100644
index 000000000..f874706b5
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting-16.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connecting-22.png b/client/ui/assets/netbird-menu-dot-connecting-22.png
new file mode 100644
index 000000000..d8e5970f5
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting-22.png differ
diff --git a/client/ui/assets/netbird-menu-dot-connecting.png b/client/ui/assets/netbird-menu-dot-connecting.png
new file mode 100644
index 000000000..3f8bc29d8
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting.png differ
diff --git a/client/ui/assets/netbird-menu-dot-error-16.png b/client/ui/assets/netbird-menu-dot-error-16.png
new file mode 100644
index 000000000..cdc6254da
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error-16.png differ
diff --git a/client/ui/assets/netbird-menu-dot-error-22.png b/client/ui/assets/netbird-menu-dot-error-22.png
new file mode 100644
index 000000000..d9bd013d6
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error-22.png differ
diff --git a/client/ui/assets/netbird-menu-dot-error.png b/client/ui/assets/netbird-menu-dot-error.png
new file mode 100644
index 000000000..ce5d0e8ef
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error.png differ
diff --git a/client/ui/assets/netbird-menu-dot-idle-16.png b/client/ui/assets/netbird-menu-dot-idle-16.png
new file mode 100644
index 000000000..354b5b860
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle-16.png differ
diff --git a/client/ui/assets/netbird-menu-dot-idle-22.png b/client/ui/assets/netbird-menu-dot-idle-22.png
new file mode 100644
index 000000000..675cf1ffe
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle-22.png differ
diff --git a/client/ui/assets/netbird-menu-dot-idle.png b/client/ui/assets/netbird-menu-dot-idle.png
new file mode 100644
index 000000000..79e7bbbf8
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle.png differ
diff --git a/client/ui/assets/netbird-menu-dot-offline-16.png b/client/ui/assets/netbird-menu-dot-offline-16.png
new file mode 100644
index 000000000..f9aa5c3e9
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline-16.png differ
diff --git a/client/ui/assets/netbird-menu-dot-offline-22.png b/client/ui/assets/netbird-menu-dot-offline-22.png
new file mode 100644
index 000000000..5202c8baa
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline-22.png differ
diff --git a/client/ui/assets/netbird-menu-dot-offline.png b/client/ui/assets/netbird-menu-dot-offline.png
new file mode 100644
index 000000000..7aec5d01d
Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline.png differ
diff --git a/client/ui/assets/netbird-systemtray-connected-dark.ico b/client/ui/assets/netbird-systemtray-connected-dark.ico
deleted file mode 100644
index 0db8a0862..000000000
Binary files a/client/ui/assets/netbird-systemtray-connected-dark.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-connected-macos.png b/client/ui/assets/netbird-systemtray-connected-macos.png
index ead210250..d29a7ade8 100644
Binary files a/client/ui/assets/netbird-systemtray-connected-macos.png and b/client/ui/assets/netbird-systemtray-connected-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-connected-mono-dark.png
new file mode 100644
index 000000000..1f7d40121
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-connected-mono.png b/client/ui/assets/netbird-systemtray-connected-mono.png
new file mode 100644
index 000000000..8a8710746
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-connected.ico b/client/ui/assets/netbird-systemtray-connected.ico
deleted file mode 100644
index c16bec3f5..000000000
Binary files a/client/ui/assets/netbird-systemtray-connected.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-connecting-dark.ico b/client/ui/assets/netbird-systemtray-connecting-dark.ico
deleted file mode 100644
index 615d40f07..000000000
Binary files a/client/ui/assets/netbird-systemtray-connecting-dark.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-connecting-macos.png b/client/ui/assets/netbird-systemtray-connecting-macos.png
index 0fe7fa0db..306c6ddf5 100644
Binary files a/client/ui/assets/netbird-systemtray-connecting-macos.png and b/client/ui/assets/netbird-systemtray-connecting-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-connecting-mono-dark.png b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png
new file mode 100644
index 000000000..f208cb6bf
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-connecting-mono.png b/client/ui/assets/netbird-systemtray-connecting-mono.png
new file mode 100644
index 000000000..e254321fd
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-connecting.ico b/client/ui/assets/netbird-systemtray-connecting.ico
deleted file mode 100644
index 4e4c3a9b1..000000000
Binary files a/client/ui/assets/netbird-systemtray-connecting.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-disconnected-macos.png b/client/ui/assets/netbird-systemtray-disconnected-macos.png
index 36b9a488f..48cfa7c60 100644
Binary files a/client/ui/assets/netbird-systemtray-disconnected-macos.png and b/client/ui/assets/netbird-systemtray-disconnected-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png
new file mode 100644
index 000000000..035e71ba7
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono.png b/client/ui/assets/netbird-systemtray-disconnected-mono.png
new file mode 100644
index 000000000..d68c4dc5b
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-disconnected.ico b/client/ui/assets/netbird-systemtray-disconnected.ico
deleted file mode 100644
index dcb9f4bf8..000000000
Binary files a/client/ui/assets/netbird-systemtray-disconnected.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-error-dark.ico b/client/ui/assets/netbird-systemtray-error-dark.ico
deleted file mode 100644
index 083816188..000000000
Binary files a/client/ui/assets/netbird-systemtray-error-dark.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-error-macos.png b/client/ui/assets/netbird-systemtray-error-macos.png
index 9a9998bcf..580fe647c 100644
Binary files a/client/ui/assets/netbird-systemtray-error-macos.png and b/client/ui/assets/netbird-systemtray-error-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-error-mono-dark.png b/client/ui/assets/netbird-systemtray-error-mono-dark.png
new file mode 100644
index 000000000..6bcdacd44
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-error-mono.png b/client/ui/assets/netbird-systemtray-error-mono.png
new file mode 100644
index 000000000..164d65a4f
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-error.ico b/client/ui/assets/netbird-systemtray-error.ico
deleted file mode 100644
index 1abc45c2a..000000000
Binary files a/client/ui/assets/netbird-systemtray-error.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-needs-login-macos.png b/client/ui/assets/netbird-systemtray-needs-login-macos.png
new file mode 100644
index 000000000..580fe647c
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png
new file mode 100644
index 000000000..6bcdacd44
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono.png b/client/ui/assets/netbird-systemtray-needs-login-mono.png
new file mode 100644
index 000000000..164d65a4f
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-needs-login.png b/client/ui/assets/netbird-systemtray-needs-login.png
new file mode 100644
index 000000000..722342989
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-connected-dark.ico b/client/ui/assets/netbird-systemtray-update-connected-dark.ico
deleted file mode 100644
index b11bb5492..000000000
Binary files a/client/ui/assets/netbird-systemtray-update-connected-dark.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-update-connected-macos.png b/client/ui/assets/netbird-systemtray-update-connected-macos.png
index 8a6b2f2db..8b7b9f131 100644
Binary files a/client/ui/assets/netbird-systemtray-update-connected-macos.png and b/client/ui/assets/netbird-systemtray-update-connected-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png
new file mode 100644
index 000000000..284efa880
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono.png b/client/ui/assets/netbird-systemtray-update-connected-mono.png
new file mode 100644
index 000000000..ed9ceb8a2
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-connected.ico b/client/ui/assets/netbird-systemtray-update-connected.ico
deleted file mode 100644
index d3ce2f0f3..000000000
Binary files a/client/ui/assets/netbird-systemtray-update-connected.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico b/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico
deleted file mode 100644
index 123237f66..000000000
Binary files a/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico and /dev/null differ
diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-macos.png b/client/ui/assets/netbird-systemtray-update-disconnected-macos.png
index 8b190034e..b6afa3937 100644
Binary files a/client/ui/assets/netbird-systemtray-update-disconnected-macos.png and b/client/ui/assets/netbird-systemtray-update-disconnected-macos.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png
new file mode 100644
index 000000000..eb0c4bcf5
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png
new file mode 100644
index 000000000..519ea014a
Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png differ
diff --git a/client/ui/assets/netbird-systemtray-update-disconnected.ico b/client/ui/assets/netbird-systemtray-update-disconnected.ico
deleted file mode 100644
index 968dc4105..000000000
Binary files a/client/ui/assets/netbird-systemtray-update-disconnected.ico and /dev/null differ
diff --git a/client/ui/assets/netbird.ico b/client/ui/assets/netbird.ico
deleted file mode 100644
index 2bab8a503..000000000
Binary files a/client/ui/assets/netbird.ico and /dev/null differ
diff --git a/client/ui/assets/svg/needs-login.svg b/client/ui/assets/svg/needs-login.svg
new file mode 100644
index 000000000..5c01b48d4
--- /dev/null
+++ b/client/ui/assets/svg/needs-login.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/client/ui/assets/svg/netbird-menu.svg b/client/ui/assets/svg/netbird-menu.svg
new file mode 100644
index 000000000..bd4e9d65d
--- /dev/null
+++ b/client/ui/assets/svg/netbird-menu.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/client/ui/authsession/service.go b/client/ui/authsession/service.go
new file mode 100644
index 000000000..28efe7cfd
--- /dev/null
+++ b/client/ui/authsession/service.go
@@ -0,0 +1,116 @@
+//go:build !android && !ios && !freebsd && !js
+
+package authsession
+
+import (
+ "context"
+ "time"
+
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+type ExtendStartParams struct {
+ // Hint is the OIDC login_hint, typically the user's email.
+ Hint string `json:"hint"`
+}
+
+type ExtendStartResult struct {
+ VerificationURI string `json:"verificationUri"`
+ VerificationURIComplete string `json:"verificationUriComplete"`
+ UserCode string `json:"userCode"`
+ DeviceCode string `json:"deviceCode"`
+ ExpiresIn int64 `json:"expiresIn"`
+}
+
+type ExtendWaitParams struct {
+ DeviceCode string `json:"deviceCode"`
+ UserCode string `json:"userCode"`
+}
+
+// ExtendResult: ExpiresAt is nil when the peer is ineligible for extension.
+// Preempted means a newer WaitExtend took over the IdP poll — a no-op, not a failure.
+type ExtendResult struct {
+ ExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
+ Preempted bool `json:"preempted,omitempty"`
+}
+
+// DaemonConn duplicates services.DaemonConn to avoid an import cycle.
+type DaemonConn interface {
+ Client() (proto.DaemonServiceClient, error)
+}
+
+// Session bundles the session-auth daemon RPCs the UI drives.
+type Session struct {
+ conn DaemonConn
+}
+
+func NewSession(conn DaemonConn) *Session {
+ return &Session{conn: conn}
+}
+
+// RequestExtend starts the SSO session-extension flow on the daemon.
+func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return ExtendStartResult{}, err
+ }
+
+ req := &proto.RequestExtendAuthSessionRequest{}
+ if p.Hint != "" {
+ h := p.Hint
+ req.Hint = &h
+ }
+
+ resp, err := cli.RequestExtendAuthSession(ctx, req)
+ if err != nil {
+ return ExtendStartResult{}, err
+ }
+
+ return ExtendStartResult{
+ VerificationURI: resp.GetVerificationURI(),
+ VerificationURIComplete: resp.GetVerificationURIComplete(),
+ UserCode: resp.GetUserCode(),
+ DeviceCode: resp.GetDeviceCode(),
+ ExpiresIn: resp.GetExpiresIn(),
+ }, nil
+}
+
+// WaitExtend blocks until the user completes the SSO flow started by RequestExtend.
+func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return ExtendResult{}, err
+ }
+
+ resp, err := cli.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{
+ DeviceCode: p.DeviceCode,
+ UserCode: p.UserCode,
+ })
+ if err != nil {
+ if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Canceled {
+ return ExtendResult{Preempted: true}, nil
+ }
+ return ExtendResult{}, err
+ }
+
+ out := ExtendResult{}
+ if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
+ t := ts.AsTime().UTC()
+ out.ExpiresAt = &t
+ }
+ return out, nil
+}
+
+// DismissWarning suppresses the daemon's T-FinalWarningLead fallback dialog for
+// the current deadline. Best-effort: a stale call is silently swallowed daemon-side.
+func (s *Session) DismissWarning(ctx context.Context) error {
+ cli, err := s.conn.Client()
+ if err != nil {
+ return err
+ }
+ _, err = cli.DismissSessionWarning(ctx, &proto.DismissSessionWarningRequest{})
+ return err
+}
diff --git a/client/ui/authsession/warning.go b/client/ui/authsession/warning.go
new file mode 100644
index 000000000..91ae7f101
--- /dev/null
+++ b/client/ui/authsession/warning.go
@@ -0,0 +1,64 @@
+//go:build !android && !ios && !freebsd && !js
+
+// Package authsession holds the UI-side domain logic for the SSO
+// session-extend feature. The Wails facades in client/ui/services/session*.go
+// are thin adapters over these types.
+package authsession
+
+import (
+ "time"
+
+ "github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
+)
+
+// Re-exported from sessionwatch so UI-side consumers don't import the
+// daemon-internal package directly.
+const (
+ MetaWarning = sessionwatch.MetaSessionWarning
+ MetaFinal = sessionwatch.MetaSessionFinal
+ MetaExpiresAt = sessionwatch.MetaSessionExpiresAt
+ MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes
+ MetaDeadlineRejected = sessionwatch.MetaSessionDeadlineRejected
+)
+
+// Warning is the typed payload emitted on the session-warning Wails events.
+type Warning struct {
+ // Absolute UTC deadline; best-effort, stays zero when metadata is
+ // missing or malformed (e.g. an older daemon) and the UI falls back
+ // to the Status snapshot.
+ ExpiresAt time.Time `json:"sessionExpiresAt"`
+ // Configured lead time, so the UI need not hardcode the constant.
+ LeadMinutes int `json:"leadMinutes"`
+ // True on the final-warning fallback event.
+ Final bool `json:"final"`
+}
+
+// WarningFromMetadata parses SystemEvent metadata into a Warning, or returns
+// (nil, false) when the event is not a session-warning. A field that fails to
+// parse stays zero; the event is still surfaced.
+func WarningFromMetadata(meta map[string]string) (*Warning, bool) {
+ if meta == nil || meta[MetaWarning] != "true" {
+ return nil, false
+ }
+
+ out := &Warning{
+ Final: meta[MetaFinal] == "true",
+ }
+ if raw := meta[MetaExpiresAt]; raw != "" {
+ if t, err := sessionwatch.ParseExpiresAt(raw); err == nil {
+ out.ExpiresAt = t
+ }
+ }
+ if raw := meta[MetaLeadMinutes]; raw != "" {
+ if n, err := sessionwatch.ParseLeadMinutes(raw); err == nil {
+ out.LeadMinutes = n
+ }
+ }
+ return out, true
+}
+
+// ParseExpiresAt re-exports sessionwatch.ParseExpiresAt so UI-side call sites
+// don't import the daemon-internal package.
+func ParseExpiresAt(s string) (time.Time, error) {
+ return sessionwatch.ParseExpiresAt(s)
+}
diff --git a/client/ui/authsession/warning_test.go b/client/ui/authsession/warning_test.go
new file mode 100644
index 000000000..297073ded
--- /dev/null
+++ b/client/ui/authsession/warning_test.go
@@ -0,0 +1,82 @@
+//go:build !android && !ios && !freebsd && !js
+
+package authsession
+
+import (
+ "testing"
+ "time"
+)
+
+func TestWarningFromMetadata_NotASessionWarning(t *testing.T) {
+ cases := []struct {
+ name string
+ meta map[string]string
+ }{
+ {"nil metadata", nil},
+ {"empty map", map[string]string{}},
+ {"unrelated event", map[string]string{"new_version_available": "0.65.0"}},
+ {"flag not 'true'", map[string]string{"session_warning": "1"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if w, ok := WarningFromMetadata(tc.meta); ok {
+ t.Fatalf("expected (nil, false), got (%+v, %v)", w, ok)
+ }
+ })
+ }
+}
+
+func TestWarningFromMetadata_FullPayload(t *testing.T) {
+ ts := "2026-05-18T13:30:00Z"
+ meta := map[string]string{
+ "session_warning": "true",
+ "session_expires_at": ts,
+ "lead_minutes": "10",
+ }
+
+ got, ok := WarningFromMetadata(meta)
+ if !ok {
+ t.Fatalf("expected the warning to be recognised, got ok=false")
+ }
+ want, _ := time.Parse(time.RFC3339, ts)
+ if !got.ExpiresAt.Equal(want.UTC()) {
+ t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, want.UTC())
+ }
+ if got.LeadMinutes != 10 {
+ t.Errorf("LeadMinutes = %d, want 10", got.LeadMinutes)
+ }
+}
+
+func TestWarningFromMetadata_BadFieldsStillEmits(t *testing.T) {
+ // Older or buggy daemon: the flag is set but the timestamp/lead are
+ // missing or malformed. The UI should still get a warning so it can
+ // at least surface "session expires soon"; field zero-values are fine.
+ meta := map[string]string{
+ "session_warning": "true",
+ "session_expires_at": "not-a-timestamp",
+ "lead_minutes": "abc",
+ }
+
+ got, ok := WarningFromMetadata(meta)
+ if !ok {
+ t.Fatalf("warning should still be recognised even with malformed fields")
+ }
+ if !got.ExpiresAt.IsZero() {
+ t.Errorf("malformed timestamp should leave field zero, got %v", got.ExpiresAt)
+ }
+ if got.LeadMinutes != 0 {
+ t.Errorf("malformed lead_minutes should leave field 0, got %d", got.LeadMinutes)
+ }
+}
+
+func TestWarningFromMetadata_MissingFieldsStillEmits(t *testing.T) {
+ // Only the flag is present (e.g. future-trimmed event). Still emit.
+ meta := map[string]string{"session_warning": "true"}
+ got, ok := WarningFromMetadata(meta)
+ if !ok {
+ t.Fatalf("warning should still be recognised when only flag is present")
+ }
+ if got.ExpiresAt.IsZero() != true || got.LeadMinutes != 0 {
+ t.Errorf("missing fields should be zero-valued, got %+v", got)
+ }
+}
diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go
new file mode 100644
index 000000000..162922579
--- /dev/null
+++ b/client/ui/autostart_default.go
@@ -0,0 +1,121 @@
+//go:build !android && !ios && !freebsd && !js
+
+package main
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/mdm"
+ "github.com/netbirdio/netbird/client/ui/preferences"
+ "github.com/netbirdio/netbird/client/ui/services"
+)
+
+// autostartDefaultState carries the guard inputs of the one-time autostart
+// default decision so the decision itself stays a pure, testable function.
+type autostartDefaultState struct {
+ supported bool
+ mdmDisabled bool
+ priorInstall bool
+}
+
+// shouldEnableAutostartDefault applies the first-run guards in order and
+// returns whether autostart may be enabled, plus the reason when it may not.
+func shouldEnableAutostartDefault(s autostartDefaultState) (bool, string) {
+ switch {
+ case !s.supported:
+ return false, "autostart not supported on this platform"
+ case s.mdmDisabled:
+ return false, "autostart disabled by MDM policy"
+ case s.priorInstall:
+ return false, "existing NetBird installation"
+ }
+ return true, ""
+}
+
+// autostartDisabledByMDM reports whether the MDM policy manages the
+// disableAutostart key in a way that must suppress the default. An
+// unparseable managed value is treated as disabled to stay on the safe side.
+func autostartDisabledByMDM(policy *mdm.Policy) bool {
+ if !policy.HasKey(mdm.KeyDisableAutostart) {
+ return false
+ }
+ disabled, ok := policy.GetBool(mdm.KeyDisableAutostart)
+ return !ok || disabled
+}
+
+// netbirdFootprintExists reports whether the machine already carries NetBird
+// daemon config or state, meaning this is not a genuinely fresh install. It is
+// the update-safety gate for the autostart default: upgrading users always
+// have a footprint, so an update can never trigger a autostart entry write.
+func netbirdFootprintExists() bool {
+ candidates := []string{
+ profilemanager.DefaultConfigPath,
+ filepath.Join(profilemanager.DefaultConfigPathDir, "config.json"),
+ filepath.Join(profilemanager.DefaultConfigPathDir, "state.json"),
+ }
+ for _, path := range candidates {
+ if path != "" && fileExists(path) {
+ return true
+ }
+ }
+ return false
+}
+
+// applyAutostartDefault runs the one-time launch-on-login default for genuinely
+// fresh installs. The autostartInitialized marker is persisted before any
+// enable attempt so a crash mid-flow degrades to "never enabled" instead of
+// retrying autostart entry writes on every launch. A user's later disable in
+// Settings is never overridden: the marker guarantees at-most-once, ever.
+func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
+ mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy())
+
+ if mdmDisabled {
+ if enabled, err := autostart.IsEnabled(ctx); err != nil {
+ log.Warnf("MDM disableAutostart: read autostart state: %v", err)
+ } else if enabled {
+ if err := autostart.SetEnabled(ctx, false); err != nil {
+ log.Warnf("MDM disableAutostart: force off failed: %v", err)
+ } else {
+ log.Info("MDM disableAutostart enforced: autostart turned off")
+ }
+ }
+ }
+
+ priorFootprint := netbirdFootprintExists() || prefsFileExisted
+
+ if prefs.Get().AutostartInitialized {
+ return
+ }
+ if err := prefs.SetAutostartInitialized(true); err != nil {
+ log.Warnf("persist autostart marker, skipping autostart default: %v", err)
+ return
+ }
+
+ state := autostartDefaultState{
+ supported: autostart.Supported(ctx),
+ mdmDisabled: mdmDisabled,
+ priorInstall: priorFootprint,
+ }
+ enable, reason := shouldEnableAutostartDefault(state)
+ if !enable {
+ log.Debugf("skipping autostart default: %s", reason)
+ return
+ }
+
+ if err := autostart.SetEnabled(ctx, true); err != nil {
+ log.Warnf("enable autostart on fresh install: %v", err)
+ return
+ }
+ log.Info("autostart enabled by default on fresh install")
+}
+
+// fileExists reports whether path exists.
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
diff --git a/client/ui/autostart_default_test.go b/client/ui/autostart_default_test.go
new file mode 100644
index 000000000..b7bdf9f2a
--- /dev/null
+++ b/client/ui/autostart_default_test.go
@@ -0,0 +1,125 @@
+//go:build !android && !ios && !freebsd && !js
+
+package main
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/netbirdio/netbird/client/mdm"
+)
+
+func TestShouldEnableAutostartDefault(t *testing.T) {
+ allPass := autostartDefaultState{
+ supported: true,
+ mdmDisabled: false,
+ priorInstall: false,
+ }
+
+ tests := []struct {
+ name string
+ mutate func(*autostartDefaultState)
+ wantEnable bool
+ wantReason string
+ }{
+ {
+ name: "fresh install with all guards passing enables",
+ mutate: func(*autostartDefaultState) {},
+ wantEnable: true,
+ },
+ {
+ name: "unsupported platform skips",
+ mutate: func(s *autostartDefaultState) { s.supported = false },
+ wantReason: "autostart not supported on this platform",
+ },
+ {
+ name: "MDM disable skips",
+ mutate: func(s *autostartDefaultState) { s.mdmDisabled = true },
+ wantReason: "autostart disabled by MDM policy",
+ },
+ {
+ name: "existing installation (upgrade) skips",
+ mutate: func(s *autostartDefaultState) { s.priorInstall = true },
+ wantReason: "existing NetBird installation",
+ },
+ {
+ name: "unsupported wins over every other guard",
+ mutate: func(s *autostartDefaultState) {
+ s.supported = false
+ s.mdmDisabled = true
+ s.priorInstall = true
+ },
+ wantReason: "autostart not supported on this platform",
+ },
+ {
+ name: "MDM disable wins over prior install",
+ mutate: func(s *autostartDefaultState) {
+ s.mdmDisabled = true
+ s.priorInstall = true
+ },
+ wantReason: "autostart disabled by MDM policy",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ state := allPass
+ tc.mutate(&state)
+ enable, reason := shouldEnableAutostartDefault(state)
+ assert.Equal(t, tc.wantEnable, enable, "enable decision should match for state %+v", state)
+ assert.Equal(t, tc.wantReason, reason, "skip reason should identify the failing guard")
+ })
+ }
+}
+
+func TestAutostartDisabledByMDM(t *testing.T) {
+ tests := []struct {
+ name string
+ values map[string]any
+ want bool
+ }{
+ {
+ name: "empty policy does not disable",
+ values: nil,
+ want: false,
+ },
+ {
+ name: "unrelated managed keys do not disable",
+ values: map[string]any{mdm.KeyDisableAutoConnect: true},
+ want: false,
+ },
+ {
+ name: "disableAutostart true disables",
+ values: map[string]any{mdm.KeyDisableAutostart: true},
+ want: true,
+ },
+ {
+ name: "disableAutostart registry DWORD 1 disables",
+ values: map[string]any{mdm.KeyDisableAutostart: int64(1)},
+ want: true,
+ },
+ {
+ name: "disableAutostart string true disables",
+ values: map[string]any{mdm.KeyDisableAutostart: "true"},
+ want: true,
+ },
+ {
+ name: "disableAutostart explicit false allows",
+ values: map[string]any{mdm.KeyDisableAutostart: false},
+ want: false,
+ },
+ {
+ name: "unparseable managed value is treated as disabled",
+ values: map[string]any{mdm.KeyDisableAutostart: "not-a-bool"},
+ want: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := autostartDisabledByMDM(mdm.NewPolicy(tc.values))
+ assert.Equal(t, tc.want, got, "MDM disable decision should match for values %v", tc.values)
+ })
+ }
+}
diff --git a/client/ui/build/Taskfile.yml b/client/ui/build/Taskfile.yml
new file mode 100644
index 000000000..590d4791b
--- /dev/null
+++ b/client/ui/build/Taskfile.yml
@@ -0,0 +1,295 @@
+version: '3'
+
+tasks:
+ go:mod:tidy:
+ summary: Runs `go mod tidy`
+ internal: true
+ cmds:
+ - go mod tidy
+
+ install:frontend:deps:
+ summary: Install frontend dependencies
+ dir: frontend
+ sources:
+ - package.json
+ - pnpm-lock.yaml
+ generates:
+ - node_modules
+ preconditions:
+ - sh: pnpm --version
+ msg: "Looks like pnpm isn't installed. Install with: corepack enable && corepack prepare pnpm@latest --activate"
+ cmds:
+ - pnpm install
+
+ build:frontend:
+ label: build:frontend (DEV={{.DEV}})
+ summary: Build the frontend project
+ dir: frontend
+ sources:
+ - "**/*"
+ - exclude: node_modules/**/*
+ generates:
+ - dist/**/*
+ deps:
+ - task: install:frontend:deps
+ - task: generate:bindings
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ cmds:
+ - pnpm run {{.BUILD_COMMAND}}
+ env:
+ PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
+ vars:
+ BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
+
+
+ frontend:vendor:puppertino:
+ summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
+ sources:
+ - frontend/public/puppertino/puppertino.css
+ generates:
+ - frontend/public/puppertino/puppertino.css
+ cmds:
+ - |
+ set -euo pipefail
+ mkdir -p frontend/public/puppertino
+ # If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
+ if [ ! -f frontend/public/puppertino/puppertino.css ]; then
+ echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
+ if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
+ curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
+ echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
+ else
+ echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
+ fi
+ else
+ echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
+ fi
+ # Ensure index.html includes Puppertino CSS and button classes
+ INDEX_HTML=frontend/index.html
+ if [ -f "$INDEX_HTML" ]; then
+ if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
+ # Insert Puppertino link tag after style.css link
+ awk '
+ /href="\/style.css"\/?/ && !x { print; print " "; x=1; next }1
+ ' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
+ fi
+ # Replace default .btn with Puppertino primary button classes if present
+ sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
+ fi
+
+
+ generate:bindings:
+ label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
+ summary: Generates bindings for the frontend
+ deps:
+ - task: go:mod:tidy
+ sources:
+ - "**/*.[jt]s"
+ - exclude: frontend/**/*
+ - frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
+ - "**/*.go"
+ - go.mod
+ - go.sum
+ generates:
+ - frontend/bindings/**/*
+ cmds:
+ - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -ts
+
+ generate:icons:
+ summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
+ dir: build
+ sources:
+ - "appicon.png"
+ - "appicon.icon"
+ generates:
+ - "darwin/icons.icns"
+ - "windows/icon.ico"
+ cmds:
+ - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
+
+ generate:tray:icons:
+ summary: Rebuild Windows multi-res .ico files from the per-state PNGs.
+ desc: |
+ The colored tray PNGs (assets/netbird-systemtray-.png) and the
+ macOS template variants are committed to the repo as the canonical
+ source. This task only regenerates the Windows multi-resolution .ico
+ files from those PNGs by downscaling each to 16/24/32/48 px and
+ packing them with icotool, so Shell_NotifyIcon picks the frame
+ matching the user's DPI instead of downscaling a single large PNG.
+
+ Run after replacing any of the colored PNGs (e.g. when copying a new
+ version of the icons from client/ui/assets). The SVG sources in
+ assets/svg/ are kept for reference but are not built by default.
+ dir: assets
+ sources:
+ - "netbird-systemtray-connected.png"
+ - "netbird-systemtray-disconnected.png"
+ - "netbird-systemtray-connecting.png"
+ - "netbird-systemtray-error.png"
+ - "netbird-systemtray-update-connected.png"
+ - "netbird-systemtray-update-disconnected.png"
+ generates:
+ - "netbird-systemtray-*.ico"
+ preconditions:
+ - sh: command -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1
+ msg: "ImageMagick is required to downscale PNGs (apt install imagemagick)"
+ - sh: command -v icotool >/dev/null 2>&1
+ msg: "icotool is required to pack tray .ico files (apt install icoutils)"
+ cmds:
+ - |
+ set -euo pipefail
+ tmp=$(mktemp -d)
+ trap 'rm -rf "$tmp"' EXIT
+ resize=$(command -v magick || echo convert)
+ for state in connected disconnected connecting error update-connected update-disconnected; do
+ for sz in 16 24 32 48; do
+ "$resize" "netbird-systemtray-$state.png" -resize ${sz}x${sz} "$tmp/$state-$sz.png"
+ done
+ icotool -c -o "netbird-systemtray-$state.ico" \
+ "$tmp/$state-16.png" "$tmp/$state-24.png" "$tmp/$state-32.png" "$tmp/$state-48.png"
+ done
+
+ dev:frontend:
+ summary: Runs the frontend in development mode
+ dir: frontend
+ deps:
+ - task: install:frontend:deps
+ cmds:
+ - pnpm exec vite --port {{.VITE_PORT}} --strictPort
+
+ update:build-assets:
+ summary: Updates the build assets
+ dir: build
+ cmds:
+ - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
+
+ build:server:
+ summary: Builds the application in server mode (no GUI, HTTP server only)
+ desc: |
+ Builds the application with the server build tag enabled.
+ Server mode runs as a pure HTTP server without native GUI dependencies.
+ Usage: task build:server
+ deps:
+ - task: build:frontend
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ cmds:
+ - go build -tags server {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
+ vars:
+ BUILD_FLAGS: "{{.BUILD_FLAGS}}"
+
+ run:server:
+ summary: Builds and runs the application in server mode
+ deps:
+ - task: build:server
+ cmds:
+ - ./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
+
+ build:docker:
+ summary: Builds a Docker image for server mode deployment
+ desc: |
+ Creates a minimal Docker image containing the server mode binary.
+ The image is based on distroless for security and small size.
+ Usage: task build:docker [TAG=myapp:latest]
+ cmds:
+ - docker build -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} -f build/docker/Dockerfile.server .
+ vars:
+ TAG: "{{.TAG}}"
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required. Please install Docker first."
+ - sh: test -f build/docker/Dockerfile.server
+ msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
+
+ run:docker:
+ summary: Builds and runs the Docker image
+ desc: |
+ Builds the Docker image and runs it, exposing port 8080.
+ Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
+ Note: The internal container port is always 8080. The PORT variable
+ only changes the host port mapping. Ensure your app uses port 8080
+ or modify the Dockerfile to match your ServerOptions.Port setting.
+ deps:
+ - task: build:docker
+ vars:
+ TAG:
+ ref: .TAG
+ cmds:
+ - docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
+ vars:
+ TAG: "{{.TAG}}"
+ PORT: "{{.PORT}}"
+
+ setup:docker:
+ summary: Builds Docker image for cross-compilation (~800MB download)
+ desc: |
+ Builds the Docker image needed for cross-compiling to any platform.
+ Run this once to enable cross-platform builds from any OS.
+ cmds:
+ - docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required. Please install Docker first."
+
+ ios:device:list:
+ summary: Lists connected iOS devices (UDIDs)
+ cmds:
+ - xcrun xcdevice list
+
+ ios:run:device:
+ summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
+ vars:
+ PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/.xcodeproj
+ SCHEME: '{{.SCHEME}}' # e.g., ios.dev
+ CONFIG: '{{.CONFIG | default "Debug"}}'
+ DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
+ UDID: '{{.UDID}}' # from `task ios:device:list`
+ BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
+ TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
+ preconditions:
+ - sh: xcrun -f xcodebuild
+ msg: "xcodebuild not found. Please install Xcode."
+ - sh: xcrun -f devicectl
+ msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
+ - sh: test -n '{{.PROJECT}}'
+ msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
+ - sh: test -n '{{.SCHEME}}'
+ msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
+ - sh: test -n '{{.UDID}}'
+ msg: "Set UDID to your device UDID (see: task ios:device:list)."
+ - sh: test -n '{{.BUNDLE_ID}}'
+ msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
+ cmds:
+ - |
+ set -euo pipefail
+ echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
+ XCB_ARGS=(
+ -project "{{.PROJECT}}"
+ -scheme "{{.SCHEME}}"
+ -configuration "{{.CONFIG}}"
+ -destination "id={{.UDID}}"
+ -derivedDataPath "{{.DERIVED}}"
+ -allowProvisioningUpdates
+ -allowProvisioningDeviceRegistration
+ )
+ # Optionally inject signing identifiers if provided
+ if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
+ if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
+ xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
+ # If xcpretty isn't installed, run without it
+ if [ "${PIPESTATUS[0]}" -ne 0 ]; then
+ xcodebuild "${XCB_ARGS[@]}" build
+ fi
+ # Find built .app
+ APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
+ if [ -z "$APP_PATH" ]; then
+ echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
+ exit 1
+ fi
+ echo "Installing: $APP_PATH"
+ xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
+ echo "Launching: {{.BUNDLE_ID}}"
+ xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"
diff --git a/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg b/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg
new file mode 100644
index 000000000..83c4c22a9
--- /dev/null
+++ b/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/client/ui/build/appicon.icon/icon.json b/client/ui/build/appicon.icon/icon.json
new file mode 100644
index 000000000..4a0371af3
--- /dev/null
+++ b/client/ui/build/appicon.icon/icon.json
@@ -0,0 +1,26 @@
+{
+ "fill" : {
+ "solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
+ },
+ "groups" : [
+ {
+ "layers" : [
+ {
+ "image-name" : "wails_icon_vector.svg",
+ "name" : "wails_icon_vector"
+ }
+ ],
+ "shadow" : {
+ "kind" : "neutral",
+ "opacity" : 0.5
+ },
+ "specular" : true
+ }
+ ],
+ "supported-platforms" : {
+ "circles" : [
+ "watchOS"
+ ],
+ "squares" : "shared"
+ }
+}
diff --git a/client/ui/build/appicon.png b/client/ui/build/appicon.png
new file mode 100644
index 000000000..977d2400a
Binary files /dev/null and b/client/ui/build/appicon.png differ
diff --git a/client/ui/build/build-ui-linux.sh b/client/ui/build/build-ui-linux.sh
deleted file mode 100644
index eab08214d..000000000
--- a/client/ui/build/build-ui-linux.sh
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/bash
-sudo apt update
-sudo apt remove gir1.2-appindicator3-0.1
-sudo apt install -y libayatana-appindicator3-dev
-go build
\ No newline at end of file
diff --git a/client/ui/build/config.yml b/client/ui/build/config.yml
new file mode 100644
index 000000000..08b95b6bd
--- /dev/null
+++ b/client/ui/build/config.yml
@@ -0,0 +1,78 @@
+# This file contains the configuration for this project.
+# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
+# Note that this will overwrite any changes you have made to the assets.
+version: '3'
+
+# This information is used to generate the build assets.
+info:
+ companyName: "NetBird GmbH" # The name of the company
+ productName: "NetBird" # The name of the application
+ productIdentifier: "io.netbird.client" # The unique product identifier
+ description: "NetBird desktop client" # The application description
+ copyright: "NetBird GmbH" # Copyright text
+ comments: "Some Product Comments" # Comments
+ version: "0.0.1" # The application version
+ # cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
+ # # Should match the name of your .icon file without the extension
+ # # If not set and Assets.car exists, defaults to "appicon"
+
+# iOS build configuration (uncomment to customise iOS project generation)
+# Note: Keys under `ios` OVERRIDE values under `info` when set.
+# ios:
+# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
+# bundleID: "com.mycompany.myproduct"
+# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
+# displayName: "My Product"
+# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
+# version: "0.0.1"
+# # The company/organisation name for templates and project settings
+# company: "My Company"
+# # Additional comments to embed in Info.plist metadata
+# comments: "Some Product Comments"
+
+# Dev mode configuration
+dev_mode:
+ root_path: .
+ log_level: warn
+ debounce: 1000
+ ignore:
+ dir:
+ - .git
+ - node_modules
+ - frontend
+ - bin
+ file:
+ - .DS_Store
+ - .gitignore
+ - .gitkeep
+ watched_extension:
+ - "*.go"
+ - "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
+ - "*.ts" # The frontend directory will be excluded entirely by the setting above.
+ git_ignore: true
+ executes:
+ - cmd: wails3 build DEV=true
+ type: blocking
+ - cmd: wails3 task common:dev:frontend
+ type: background
+ - cmd: wails3 task run
+ type: primary
+
+# File Associations
+# More information at: https://v3.wails.io/noit/done/yet
+fileAssociations:
+# - ext: wails
+# name: Wails
+# description: Wails Application File
+# iconName: wailsFileIcon
+# role: Editor
+# - ext: jpg
+# name: JPEG
+# description: Image File
+# iconName: jpegFileIcon
+# role: Editor
+# mimeType: image/jpeg # (optional)
+
+# Other data
+other:
+ - name: My Other Data
\ No newline at end of file
diff --git a/client/ui/build/darwin/Info.dev.plist b/client/ui/build/darwin/Info.dev.plist
new file mode 100644
index 000000000..78f5a7b1c
--- /dev/null
+++ b/client/ui/build/darwin/Info.dev.plist
@@ -0,0 +1,38 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ NetBird
+ CFBundleDisplayName
+ NetBird
+ CFBundleExecutable
+ netbird-ui
+ CFBundleIdentifier
+ io.netbird.client
+ CFBundleVersion
+ 0.0.1
+ CFBundleGetInfoString
+ This is a comment
+ CFBundleShortVersionString
+ 0.0.1
+ CFBundleIconFile
+ icons
+ CFBundleIconName
+ appicon
+ LSMinimumSystemVersion
+ 10.15.0
+ NSHighResolutionCapable
+ true
+ LSUIElement
+ 1
+ NSHumanReadableCopyright
+ NetBird GmbH
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+
+
\ No newline at end of file
diff --git a/client/ui/build/darwin/Info.plist b/client/ui/build/darwin/Info.plist
new file mode 100644
index 000000000..1e12b049b
--- /dev/null
+++ b/client/ui/build/darwin/Info.plist
@@ -0,0 +1,36 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ NetBird
+ CFBundleDisplayName
+ NetBird
+ CFBundleExecutable
+ netbird-ui
+ CFBundleIdentifier
+ io.netbird.client
+ CFBundleVersion
+ 0.0.1
+ CFBundleGetInfoString
+ This is a comment
+ CFBundleShortVersionString
+ 0.0.1
+ CFBundleIconFile
+ icons
+ CFBundleIconName
+ appicon
+ LSMinimumSystemVersion
+ 10.15.0
+ NSHighResolutionCapable
+ true
+
+ LSUIElement
+ 1
+ NSHumanReadableCopyright
+ NetBird GmbH
+
+
\ No newline at end of file
diff --git a/client/ui/build/darwin/Taskfile.yml b/client/ui/build/darwin/Taskfile.yml
new file mode 100644
index 000000000..8a5c27bdc
--- /dev/null
+++ b/client/ui/build/darwin/Taskfile.yml
@@ -0,0 +1,210 @@
+version: '3'
+
+includes:
+ common: ../Taskfile.yml
+
+vars:
+ # Signing configuration - edit these values for your project
+ # SIGN_IDENTITY: "Developer ID Application: Your Company (TEAMID)"
+ # KEYCHAIN_PROFILE: "my-notarize-profile"
+ # ENTITLEMENTS: "build/darwin/entitlements.plist"
+
+ # Docker image for cross-compilation (used when building on non-macOS)
+ CROSS_IMAGE: wails-cross
+
+tasks:
+ build:
+ summary: Builds the application
+ cmds:
+ - task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}'
+ vars:
+ ARCH: '{{.ARCH}}'
+ DEV: '{{.DEV}}'
+ OUTPUT: '{{.OUTPUT}}'
+ EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+ vars:
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+
+ build:native:
+ summary: Builds the application natively on macOS
+ internal: true
+ deps:
+ - task: common:go:mod:tidy
+ - task: common:build:frontend
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ DEV:
+ ref: .DEV
+ - task: common:generate:icons
+ cmds:
+ - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
+ vars:
+ BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}'
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+ env:
+ GOOS: darwin
+ CGO_ENABLED: 1
+ GOARCH: '{{.ARCH | default ARCH}}'
+ CGO_CFLAGS: "-mmacosx-version-min=10.15"
+ CGO_LDFLAGS: "-mmacosx-version-min=10.15"
+ MACOSX_DEPLOYMENT_TARGET: "10.15"
+
+ build:docker:
+ summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts)
+ internal: true
+ deps:
+ - task: common:build:frontend
+ - task: common:generate:icons
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required for cross-compilation. Please install Docker."
+ - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+ msg: |
+ Docker image '{{.CROSS_IMAGE}}' not found.
+ Build it first: wails3 task setup:docker
+ cmds:
+ - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}}
+ - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+ - mkdir -p {{.BIN_DIR}}
+ - mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
+ vars:
+ DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}'
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+ # Mount Go module cache for faster builds
+ GO_CACHE_MOUNT:
+ sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
+ # Extract replace directives from go.mod and create -v mounts for each
+ # Handles both relative (=> ../) and absolute (=> /) paths
+ REPLACE_MOUNTS:
+ sh: |
+ grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
+ path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
+ # Convert relative paths to absolute
+ if [ "${path#/}" = "$path" ]; then
+ path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
+ fi
+ # Only mount if directory exists
+ if [ -d "$path" ]; then
+ echo "-v $path:$path:ro"
+ fi
+ done | tr '\n' ' '
+
+ build:universal:
+ summary: Builds darwin universal binary (arm64 + amd64)
+ deps:
+ - task: build
+ vars:
+ ARCH: amd64
+ OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
+ - task: build
+ vars:
+ ARCH: arm64
+ OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+ cmds:
+ - task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}'
+
+ build:universal:lipo:native:
+ summary: Creates universal binary using native lipo (macOS)
+ internal: true
+ cmds:
+ - lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+ - rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+
+ build:universal:lipo:go:
+ summary: Creates universal binary using wails3 tool lipo (Linux/Windows)
+ internal: true
+ cmds:
+ - wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+ - rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
+
+ package:
+ summary: Packages the application into a `.app` bundle
+ deps:
+ - task: build
+ cmds:
+ - task: create:app:bundle
+
+ package:universal:
+ summary: Packages darwin universal binary (arm64 + amd64)
+ deps:
+ - task: build:universal
+ cmds:
+ - task: create:app:bundle
+
+
+ create:app:bundle:
+ summary: Creates an `.app` bundle
+ cmds:
+ - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
+ - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+ - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+ - |
+ if [ -f build/darwin/Assets.car ]; then
+ cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
+ fi
+ - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
+ - cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents"
+ - task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}'
+
+ codesign:adhoc:
+ summary: Ad-hoc signs the app bundle (macOS only)
+ internal: true
+ cmds:
+ - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
+
+ codesign:skip:
+ summary: Skips codesigning when cross-compiling
+ internal: true
+ cmds:
+ - 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
+
+ run:
+ deps:
+ - task: common:generate:icons
+ cmds:
+ - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
+ - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+ - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+ - |
+ if [ -f build/darwin/Assets.car ]; then
+ cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
+ fi
+ - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
+ - cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
+ - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
+ - '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}'
+
+ sign:
+ summary: Signs the application bundle with Developer ID
+ desc: |
+ Signs the .app bundle for distribution.
+ Configure SIGN_IDENTITY in the vars section at the top of this file.
+ deps:
+ - task: package
+ cmds:
+ - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}}
+ preconditions:
+ - sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
+ msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
+
+ sign:notarize:
+ summary: Signs and notarizes the application bundle
+ desc: |
+ Signs the .app bundle and submits it for notarization.
+ Configure SIGN_IDENTITY and KEYCHAIN_PROFILE in the vars section at the top of this file.
+
+ Setup (one-time):
+ wails3 signing credentials --apple-id "you@email.com" --team-id "TEAMID" --password "app-specific-password" --profile "my-profile"
+ deps:
+ - task: package
+ cmds:
+ - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} --notarize --keychain-profile {{.KEYCHAIN_PROFILE}}
+ preconditions:
+ - sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
+ msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
+ - sh: '[ -n "{{.KEYCHAIN_PROFILE}}" ]'
+ msg: "KEYCHAIN_PROFILE is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
diff --git a/client/ui/build/darwin/icons.icns b/client/ui/build/darwin/icons.icns
new file mode 100644
index 000000000..fb78a18a9
Binary files /dev/null and b/client/ui/build/darwin/icons.icns differ
diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross
new file mode 100644
index 000000000..a487b8db0
--- /dev/null
+++ b/client/ui/build/docker/Dockerfile.cross
@@ -0,0 +1,203 @@
+# Cross-compile Wails v3 apps to any platform
+#
+# Darwin: Zig + macOS SDK
+# Linux: Native GCC when host matches target, Zig for cross-arch
+# Windows: Zig + bundled mingw
+#
+# Usage:
+# docker build -t wails-cross -f Dockerfile.cross .
+# docker run --rm -v $(pwd):/app wails-cross darwin arm64
+# docker run --rm -v $(pwd):/app wails-cross darwin amd64
+# docker run --rm -v $(pwd):/app wails-cross linux amd64
+# docker run --rm -v $(pwd):/app wails-cross linux arm64
+# docker run --rm -v $(pwd):/app wails-cross windows amd64
+# docker run --rm -v $(pwd):/app wails-cross windows arm64
+
+FROM golang:1.25-bookworm
+
+ARG TARGETARCH
+
+# Install base tools, GCC, and GTK/WebKit dev packages
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl xz-utils nodejs npm pkg-config gcc libc6-dev \
+ libgtk-3-dev libwebkit2gtk-4.1-dev \
+ libgtk-4-dev libwebkitgtk-6.0-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Zig - automatically selects correct binary for host architecture
+ARG ZIG_VERSION=0.14.0
+RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
+ curl -L "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" \
+ | tar -xJ -C /opt \
+ && ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
+
+# Download macOS SDK (required for darwin targets)
+ARG MACOS_SDK_VERSION=14.5
+RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \
+ | tar -xJ -C /opt \
+ && mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk
+
+ENV MACOS_SDK_PATH=/opt/macos-sdk
+
+# Create Zig CC wrappers for cross-compilation targets
+# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch)
+
+# Darwin arm64
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+ if [ $SKIP_NEXT -eq 1 ]; then
+ SKIP_NEXT=0
+ continue
+ fi
+ case "$arg" in
+ -target) SKIP_NEXT=1 ;;
+ -mmacosx-version-min=*) ;;
+ *) ARGS="$ARGS $arg" ;;
+ esac
+done
+exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-darwin-arm64
+
+# Darwin amd64
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+ if [ $SKIP_NEXT -eq 1 ]; then
+ SKIP_NEXT=0
+ continue
+ fi
+ case "$arg" in
+ -target) SKIP_NEXT=1 ;;
+ -mmacosx-version-min=*) ;;
+ *) ARGS="$ARGS $arg" ;;
+ esac
+done
+exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-darwin-amd64
+
+# Windows amd64 - uses Zig's bundled mingw
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+ if [ $SKIP_NEXT -eq 1 ]; then
+ SKIP_NEXT=0
+ continue
+ fi
+ case "$arg" in
+ -target) SKIP_NEXT=1 ;;
+ -Wl,*) ;;
+ *) ARGS="$ARGS $arg" ;;
+ esac
+done
+exec zig cc -target x86_64-windows-gnu $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-windows-amd64
+
+# Windows arm64 - uses Zig's bundled mingw
+COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64
+#!/bin/sh
+ARGS=""
+SKIP_NEXT=0
+for arg in "$@"; do
+ if [ $SKIP_NEXT -eq 1 ]; then
+ SKIP_NEXT=0
+ continue
+ fi
+ case "$arg" in
+ -target) SKIP_NEXT=1 ;;
+ -Wl,*) ;;
+ *) ARGS="$ARGS $arg" ;;
+ esac
+done
+exec zig cc -target aarch64-windows-gnu $ARGS
+ZIGWRAP
+RUN chmod +x /usr/local/bin/zcc-windows-arm64
+
+# Build script
+COPY <<'SCRIPT' /usr/local/bin/build.sh
+#!/bin/sh
+set -e
+
+OS=${1:-darwin}
+ARCH=${2:-arm64}
+
+case "${OS}-${ARCH}" in
+ darwin-arm64|darwin-aarch64)
+ export CC=zcc-darwin-arm64
+ export GOARCH=arm64
+ export GOOS=darwin
+ ;;
+ darwin-amd64|darwin-x86_64)
+ export CC=zcc-darwin-amd64
+ export GOARCH=amd64
+ export GOOS=darwin
+ ;;
+ linux-arm64|linux-aarch64)
+ export CC=gcc
+ export GOARCH=arm64
+ export GOOS=linux
+ ;;
+ linux-amd64|linux-x86_64)
+ export CC=gcc
+ export GOARCH=amd64
+ export GOOS=linux
+ ;;
+ windows-arm64|windows-aarch64)
+ export CC=zcc-windows-arm64
+ export GOARCH=arm64
+ export GOOS=windows
+ ;;
+ windows-amd64|windows-x86_64)
+ export CC=zcc-windows-amd64
+ export GOARCH=amd64
+ export GOOS=windows
+ ;;
+ *)
+ echo "Usage: "
+ echo " os: darwin, linux, windows"
+ echo " arch: amd64, arm64"
+ exit 1
+ ;;
+esac
+
+export CGO_ENABLED=1
+export CGO_CFLAGS="-w"
+
+# Build frontend if exists and not already built (host may have built it)
+if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
+ (cd frontend && npm install --silent && npm run build --silent)
+fi
+
+# Build
+APP=${APP_NAME:-$(basename $(pwd))}
+mkdir -p bin
+
+EXT=""
+LDFLAGS="-s -w"
+if [ "$GOOS" = "windows" ]; then
+ EXT=".exe"
+ LDFLAGS="-s -w -H windowsgui"
+fi
+
+TAGS="production"
+if [ -n "$EXTRA_TAGS" ]; then
+ TAGS="${TAGS},${EXTRA_TAGS}"
+fi
+
+go build -tags "$TAGS" -trimpath -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} .
+echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}"
+SCRIPT
+RUN chmod +x /usr/local/bin/build.sh
+
+WORKDIR /app
+ENTRYPOINT ["/usr/local/bin/build.sh"]
+CMD ["darwin", "arm64"]
diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server
new file mode 100644
index 000000000..58fb64f76
--- /dev/null
+++ b/client/ui/build/docker/Dockerfile.server
@@ -0,0 +1,41 @@
+# Wails Server Mode Dockerfile
+# Multi-stage build for minimal image size
+
+# Build stage
+FROM golang:alpine AS builder
+
+WORKDIR /app
+
+# Install build dependencies
+RUN apk add --no-cache git
+
+# Copy source code
+COPY . .
+
+# Remove local replace directive if present (for production builds)
+RUN sed -i '/^replace/d' go.mod || true
+
+# Download dependencies
+RUN go mod tidy
+
+# Build the server binary
+RUN go build -tags server -ldflags="-s -w" -o server .
+
+# Runtime stage - minimal image
+FROM gcr.io/distroless/static-debian12
+
+# Copy the binary
+COPY --from=builder /app/server /server
+
+# Copy frontend assets
+COPY --from=builder /app/frontend/dist /frontend/dist
+
+# Expose the default port
+EXPOSE 8080
+
+# Bind to all interfaces (required for Docker)
+# Can be overridden at runtime with -e WAILS_SERVER_HOST=...
+ENV WAILS_SERVER_HOST=0.0.0.0
+
+# Run the server
+ENTRYPOINT ["/server"]
diff --git a/client/ui/build/linux/Taskfile.yml b/client/ui/build/linux/Taskfile.yml
new file mode 100644
index 000000000..94d041375
--- /dev/null
+++ b/client/ui/build/linux/Taskfile.yml
@@ -0,0 +1,235 @@
+version: '3'
+
+includes:
+ common: ../Taskfile.yml
+
+vars:
+ # Signing configuration - edit these values for your project
+ # PGP_KEY: "path/to/signing-key.asc"
+ # SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
+ #
+ # Password is stored securely in system keychain. Run: wails3 setup signing
+
+ # Docker image for cross-compilation (used when building on non-Linux or no CC available)
+ CROSS_IMAGE: wails-cross
+
+tasks:
+ build:
+ summary: Builds the application for Linux
+ cmds:
+ # Linux requires CGO - use Docker when:
+ # 1. Cross-compiling from non-Linux, OR
+ # 2. No C compiler is available, OR
+ # 3. Target architecture differs from host architecture (cross-arch compilation)
+ - task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
+ vars:
+ ARCH: '{{.ARCH}}'
+ DEV: '{{.DEV}}'
+ OUTPUT: '{{.OUTPUT}}'
+ EXTRA_TAGS: '{{.EXTRA_TAGS}}'
+ vars:
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+ # Determine target architecture (defaults to host ARCH if not specified)
+ TARGET_ARCH: '{{.ARCH | default ARCH}}'
+ # Check if a C compiler is available (gcc or clang)
+ HAS_CC:
+ sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
+
+ build:native:
+ summary: Builds the application natively on Linux
+ internal: true
+ deps:
+ - task: common:go:mod:tidy
+ - task: common:build:frontend
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ DEV:
+ ref: .DEV
+ - task: common:generate:icons
+ - task: generate:dotdesktop
+ cmds:
+ - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
+ vars:
+ BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}'
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+ env:
+ GOOS: linux
+ CGO_ENABLED: 1
+ GOARCH: '{{.ARCH | default ARCH}}'
+
+ build:docker:
+ summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
+ internal: true
+ deps:
+ - task: common:build:frontend
+ - task: common:generate:icons
+ - task: generate:dotdesktop
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required for cross-compilation to Linux. Please install Docker."
+ - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
+ msg: |
+ Docker image '{{.CROSS_IMAGE}}' not found.
+ Build it first: wails3 task setup:docker
+ cmds:
+ - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
+ - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
+ - mkdir -p {{.BIN_DIR}}
+ - mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
+ vars:
+ DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
+ DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
+ OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
+ # Mount Go module cache for faster builds
+ GO_CACHE_MOUNT:
+ sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
+ # Extract replace directives from go.mod and create -v mounts for each
+ REPLACE_MOUNTS:
+ sh: |
+ grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
+ path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
+ # Convert relative paths to absolute
+ if [ "${path#/}" = "$path" ]; then
+ path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
+ fi
+ # Only mount if directory exists
+ if [ -d "$path" ]; then
+ echo "-v $path:$path:ro"
+ fi
+ done | tr '\n' ' '
+
+ package:
+ summary: Packages the application for Linux
+ deps:
+ - task: build
+ cmds:
+ - task: create:appimage
+ - task: create:deb
+ - task: create:rpm
+ - task: create:aur
+
+ create:appimage:
+ summary: Creates an AppImage
+ dir: build/linux/appimage
+ deps:
+ - task: build
+ - task: generate:dotdesktop
+ cmds:
+ - cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
+ - cp ../../appicon.png "{{.APP_NAME}}.png"
+ - wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
+ vars:
+ APP_NAME: '{{.APP_NAME}}'
+ APP_BINARY: '../../../bin/{{.APP_NAME}}'
+ ICON: '{{.APP_NAME}}.png'
+ DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
+ OUTPUT_DIR: '../../../bin'
+
+ create:deb:
+ summary: Creates a deb package
+ deps:
+ - task: build
+ cmds:
+ - task: generate:dotdesktop
+ - task: generate:deb
+
+ create:rpm:
+ summary: Creates a rpm package
+ deps:
+ - task: build
+ cmds:
+ - task: generate:dotdesktop
+ - task: generate:rpm
+
+ create:aur:
+ summary: Creates a arch linux packager package
+ deps:
+ - task: build
+ cmds:
+ - task: generate:dotdesktop
+ - task: generate:aur
+
+ generate:deb:
+ summary: Creates a deb package
+ cmds:
+ - wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+ generate:rpm:
+ summary: Creates a rpm package
+ cmds:
+ - wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+ generate:aur:
+ summary: Creates a arch linux packager package
+ cmds:
+ - wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
+
+ generate:dotdesktop:
+ summary: Generates a `.desktop` file
+ dir: build
+ cmds:
+ - mkdir -p {{.ROOT_DIR}}/build/linux/appimage
+ - wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
+ # Wrap Exec= with `env WEBKIT_DISABLE_DMABUF_RENDERER=1 ...` so launches
+ # from any desktop environment use the working renderer. See build/linux/Taskfile.yml :run for the matching dev-mode env block.
+ - sed -i -E 's|^Exec=([^ ]+)(.*)$|Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 \1\2|' {{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop
+ vars:
+ APP_NAME: '{{.APP_NAME}}'
+ EXEC: '{{.APP_NAME}}'
+ ICON: '{{.APP_NAME}}'
+ CATEGORIES: 'Development;'
+ OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
+
+ run:
+ cmds:
+ - '{{.BIN_DIR}}/{{.APP_NAME}}'
+ env:
+ # WebKitGTK 2.50's default DMA-BUF renderer fails on RDP, VirtualBox/QEMU,
+ # and some bare WMs (Fluxbox, dwm) where DRM dumb-buffer access is
+ # restricted. Disabling it falls back to the GLES2/cairo path which works
+ # everywhere. Production launchers must set this too.
+ WEBKIT_DISABLE_DMABUF_RENDERER: "1"
+
+ sign:deb:
+ summary: Signs the DEB package
+ desc: |
+ Signs the .deb package with a PGP key.
+ Configure PGP_KEY in the vars section at the top of this file.
+ Password is retrieved from system keychain (run: wails3 setup signing)
+ deps:
+ - task: create:deb
+ cmds:
+ - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" --pgp-key {{.PGP_KEY}} {{if .SIGN_ROLE}}--role {{.SIGN_ROLE}}{{end}}
+ preconditions:
+ - sh: '[ -n "{{.PGP_KEY}}" ]'
+ msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
+
+ sign:rpm:
+ summary: Signs the RPM package
+ desc: |
+ Signs the .rpm package with a PGP key.
+ Configure PGP_KEY in the vars section at the top of this file.
+ Password is retrieved from system keychain (run: wails3 setup signing)
+ deps:
+ - task: create:rpm
+ cmds:
+ - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" --pgp-key {{.PGP_KEY}}
+ preconditions:
+ - sh: '[ -n "{{.PGP_KEY}}" ]'
+ msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
+
+ sign:packages:
+ summary: Signs all Linux packages (DEB and RPM)
+ desc: |
+ Signs both .deb and .rpm packages with a PGP key.
+ Configure PGP_KEY in the vars section at the top of this file.
+ Password is retrieved from system keychain (run: wails3 setup signing)
+ cmds:
+ - task: sign:deb
+ - task: sign:rpm
+ preconditions:
+ - sh: '[ -n "{{.PGP_KEY}}" ]'
+ msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
diff --git a/client/ui/build/linux/appimage/build.sh b/client/ui/build/linux/appimage/build.sh
new file mode 100644
index 000000000..85901c34e
--- /dev/null
+++ b/client/ui/build/linux/appimage/build.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Copyright (c) 2018-Present Lea Anthony
+# SPDX-License-Identifier: MIT
+
+# Fail script on any error
+set -euxo pipefail
+
+# Define variables
+APP_DIR="${APP_NAME}.AppDir"
+
+# Create AppDir structure
+mkdir -p "${APP_DIR}/usr/bin"
+cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
+cp "${ICON_PATH}" "${APP_DIR}/"
+cp "${DESKTOP_FILE}" "${APP_DIR}/"
+
+if [[ $(uname -m) == *x86_64* ]]; then
+ # Download linuxdeploy and make it executable
+ wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
+ chmod +x linuxdeploy-x86_64.AppImage
+
+ # Run linuxdeploy to bundle the application
+ ./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
+else
+ # Download linuxdeploy and make it executable (arm64)
+ wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage
+ chmod +x linuxdeploy-aarch64.AppImage
+
+ # Run linuxdeploy to bundle the application (arm64)
+ ./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage
+fi
+
+# Rename the generated AppImage
+mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"
+
diff --git a/client/ui/build/linux/desktop b/client/ui/build/linux/desktop
new file mode 100644
index 000000000..deadfe9f4
--- /dev/null
+++ b/client/ui/build/linux/desktop
@@ -0,0 +1,13 @@
+[Desktop Entry]
+Version=1.0
+Name=NetBird
+Comment=NetBird desktop client
+# The Exec line includes %u to pass the URL to the application
+Exec=/usr/local/bin/netbird-ui %u
+Terminal=false
+Type=Application
+Icon=netbird-ui
+Categories=Utility;
+StartupWMClass=netbird-ui
+
+
diff --git a/client/ui/build/linux/netbird-ui.desktop b/client/ui/build/linux/netbird-ui.desktop
new file mode 100755
index 000000000..6b6ed42a5
--- /dev/null
+++ b/client/ui/build/linux/netbird-ui.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=netbird-ui
+Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
+Icon=netbird-ui
+Categories=Development;
+Terminal=false
+Keywords=wails
+Version=1.0
+StartupNotify=false
diff --git a/client/ui/build/netbird.desktop b/client/ui/build/linux/netbird.desktop
similarity index 54%
rename from client/ui/build/netbird.desktop
rename to client/ui/build/linux/netbird.desktop
index b3a1b92dc..a81f3698a 100644
--- a/client/ui/build/netbird.desktop
+++ b/client/ui/build/linux/netbird.desktop
@@ -1,8 +1,9 @@
[Desktop Entry]
Name=Netbird
-Exec=/usr/bin/netbird-ui
+Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
Icon=netbird
Type=Application
Terminal=false
Categories=Utility;
Keywords=netbird;
+StartupWMClass=org.wails.netbird
\ No newline at end of file
diff --git a/client/ui/build/linux/nfpm/nfpm.yaml b/client/ui/build/linux/nfpm/nfpm.yaml
new file mode 100644
index 000000000..a05daef62
--- /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
+ - libwebkitgtk-6.0-4
+ - xdg-utils
+
+# Distribution-specific overrides for different package formats
+overrides:
+ # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
+ rpm:
+ depends:
+ - gtk4
+ - webkitgtk6.0
+ - 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..3131b36cd
--- /dev/null
+++ b/client/ui/frontend/package.json
@@ -0,0 +1,69 @@
+{
+ "name": "netbird-ui",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build:dev": "tsc && vite build --minify false --mode development",
+ "build": "tsc && vite build --mode production",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "bindings": "cd .. && wails3 generate bindings -clean=true -ts",
+ "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
+ "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
+ "lint": "eslint \"src/**/*.{ts,tsx}\"",
+ "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
+ "check": "pnpm lint && pnpm typecheck && pnpm format:check",
+ "check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck"
+ },
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-radio-group": "^1.3.8",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@radix-ui/react-visually-hidden": "^1.2.4",
+ "@wailsio/runtime": "latest",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "framer-motion": "^12.38.0",
+ "i18next": "^26.2.0",
+ "lucide-react": "^0.566.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-i18next": "^17.0.8",
+ "react-loading-skeleton": "^3.5.0",
+ "react-router-dom": "^7.1.3",
+ "react-virtuoso": "^4.12.5",
+ "tailwind-merge": "^2.6.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@types/node": "^25.6.0",
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.39.4",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.3",
+ "globals": "^17.6.0",
+ "postcss": "^8.5.1",
+ "prettier": "^3.8.3",
+ "prettier-plugin-tailwindcss": "^0.8.0",
+ "tailwindcss": "^3.4.17",
+ "tailwindcss-animate": "^1.0.7",
+ "typescript": "^5.7.3",
+ "typescript-eslint": "^8.61.1",
+ "vite": "^6.0.7"
+ },
+ "packageManager": "pnpm@11.4.0+sha512.f0febc7e37552ab485494a914241b338e0b3580b93d54ce31f00933015880863129038a1b4ae4e414a0ee63ac35bf21197e990172c4a68256450b5636310968f"
+}
diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml
new file mode 100644
index 000000000..b6b3dd336
--- /dev/null
+++ b/client/ui/frontend/pnpm-lock.yaml
@@ -0,0 +1,5240 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@radix-ui/react-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-dropdown-menu':
+ specifier: ^2.1.16
+ version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-label':
+ specifier: ^2.1.8
+ version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-popover':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-radio-group':
+ specifier: ^1.3.8
+ version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-scroll-area':
+ specifier: ^1.2.10
+ version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-switch':
+ specifier: ^1.2.6
+ version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-tabs':
+ specifier: ^1.1.13
+ version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-tooltip':
+ specifier: ^1.2.8
+ version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-visually-hidden':
+ specifier: ^1.2.4
+ version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wailsio/runtime':
+ specifier: latest
+ version: 3.0.0-alpha.79
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ cmdk:
+ specifier: ^1.1.1
+ version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ framer-motion:
+ specifier: ^12.38.0
+ version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ i18next:
+ specifier: ^26.2.0
+ version: 26.3.0(typescript@5.9.3)
+ lucide-react:
+ specifier: ^0.566.0
+ version: 0.566.0(react@18.3.1)
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ react-i18next:
+ specifier: ^17.0.8
+ version: 17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
+ react-loading-skeleton:
+ specifier: ^3.5.0
+ version: 3.5.0(react@18.3.1)
+ react-router-dom:
+ specifier: ^7.1.3
+ version: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react-virtuoso:
+ specifier: ^4.12.5
+ version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ tailwind-merge:
+ specifier: ^2.6.0
+ version: 2.6.1
+ devDependencies:
+ '@eslint/js':
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@9.39.4(jiti@1.21.7))
+ '@types/node':
+ specifier: ^25.6.0
+ version: 25.9.1
+ '@types/react':
+ specifier: ^18.3.18
+ version: 18.3.29
+ '@types/react-dom':
+ specifier: ^18.3.5
+ version: 18.3.7(@types/react@18.3.29)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7))
+ autoprefixer:
+ specifier: ^10.4.20
+ version: 10.5.0(postcss@8.5.15)
+ eslint:
+ specifier: ^9.39.4
+ version: 9.39.4(jiti@1.21.7)
+ eslint-plugin-jsx-a11y:
+ specifier: ^6.10.2
+ version: 6.10.2(eslint@9.39.4(jiti@1.21.7))
+ eslint-plugin-react:
+ specifier: ^7.37.5
+ version: 7.37.5(eslint@9.39.4(jiti@1.21.7))
+ eslint-plugin-react-hooks:
+ specifier: ^7.1.1
+ version: 7.1.1(eslint@9.39.4(jiti@1.21.7))
+ eslint-plugin-react-refresh:
+ specifier: ^0.5.3
+ version: 0.5.3(eslint@9.39.4(jiti@1.21.7))
+ globals:
+ specifier: ^17.6.0
+ version: 17.6.0
+ postcss:
+ specifier: ^8.5.1
+ version: 8.5.15
+ prettier:
+ specifier: ^3.8.3
+ version: 3.8.3
+ prettier-plugin-tailwindcss:
+ specifier: ^0.8.0
+ version: 0.8.0(prettier@3.8.3)
+ tailwindcss:
+ specifier: ^3.4.17
+ version: 3.4.19
+ tailwindcss-animate:
+ specifier: ^1.0.7
+ version: 1.0.7(tailwindcss@3.4.19)
+ typescript:
+ specifier: ^5.7.3
+ version: 5.9.3
+ typescript-eslint:
+ specifier: ^8.61.1
+ version: 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ vite:
+ specifier: ^6.0.7
+ version: 6.4.2(@types/node@25.9.1)(jiti@1.21.7)
+
+packages:
+
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.7':
+ resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.7':
+ resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7':
+ resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7':
+ resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.7':
+ resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.9.1':
+ resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.21.2':
+ resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.5':
+ resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@10.0.1':
+ resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ peerDependencies:
+ eslint: ^10.0.0
+ peerDependenciesMeta:
+ eslint:
+ optional: true
+
+ '@eslint/js@9.39.4':
+ resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.7':
+ resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@floating-ui/core@1.7.5':
+ resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
+
+ '@floating-ui/dom@1.7.6':
+ resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+
+ '@floating-ui/react-dom@2.1.8':
+ resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/utils@0.2.11':
+ resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+
+ '@humanfs/core@0.19.2':
+ resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.8':
+ resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/types@0.15.0':
+ resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@nodelib/fs.scandir@2.1.5':
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.stat@2.0.5':
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.walk@1.2.8':
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+
+ '@radix-ui/number@1.1.1':
+ resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
+
+ '@radix-ui/primitive@1.1.3':
+ resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+
+ '@radix-ui/react-arrow@1.1.7':
+ resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-collection@1.1.7':
+ resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-compose-refs@1.1.2':
+ resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-context@1.1.2':
+ resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-dialog@1.1.15':
+ resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-direction@1.1.1':
+ resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-dismissable-layer@1.1.11':
+ resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-dropdown-menu@2.1.16':
+ resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-focus-guards@1.1.3':
+ resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-focus-scope@1.1.7':
+ resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-id@1.1.1':
+ resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-label@2.1.8':
+ resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-menu@2.1.16':
+ resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-popover@1.1.15':
+ resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-popper@1.2.8':
+ resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-portal@1.1.9':
+ resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-presence@1.1.5':
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-primitive@2.1.3':
+ resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-primitive@2.1.4':
+ resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-radio-group@1.3.8':
+ resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-roving-focus@1.1.11':
+ resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-scroll-area@1.2.10':
+ resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.3':
+ resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.4':
+ resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-switch@1.2.6':
+ resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-tabs@1.1.13':
+ resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-tooltip@1.2.8':
+ resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-use-callback-ref@1.1.1':
+ resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-controllable-state@1.2.2':
+ resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-effect-event@0.0.2':
+ resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-escape-keydown@1.1.1':
+ resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-layout-effect@1.1.1':
+ resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-previous@1.1.1':
+ resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-rect@1.1.1':
+ resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-size@1.1.1':
+ resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-visually-hidden@1.2.3':
+ resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-visually-hidden@1.2.4':
+ resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/rect@1.1.1':
+ resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+
+ '@rolldown/pluginutils@1.0.0-beta.27':
+ resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+ '@rollup/rollup-android-arm-eabi@4.60.4':
+ resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.60.4':
+ resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.60.4':
+ resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.60.4':
+ resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.60.4':
+ resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.60.4':
+ resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.4':
+ resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.4':
+ resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.4':
+ resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.60.4':
+ resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.4':
+ resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.60.4':
+ resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.4':
+ resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.4':
+ resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.4':
+ resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.4':
+ resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.4':
+ resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.60.4':
+ resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.60.4':
+ resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.60.4':
+ resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.60.4':
+ resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.4':
+ resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.4':
+ resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.60.4':
+ resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.60.4':
+ resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/node@25.9.1':
+ resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
+
+ '@types/prop-types@15.7.15':
+ resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
+
+ '@types/react-dom@18.3.7':
+ resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
+ peerDependencies:
+ '@types/react': ^18.0.0
+
+ '@types/react@18.3.29':
+ resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==}
+
+ '@typescript-eslint/eslint-plugin@8.61.1':
+ resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.61.1
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/parser@8.61.1':
+ resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/project-service@8.61.1':
+ resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/scope-manager@8.61.1':
+ resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.61.1':
+ resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/type-utils@8.61.1':
+ resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/types@8.61.1':
+ resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.61.1':
+ resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.61.1':
+ resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/visitor-keys@8.61.1':
+ resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@vitejs/plugin-react@4.7.0':
+ resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ '@wailsio/runtime@3.0.0-alpha.79':
+ resolution: {integrity: sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==}
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.17.0:
+ resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv@6.15.0:
+ resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ any-promise@1.3.0:
+ resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
+ anymatch@3.1.3:
+ resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+ engines: {node: '>= 8'}
+
+ arg@5.0.2:
+ resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ aria-hidden@1.2.6:
+ resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
+ engines: {node: '>=10'}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ array-buffer-byte-length@1.0.2:
+ resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
+ engines: {node: '>= 0.4'}
+
+ array-includes@3.1.9:
+ resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.findlast@1.2.5:
+ resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flat@1.3.3:
+ resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flatmap@1.3.3:
+ resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.tosorted@1.1.4:
+ resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
+ engines: {node: '>= 0.4'}
+
+ arraybuffer.prototype.slice@1.0.4:
+ resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
+ engines: {node: '>= 0.4'}
+
+ ast-types-flow@0.0.8:
+ resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
+
+ async-function@1.0.0:
+ resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
+ engines: {node: '>= 0.4'}
+
+ autoprefixer@10.5.0:
+ resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+
+ available-typed-arrays@1.0.7:
+ resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+ engines: {node: '>= 0.4'}
+
+ axe-core@4.12.1:
+ resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
+ engines: {node: '>=4'}
+
+ axobject-query@4.1.0:
+ resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
+ engines: {node: '>= 0.4'}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ baseline-browser-mapping@2.10.32:
+ resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ binary-extensions@2.3.0:
+ resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+ engines: {node: '>=8'}
+
+ brace-expansion@1.1.15:
+ resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
+
+ brace-expansion@5.0.6:
+ resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
+ engines: {node: 18 || 20 || >=22}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bind@1.0.9:
+ resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ camelcase-css@2.0.1:
+ resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+ engines: {node: '>= 6'}
+
+ caniuse-lite@1.0.30001793:
+ resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ chokidar@3.6.0:
+ resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+ engines: {node: '>= 8.10.0'}
+
+ class-variance-authority@0.7.1:
+ resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ cmdk@1.1.1:
+ resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
+ peerDependencies:
+ react: ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^18 || ^19 || ^19.0.0-rc
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ commander@4.1.1:
+ resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
+ engines: {node: '>= 6'}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ damerau-levenshtein@1.0.8:
+ resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
+
+ data-view-buffer@1.0.2:
+ resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-length@1.0.2:
+ resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-offset@1.0.1:
+ resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
+ engines: {node: '>= 0.4'}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
+
+ define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+
+ detect-node-es@1.1.0:
+ resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+
+ didyoumean@1.2.2:
+ resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+
+ dlv@1.1.3:
+ resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+
+ doctrine@2.1.0:
+ resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
+ engines: {node: '>=0.10.0'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ electron-to-chromium@1.5.362:
+ resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==}
+
+ emoji-regex@9.2.2:
+ resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+
+ es-abstract-get@1.0.0:
+ resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
+ engines: {node: '>= 0.4'}
+
+ es-abstract@1.24.2:
+ resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
+ engines: {node: '>= 0.4'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-iterator-helpers@1.3.3:
+ resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ es-shim-unscopables@1.1.0:
+ resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
+ engines: {node: '>= 0.4'}
+
+ es-to-primitive@1.3.1:
+ resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==}
+ engines: {node: '>= 0.4'}
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-plugin-jsx-a11y@6.10.2:
+ resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
+
+ eslint-plugin-react-hooks@7.1.1:
+ resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
+
+ eslint-plugin-react-refresh@0.5.3:
+ resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==}
+ peerDependencies:
+ eslint: ^9 || ^10
+
+ eslint-plugin-react@7.37.5:
+ resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@5.0.1:
+ resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ eslint@9.39.4:
+ resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-glob@3.3.3:
+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+ engines: {node: '>=8.6.0'}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fastq@1.20.1:
+ resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.4.2:
+ resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+
+ for-each@0.3.5:
+ resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+ engines: {node: '>= 0.4'}
+
+ fraction.js@5.3.4:
+ resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
+ framer-motion@12.40.0:
+ resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ function.prototype.name@1.2.0:
+ resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
+ engines: {node: '>= 0.4'}
+
+ functions-have-names@1.2.3:
+ resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+
+ generator-function@2.0.1:
+ resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
+ engines: {node: '>= 0.4'}
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-nonce@1.0.1:
+ resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
+ engines: {node: '>=6'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ get-symbol-description@1.1.0:
+ resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
+ engines: {node: '>= 0.4'}
+
+ glob-parent@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ globals@17.6.0:
+ resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
+ engines: {node: '>=18'}
+
+ globalthis@1.0.4:
+ resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ has-bigints@1.1.0:
+ resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
+ engines: {node: '>= 0.4'}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
+ has-proto@1.2.0:
+ resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
+ engines: {node: '>= 0.4'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.3:
+ resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ hermes-estree@0.25.1:
+ resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
+
+ hermes-parser@0.25.1:
+ resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+
+ html-parse-stringify@3.0.1:
+ resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+
+ i18next@26.3.0:
+ resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==}
+ peerDependencies:
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.5:
+ resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ internal-slot@1.1.0:
+ resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
+ engines: {node: '>= 0.4'}
+
+ is-array-buffer@3.0.5:
+ resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
+ engines: {node: '>= 0.4'}
+
+ is-async-function@2.1.1:
+ resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
+ engines: {node: '>= 0.4'}
+
+ is-bigint@1.1.0:
+ resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
+ engines: {node: '>= 0.4'}
+
+ is-binary-path@2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+
+ is-boolean-object@1.2.2:
+ resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
+ engines: {node: '>= 0.4'}
+
+ is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
+
+ is-core-module@2.16.2:
+ resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
+ engines: {node: '>= 0.4'}
+
+ is-data-view@1.0.2:
+ resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
+ engines: {node: '>= 0.4'}
+
+ is-date-object@1.1.0:
+ resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
+ engines: {node: '>= 0.4'}
+
+ is-document.all@1.0.0:
+ resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
+ engines: {node: '>= 0.4'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-finalizationregistry@1.1.1:
+ resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
+ engines: {node: '>= 0.4'}
+
+ is-generator-function@1.1.2:
+ resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
+ engines: {node: '>= 0.4'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-map@2.0.3:
+ resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
+ engines: {node: '>= 0.4'}
+
+ is-negative-zero@2.0.3:
+ resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
+ engines: {node: '>= 0.4'}
+
+ is-number-object@1.1.1:
+ resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
+ engines: {node: '>= 0.4'}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ is-regex@1.2.1:
+ resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+ engines: {node: '>= 0.4'}
+
+ is-set@2.0.3:
+ resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
+ engines: {node: '>= 0.4'}
+
+ is-shared-array-buffer@1.0.4:
+ resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
+ engines: {node: '>= 0.4'}
+
+ is-string@1.1.1:
+ resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
+ engines: {node: '>= 0.4'}
+
+ is-symbol@1.1.1:
+ resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
+ engines: {node: '>= 0.4'}
+
+ is-typed-array@1.1.15:
+ resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+ engines: {node: '>= 0.4'}
+
+ is-weakmap@2.0.2:
+ resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
+ engines: {node: '>= 0.4'}
+
+ is-weakref@1.1.1:
+ resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
+ engines: {node: '>= 0.4'}
+
+ is-weakset@2.0.4:
+ resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
+ engines: {node: '>= 0.4'}
+
+ isarray@2.0.5:
+ resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ iterator.prototype@1.1.5:
+ resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
+ engines: {node: '>= 0.4'}
+
+ jiti@1.21.7:
+ resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
+ hasBin: true
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ js-yaml@4.2.0:
+ resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
+ hasBin: true
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ jsx-ast-utils@3.3.5:
+ resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
+ engines: {node: '>=4.0'}
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ language-subtag-registry@0.3.23:
+ resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
+
+ language-tags@1.0.9:
+ resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
+ engines: {node: '>=0.10'}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ lilconfig@3.1.3:
+ resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
+ engines: {node: '>=14'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ lucide-react@0.566.0:
+ resolution: {integrity: sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ merge2@1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ minimatch@10.2.5:
+ resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
+ engines: {node: 18 || 20 || >=22}
+
+ minimatch@3.1.5:
+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
+ motion-dom@12.40.0:
+ resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==}
+
+ motion-utils@12.39.0:
+ resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ mz@2.7.0:
+ resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
+ nanoid@3.3.12:
+ resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ node-exports-info@1.6.0:
+ resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
+ engines: {node: '>= 0.4'}
+
+ node-releases@2.0.46:
+ resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
+ engines: {node: '>=18'}
+
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ object-keys@1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+
+ object.assign@4.1.7:
+ resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
+ engines: {node: '>= 0.4'}
+
+ object.entries@1.1.9:
+ resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
+ engines: {node: '>= 0.4'}
+
+ object.fromentries@2.0.8:
+ resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
+ engines: {node: '>= 0.4'}
+
+ object.values@1.2.1:
+ resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
+ engines: {node: '>= 0.4'}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ own-keys@1.0.1:
+ resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
+ engines: {node: '>= 0.4'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
+ pify@2.3.0:
+ resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
+ engines: {node: '>=0.10.0'}
+
+ pirates@4.0.7:
+ resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
+ engines: {node: '>= 6'}
+
+ possible-typed-array-names@1.1.0:
+ resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+ engines: {node: '>= 0.4'}
+
+ postcss-import@15.1.0:
+ resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ postcss: ^8.0.0
+
+ postcss-js@4.1.0:
+ resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
+ engines: {node: ^12 || ^14 || >= 16}
+ peerDependencies:
+ postcss: ^8.4.21
+
+ postcss-load-config@6.0.1:
+ resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ jiti: '>=1.21.0'
+ postcss: '>=8.0.9'
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+ postcss:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ postcss-nested@6.2.0:
+ resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+
+ postcss-selector-parser@6.1.2:
+ resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
+ engines: {node: '>=4'}
+
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
+ postcss@8.5.15:
+ resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ prettier-plugin-tailwindcss@0.8.0:
+ resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
+ engines: {node: '>=20.19'}
+ peerDependencies:
+ '@ianvs/prettier-plugin-sort-imports': '*'
+ '@prettier/plugin-hermes': '*'
+ '@prettier/plugin-oxc': '*'
+ '@prettier/plugin-pug': '*'
+ '@shopify/prettier-plugin-liquid': '*'
+ '@trivago/prettier-plugin-sort-imports': '*'
+ '@zackad/prettier-plugin-twig': '*'
+ prettier: ^3.0
+ prettier-plugin-astro: '*'
+ prettier-plugin-css-order: '*'
+ prettier-plugin-jsdoc: '*'
+ prettier-plugin-marko: '*'
+ prettier-plugin-multiline-arrays: '*'
+ prettier-plugin-organize-attributes: '*'
+ prettier-plugin-organize-imports: '*'
+ prettier-plugin-sort-imports: '*'
+ prettier-plugin-svelte: '*'
+ peerDependenciesMeta:
+ '@ianvs/prettier-plugin-sort-imports':
+ optional: true
+ '@prettier/plugin-hermes':
+ optional: true
+ '@prettier/plugin-oxc':
+ optional: true
+ '@prettier/plugin-pug':
+ optional: true
+ '@shopify/prettier-plugin-liquid':
+ optional: true
+ '@trivago/prettier-plugin-sort-imports':
+ optional: true
+ '@zackad/prettier-plugin-twig':
+ optional: true
+ prettier-plugin-astro:
+ optional: true
+ prettier-plugin-css-order:
+ optional: true
+ prettier-plugin-jsdoc:
+ optional: true
+ prettier-plugin-marko:
+ optional: true
+ prettier-plugin-multiline-arrays:
+ optional: true
+ prettier-plugin-organize-attributes:
+ optional: true
+ prettier-plugin-organize-imports:
+ optional: true
+ prettier-plugin-sort-imports:
+ optional: true
+ prettier-plugin-svelte:
+ optional: true
+
+ prettier@3.8.3:
+ resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
+ engines: {node: '>=14'}
+ hasBin: true
+
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ queue-microtask@1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+ react-dom@18.3.1:
+ resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
+ peerDependencies:
+ react: ^18.3.1
+
+ react-i18next@17.0.8:
+ resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
+ peerDependencies:
+ i18next: '>= 26.2.0'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
+ react-loading-skeleton@3.5.0:
+ resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ react-refresh@0.17.0:
+ resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+ engines: {node: '>=0.10.0'}
+
+ react-remove-scroll-bar@2.3.8:
+ resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-remove-scroll@2.7.2:
+ resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-router-dom@7.15.1:
+ resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ react-router@7.15.1:
+ resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react-style-singleton@2.2.3:
+ resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-virtuoso@4.18.7:
+ resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
+ peerDependencies:
+ react: '>=16 || >=17 || >= 18 || >= 19'
+ react-dom: '>=16 || >=17 || >= 18 || >=19'
+
+ react@18.3.1:
+ resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
+ engines: {node: '>=0.10.0'}
+
+ read-cache@1.0.0:
+ resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
+
+ readdirp@3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+
+ reflect.getprototypeof@1.0.10:
+ resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
+ engines: {node: '>= 0.4'}
+
+ regexp.prototype.flags@1.5.4:
+ resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
+ engines: {node: '>= 0.4'}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ resolve@1.22.12:
+ resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ resolve@2.0.0-next.7:
+ resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ reusify@1.1.0:
+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+ rollup@4.60.4:
+ resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ run-parallel@1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+ safe-array-concat@1.1.4:
+ resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
+ engines: {node: '>=0.4'}
+
+ safe-push-apply@1.0.0:
+ resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
+ engines: {node: '>= 0.4'}
+
+ safe-regex-test@1.1.0:
+ resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+ engines: {node: '>= 0.4'}
+
+ scheduler@0.23.2:
+ resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.8.4:
+ resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ set-function-length@1.2.2:
+ resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+ engines: {node: '>= 0.4'}
+
+ set-function-name@2.0.2:
+ resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
+ engines: {node: '>= 0.4'}
+
+ set-proto@1.0.0:
+ resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
+ engines: {node: '>= 0.4'}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ stop-iteration-iterator@1.1.0:
+ resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.includes@2.0.1:
+ resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.matchall@4.0.12:
+ resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.repeat@1.0.0:
+ resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
+
+ string.prototype.trim@1.2.11:
+ resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimend@1.0.10:
+ resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimstart@1.0.8:
+ resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
+ engines: {node: '>= 0.4'}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ sucrase@3.35.1:
+ resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ hasBin: true
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ tailwind-merge@2.6.1:
+ resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
+
+ tailwindcss-animate@1.0.7:
+ resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
+ peerDependencies:
+ tailwindcss: '>=3.0.0 || insiders'
+
+ tailwindcss@3.4.19:
+ resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
+ thenify-all@1.6.0:
+ resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+ engines: {node: '>=0.8'}
+
+ thenify@3.3.1:
+ resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
+ engines: {node: '>=12.0.0'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ ts-interface-checker@0.1.13:
+ resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ typed-array-buffer@1.0.3:
+ resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-length@1.0.3:
+ resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-offset@1.0.4:
+ resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-length@1.0.8:
+ resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
+ engines: {node: '>= 0.4'}
+
+ typescript-eslint@8.61.1:
+ resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ unbox-primitive@1.1.0:
+ resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
+ engines: {node: '>= 0.4'}
+
+ undici-types@7.24.6:
+ resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ use-callback-ref@1.3.3:
+ resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-sidecar@1.1.3:
+ resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ vite@6.4.2:
+ resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ void-elements@3.1.0:
+ resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+ engines: {node: '>=0.10.0'}
+
+ which-boxed-primitive@1.1.1:
+ resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
+ engines: {node: '>= 0.4'}
+
+ which-builtin-type@1.2.1:
+ resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
+ engines: {node: '>= 0.4'}
+
+ which-collection@1.0.2:
+ resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
+ engines: {node: '>= 0.4'}
+
+ which-typed-array@1.1.22:
+ resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
+ engines: {node: '>= 0.4'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ zod-validation-error@4.0.2:
+ resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
+snapshots:
+
+ '@alloc/quick-lru@5.2.0': {}
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-compilation-targets': 7.29.7
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helpers': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.7':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.28.2
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.29.7': {}
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.7':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/traverse@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
+ dependencies:
+ eslint: 9.39.4(jiti@1.21.7)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.21.2':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3
+ minimatch: 3.1.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.5':
+ dependencies:
+ ajv: 6.15.0
+ debug: 4.4.3
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.2.0
+ minimatch: 3.1.5
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@10.0.1(eslint@9.39.4(jiti@1.21.7))':
+ optionalDependencies:
+ eslint: 9.39.4(jiti@1.21.7)
+
+ '@eslint/js@9.39.4': {}
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@floating-ui/core@1.7.5':
+ dependencies:
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/dom@1.7.6':
+ dependencies:
+ '@floating-ui/core': 1.7.5
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ '@floating-ui/utils@0.2.11': {}
+
+ '@humanfs/core@0.19.2':
+ dependencies:
+ '@humanfs/types': 0.15.0
+
+ '@humanfs/node@0.16.8':
+ dependencies:
+ '@humanfs/core': 0.19.2
+ '@humanfs/types': 0.15.0
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanfs/types@0.15.0': {}
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@nodelib/fs.scandir@2.1.5':
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+
+ '@nodelib/fs.stat@2.0.5': {}
+
+ '@nodelib/fs.walk@1.2.8':
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.20.1
+
+ '@radix-ui/number@1.1.1': {}
+
+ '@radix-ui/primitive@1.1.3': {}
+
+ '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-context@1.1.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ aria-hidden: 1.2.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-direction@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-id@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ aria-hidden: 1.2.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ aria-hidden: 1.2.6
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/rect': 1.1.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.4(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-slot@1.2.3(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-slot@1.2.4(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/rect': 1.1.1
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-use-size@1.1.1(@types/react@18.3.29)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+ '@types/react-dom': 18.3.7(@types/react@18.3.29)
+
+ '@radix-ui/rect@1.1.1': {}
+
+ '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+ '@rollup/rollup-android-arm-eabi@4.60.4':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.60.4':
+ optional: true
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/estree@1.0.8': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/node@25.9.1':
+ dependencies:
+ undici-types: 7.24.6
+
+ '@types/prop-types@15.7.15': {}
+
+ '@types/react-dom@18.3.7(@types/react@18.3.29)':
+ dependencies:
+ '@types/react': 18.3.29
+
+ '@types/react@18.3.29':
+ dependencies:
+ '@types/prop-types': 15.7.15
+ csstype: 3.2.3
+
+ '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.61.1
+ '@typescript-eslint/type-utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.61.1
+ eslint: 9.39.4(jiti@1.21.7)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.61.1
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.61.1
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.61.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.61.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.61.1':
+ dependencies:
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/visitor-keys': 8.61.1
+
+ '@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@1.21.7)
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.61.1': {}
+
+ '@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/visitor-keys': 8.61.1
+ debug: 4.4.3
+ minimatch: 10.2.5
+ semver: 7.8.4
+ tinyglobby: 0.2.16
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
+ '@typescript-eslint/scope-manager': 8.61.1
+ '@typescript-eslint/types': 8.61.1
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
+ eslint: 9.39.4(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.61.1':
+ dependencies:
+ '@typescript-eslint/types': 8.61.1
+ eslint-visitor-keys: 5.0.1
+
+ '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7))':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
+ '@rolldown/pluginutils': 1.0.0-beta.27
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.17.0
+ vite: 6.4.2(@types/node@25.9.1)(jiti@1.21.7)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@wailsio/runtime@3.0.0-alpha.79': {}
+
+ acorn-jsx@5.3.2(acorn@8.17.0):
+ dependencies:
+ acorn: 8.17.0
+
+ acorn@8.17.0: {}
+
+ ajv@6.15.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ any-promise@1.3.0: {}
+
+ anymatch@3.1.3:
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.2
+
+ arg@5.0.2: {}
+
+ argparse@2.0.1: {}
+
+ aria-hidden@1.2.6:
+ dependencies:
+ tslib: 2.8.1
+
+ aria-query@5.3.2: {}
+
+ array-buffer-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ is-array-buffer: 3.0.5
+
+ array-includes@3.1.9:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ is-string: 1.1.1
+ math-intrinsics: 1.1.0
+
+ array.prototype.findlast@1.2.5:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flat@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flatmap@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.tosorted@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-shim-unscopables: 1.1.0
+
+ arraybuffer.prototype.slice@1.0.4:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ is-array-buffer: 3.0.5
+
+ ast-types-flow@0.0.8: {}
+
+ async-function@1.0.0: {}
+
+ autoprefixer@10.5.0(postcss@8.5.15):
+ dependencies:
+ browserslist: 4.28.2
+ caniuse-lite: 1.0.30001793
+ fraction.js: 5.3.4
+ picocolors: 1.1.1
+ postcss: 8.5.15
+ postcss-value-parser: 4.2.0
+
+ available-typed-arrays@1.0.7:
+ dependencies:
+ possible-typed-array-names: 1.1.0
+
+ axe-core@4.12.1: {}
+
+ axobject-query@4.1.0: {}
+
+ balanced-match@1.0.2: {}
+
+ balanced-match@4.0.4: {}
+
+ baseline-browser-mapping@2.10.32: {}
+
+ binary-extensions@2.3.0: {}
+
+ brace-expansion@1.1.15:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@5.0.6:
+ dependencies:
+ balanced-match: 4.0.4
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ browserslist@4.28.2:
+ dependencies:
+ baseline-browser-mapping: 2.10.32
+ caniuse-lite: 1.0.30001793
+ electron-to-chromium: 1.5.362
+ node-releases: 2.0.46
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bind@1.0.9:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ get-intrinsic: 1.3.0
+ set-function-length: 1.2.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ callsites@3.1.0: {}
+
+ camelcase-css@2.0.1: {}
+
+ caniuse-lite@1.0.30001793: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ chokidar@3.6.0:
+ dependencies:
+ anymatch: 3.1.3
+ braces: 3.0.3
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ class-variance-authority@0.7.1:
+ dependencies:
+ clsx: 2.1.1
+
+ clsx@2.1.1: {}
+
+ cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ commander@4.1.1: {}
+
+ concat-map@0.0.1: {}
+
+ convert-source-map@2.0.0: {}
+
+ cookie@1.1.1: {}
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ cssesc@3.0.0: {}
+
+ csstype@3.2.3: {}
+
+ damerau-levenshtein@1.0.8: {}
+
+ data-view-buffer@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-offset@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deep-is@0.1.4: {}
+
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ define-properties@1.2.1:
+ dependencies:
+ define-data-property: 1.1.4
+ has-property-descriptors: 1.0.2
+ object-keys: 1.1.1
+
+ detect-node-es@1.1.0: {}
+
+ didyoumean@1.2.2: {}
+
+ dlv@1.1.3: {}
+
+ doctrine@2.1.0:
+ dependencies:
+ esutils: 2.0.3
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ electron-to-chromium@1.5.362: {}
+
+ emoji-regex@9.2.2: {}
+
+ es-abstract-get@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ is-callable: 1.2.7
+ object-inspect: 1.13.4
+
+ es-abstract@1.24.2:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ arraybuffer.prototype.slice: 1.0.4
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ data-view-buffer: 1.0.2
+ data-view-byte-length: 1.0.2
+ data-view-byte-offset: 1.0.1
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-set-tostringtag: 2.1.0
+ es-to-primitive: 1.3.1
+ function.prototype.name: 1.2.0
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ get-symbol-description: 1.1.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.3
+ internal-slot: 1.1.0
+ is-array-buffer: 3.0.5
+ is-callable: 1.2.7
+ is-data-view: 1.0.2
+ is-negative-zero: 2.0.3
+ is-regex: 1.2.1
+ is-set: 2.0.3
+ is-shared-array-buffer: 1.0.4
+ is-string: 1.1.1
+ is-typed-array: 1.1.15
+ is-weakref: 1.1.1
+ math-intrinsics: 1.1.0
+ object-inspect: 1.13.4
+ object-keys: 1.1.1
+ object.assign: 4.1.7
+ own-keys: 1.0.1
+ regexp.prototype.flags: 1.5.4
+ safe-array-concat: 1.1.4
+ safe-push-apply: 1.0.0
+ safe-regex-test: 1.1.0
+ set-proto: 1.0.0
+ stop-iteration-iterator: 1.1.0
+ string.prototype.trim: 1.2.11
+ string.prototype.trimend: 1.0.10
+ string.prototype.trimstart: 1.0.8
+ typed-array-buffer: 1.0.3
+ typed-array-byte-length: 1.0.3
+ typed-array-byte-offset: 1.0.4
+ typed-array-length: 1.0.8
+ unbox-primitive: 1.1.0
+ which-typed-array: 1.1.22
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-iterator-helpers@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-set-tostringtag: 2.1.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ internal-slot: 1.1.0
+ iterator.prototype: 1.1.5
+ math-intrinsics: 1.1.0
+
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.3
+
+ es-shim-unscopables@1.1.0:
+ dependencies:
+ hasown: 2.0.3
+
+ es-to-primitive@1.3.1:
+ dependencies:
+ es-abstract-get: 1.0.0
+ es-errors: 1.3.0
+ is-callable: 1.2.7
+ is-date-object: 1.1.0
+ is-symbol: 1.1.1
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
+ escalade@3.2.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)):
+ dependencies:
+ aria-query: 5.3.2
+ array-includes: 3.1.9
+ array.prototype.flatmap: 1.3.3
+ ast-types-flow: 0.0.8
+ axe-core: 4.12.1
+ axobject-query: 4.1.0
+ damerau-levenshtein: 1.0.8
+ emoji-regex: 9.2.2
+ eslint: 9.39.4(jiti@1.21.7)
+ hasown: 2.0.3
+ jsx-ast-utils: 3.3.5
+ language-tags: 1.0.9
+ minimatch: 3.1.5
+ object.fromentries: 2.0.8
+ safe-regex-test: 1.1.0
+ string.prototype.includes: 2.0.1
+
+ eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@1.21.7)):
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/parser': 7.29.7
+ eslint: 9.39.4(jiti@1.21.7)
+ hermes-parser: 0.25.1
+ zod: 4.4.3
+ zod-validation-error: 4.0.2(zod@4.4.3)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-react-refresh@0.5.3(eslint@9.39.4(jiti@1.21.7)):
+ dependencies:
+ eslint: 9.39.4(jiti@1.21.7)
+
+ eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)):
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.findlast: 1.2.5
+ array.prototype.flatmap: 1.3.3
+ array.prototype.tosorted: 1.1.4
+ doctrine: 2.1.0
+ es-iterator-helpers: 1.3.3
+ eslint: 9.39.4(jiti@1.21.7)
+ estraverse: 5.3.0
+ hasown: 2.0.3
+ jsx-ast-utils: 3.3.5
+ minimatch: 3.1.5
+ object.entries: 1.1.9
+ object.fromentries: 2.0.8
+ object.values: 1.2.1
+ prop-types: 15.8.1
+ resolve: 2.0.0-next.7
+ semver: 6.3.1
+ string.prototype.matchall: 4.0.12
+ string.prototype.repeat: 1.0.0
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint-visitor-keys@5.0.1: {}
+
+ eslint@9.39.4(jiti@1.21.7):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.2
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.5
+ '@eslint/js': 9.39.4
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.8
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.15.0
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.5
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 1.21.7
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.17.0
+ acorn-jsx: 5.3.2(acorn@8.17.0)
+ eslint-visitor-keys: 4.2.1
+
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ esutils@2.0.3: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-glob@3.3.3:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fastq@1.20.1:
+ dependencies:
+ reusify: 1.1.0
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.4.2
+ keyv: 4.5.4
+
+ flatted@3.4.2: {}
+
+ for-each@0.3.5:
+ dependencies:
+ is-callable: 1.2.7
+
+ fraction.js@5.3.4: {}
+
+ framer-motion@12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ motion-dom: 12.40.0
+ motion-utils: 12.39.0
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ function.prototype.name@1.2.0:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+ hasown: 2.0.4
+ is-callable: 1.2.7
+ is-document.all: 1.0.0
+
+ functions-have-names@1.2.3: {}
+
+ generator-function@2.0.1: {}
+
+ gensync@1.0.0-beta.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.3
+ math-intrinsics: 1.1.0
+
+ get-nonce@1.0.1: {}
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
+ get-symbol-description@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+
+ glob-parent@5.1.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@14.0.0: {}
+
+ globals@17.6.0: {}
+
+ globalthis@1.0.4:
+ dependencies:
+ define-properties: 1.2.1
+ gopd: 1.2.0
+
+ gopd@1.2.0: {}
+
+ has-bigints@1.1.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
+
+ has-proto@1.2.0:
+ dependencies:
+ dunder-proto: 1.0.1
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.3:
+ dependencies:
+ function-bind: 1.1.2
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
+ html-parse-stringify@3.0.1:
+ dependencies:
+ void-elements: 3.1.0
+
+ i18next@26.3.0(typescript@5.9.3):
+ optionalDependencies:
+ typescript: 5.9.3
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ internal-slot@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ hasown: 2.0.3
+ side-channel: 1.1.1
+
+ is-array-buffer@3.0.5:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ is-async-function@2.1.1:
+ dependencies:
+ async-function: 1.0.0
+ call-bound: 1.0.4
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-bigint@1.1.0:
+ dependencies:
+ has-bigints: 1.1.0
+
+ is-binary-path@2.1.0:
+ dependencies:
+ binary-extensions: 2.3.0
+
+ is-boolean-object@1.2.2:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-callable@1.2.7: {}
+
+ is-core-module@2.16.2:
+ dependencies:
+ hasown: 2.0.3
+
+ is-data-view@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ is-typed-array: 1.1.15
+
+ is-date-object@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-document.all@1.0.0:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-extglob@2.1.1: {}
+
+ is-finalizationregistry@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-generator-function@1.1.2:
+ dependencies:
+ call-bound: 1.0.4
+ generator-function: 2.0.1
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-map@2.0.3: {}
+
+ is-negative-zero@2.0.3: {}
+
+ is-number-object@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-number@7.0.0: {}
+
+ is-regex@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.3
+
+ is-set@2.0.3: {}
+
+ is-shared-array-buffer@1.0.4:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-string@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-symbol@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-symbols: 1.1.0
+ safe-regex-test: 1.1.0
+
+ is-typed-array@1.1.15:
+ dependencies:
+ which-typed-array: 1.1.22
+
+ is-weakmap@2.0.2: {}
+
+ is-weakref@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-weakset@2.0.4:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ isarray@2.0.5: {}
+
+ isexe@2.0.0: {}
+
+ iterator.prototype@1.1.5:
+ dependencies:
+ define-data-property: 1.1.4
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ has-symbols: 1.1.0
+ set-function-name: 2.0.2
+
+ jiti@1.21.7: {}
+
+ js-tokens@4.0.0: {}
+
+ js-yaml@4.2.0:
+ dependencies:
+ argparse: 2.0.1
+
+ jsesc@3.1.0: {}
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@2.2.3: {}
+
+ jsx-ast-utils@3.3.5:
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.flat: 1.3.3
+ object.assign: 4.1.7
+ object.values: 1.2.1
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ language-subtag-registry@0.3.23: {}
+
+ language-tags@1.0.9:
+ dependencies:
+ language-subtag-registry: 0.3.23
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ lilconfig@3.1.3: {}
+
+ lines-and-columns@1.2.4: {}
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ lucide-react@0.566.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
+ math-intrinsics@1.1.0: {}
+
+ merge2@1.4.1: {}
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.2
+
+ minimatch@10.2.5:
+ dependencies:
+ brace-expansion: 5.0.6
+
+ minimatch@3.1.5:
+ dependencies:
+ brace-expansion: 1.1.15
+
+ motion-dom@12.40.0:
+ dependencies:
+ motion-utils: 12.39.0
+
+ motion-utils@12.39.0: {}
+
+ ms@2.1.3: {}
+
+ mz@2.7.0:
+ dependencies:
+ any-promise: 1.3.0
+ object-assign: 4.1.1
+ thenify-all: 1.6.0
+
+ nanoid@3.3.12: {}
+
+ natural-compare@1.4.0: {}
+
+ node-exports-info@1.6.0:
+ dependencies:
+ array.prototype.flatmap: 1.3.3
+ es-errors: 1.3.0
+ object.entries: 1.1.9
+ semver: 6.3.1
+
+ node-releases@2.0.46: {}
+
+ normalize-path@3.0.0: {}
+
+ object-assign@4.1.1: {}
+
+ object-hash@3.0.0: {}
+
+ object-inspect@1.13.4: {}
+
+ object-keys@1.1.1: {}
+
+ object.assign@4.1.7:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+ has-symbols: 1.1.0
+ object-keys: 1.1.1
+
+ object.entries@1.1.9:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ object.fromentries@2.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+
+ object.values@1.2.1:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ own-keys@1.0.1:
+ dependencies:
+ get-intrinsic: 1.3.0
+ object-keys: 1.1.1
+ safe-push-apply: 1.0.0
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ path-parse@1.0.7: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.2: {}
+
+ picomatch@4.0.4: {}
+
+ pify@2.3.0: {}
+
+ pirates@4.0.7: {}
+
+ possible-typed-array-names@1.1.0: {}
+
+ postcss-import@15.1.0(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-value-parser: 4.2.0
+ read-cache: 1.0.0
+ resolve: 1.22.12
+
+ postcss-js@4.1.0(postcss@8.5.15):
+ dependencies:
+ camelcase-css: 2.0.1
+ postcss: 8.5.15
+
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15):
+ dependencies:
+ lilconfig: 3.1.3
+ optionalDependencies:
+ jiti: 1.21.7
+ postcss: 8.5.15
+
+ postcss-nested@6.2.0(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-selector-parser: 6.1.2
+
+ postcss-selector-parser@6.1.2:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss-value-parser@4.2.0: {}
+
+ postcss@8.5.15:
+ dependencies:
+ nanoid: 3.3.12
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prelude-ls@1.2.1: {}
+
+ prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3):
+ dependencies:
+ prettier: 3.8.3
+
+ prettier@3.8.3: {}
+
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
+ punycode@2.3.1: {}
+
+ queue-microtask@1.2.3: {}
+
+ react-dom@18.3.1(react@18.3.1):
+ dependencies:
+ loose-envify: 1.4.0
+ react: 18.3.1
+ scheduler: 0.23.2
+
+ react-i18next@17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.7
+ html-parse-stringify: 3.0.1
+ i18next: 26.3.0(typescript@5.9.3)
+ react: 18.3.1
+ use-sync-external-store: 1.6.0(react@18.3.1)
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
+ typescript: 5.9.3
+
+ react-is@16.13.1: {}
+
+ react-loading-skeleton@3.5.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
+ react-refresh@0.17.0: {}
+
+ react-remove-scroll-bar@2.3.8(@types/react@18.3.29)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1)
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ react-remove-scroll@2.7.2(@types/react@18.3.29)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-remove-scroll-bar: 2.3.8(@types/react@18.3.29)(react@18.3.1)
+ react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1)
+ tslib: 2.8.1
+ use-callback-ref: 1.3.3(@types/react@18.3.29)(react@18.3.1)
+ use-sidecar: 1.1.3(@types/react@18.3.29)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ react-router-dom@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-router: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+
+ react-router@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ cookie: 1.1.1
+ react: 18.3.1
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
+
+ react-style-singleton@2.2.3(@types/react@18.3.29)(react@18.3.1):
+ dependencies:
+ get-nonce: 1.0.1
+ react: 18.3.1
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
+ react@18.3.1:
+ dependencies:
+ loose-envify: 1.4.0
+
+ read-cache@1.0.0:
+ dependencies:
+ pify: 2.3.0
+
+ readdirp@3.6.0:
+ dependencies:
+ picomatch: 2.3.2
+
+ reflect.getprototypeof@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ which-builtin-type: 1.2.1
+
+ regexp.prototype.flags@1.5.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-errors: 1.3.0
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ set-function-name: 2.0.2
+
+ resolve-from@4.0.0: {}
+
+ resolve@1.22.12:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ resolve@2.0.0-next.7:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ node-exports-info: 1.6.0
+ object-keys: 1.1.1
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ reusify@1.1.0: {}
+
+ rollup@4.60.4:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.60.4
+ '@rollup/rollup-android-arm64': 4.60.4
+ '@rollup/rollup-darwin-arm64': 4.60.4
+ '@rollup/rollup-darwin-x64': 4.60.4
+ '@rollup/rollup-freebsd-arm64': 4.60.4
+ '@rollup/rollup-freebsd-x64': 4.60.4
+ '@rollup/rollup-linux-arm-gnueabihf': 4.60.4
+ '@rollup/rollup-linux-arm-musleabihf': 4.60.4
+ '@rollup/rollup-linux-arm64-gnu': 4.60.4
+ '@rollup/rollup-linux-arm64-musl': 4.60.4
+ '@rollup/rollup-linux-loong64-gnu': 4.60.4
+ '@rollup/rollup-linux-loong64-musl': 4.60.4
+ '@rollup/rollup-linux-ppc64-gnu': 4.60.4
+ '@rollup/rollup-linux-ppc64-musl': 4.60.4
+ '@rollup/rollup-linux-riscv64-gnu': 4.60.4
+ '@rollup/rollup-linux-riscv64-musl': 4.60.4
+ '@rollup/rollup-linux-s390x-gnu': 4.60.4
+ '@rollup/rollup-linux-x64-gnu': 4.60.4
+ '@rollup/rollup-linux-x64-musl': 4.60.4
+ '@rollup/rollup-openbsd-x64': 4.60.4
+ '@rollup/rollup-openharmony-arm64': 4.60.4
+ '@rollup/rollup-win32-arm64-msvc': 4.60.4
+ '@rollup/rollup-win32-ia32-msvc': 4.60.4
+ '@rollup/rollup-win32-x64-gnu': 4.60.4
+ '@rollup/rollup-win32-x64-msvc': 4.60.4
+ fsevents: 2.3.3
+
+ run-parallel@1.2.0:
+ dependencies:
+ queue-microtask: 1.2.3
+
+ safe-array-concat@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ has-symbols: 1.1.0
+ isarray: 2.0.5
+
+ safe-push-apply@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ isarray: 2.0.5
+
+ safe-regex-test@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-regex: 1.2.1
+
+ scheduler@0.23.2:
+ dependencies:
+ loose-envify: 1.4.0
+
+ semver@6.3.1: {}
+
+ semver@7.8.4: {}
+
+ set-cookie-parser@2.7.2: {}
+
+ set-function-length@1.2.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+
+ set-function-name@2.0.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+
+ set-proto@1.0.0:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ side-channel-list@1.0.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
+ source-map-js@1.2.1: {}
+
+ stop-iteration-iterator@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ internal-slot: 1.1.0
+
+ string.prototype.includes@2.0.1:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ string.prototype.matchall@4.0.12:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ internal-slot: 1.1.0
+ regexp.prototype.flags: 1.5.4
+ set-function-name: 2.0.2
+ side-channel: 1.1.1
+
+ string.prototype.repeat@1.0.0:
+ dependencies:
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ string.prototype.trim@1.2.11:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-data-property: 1.1.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ has-property-descriptors: 1.0.2
+ safe-regex-test: 1.1.0
+
+ string.prototype.trimend@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ string.prototype.trimstart@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ strip-json-comments@3.1.1: {}
+
+ sucrase@3.35.1:
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ commander: 4.1.1
+ lines-and-columns: 1.2.4
+ mz: 2.7.0
+ pirates: 4.0.7
+ tinyglobby: 0.2.16
+ ts-interface-checker: 0.1.13
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ tailwind-merge@2.6.1: {}
+
+ tailwindcss-animate@1.0.7(tailwindcss@3.4.19):
+ dependencies:
+ tailwindcss: 3.4.19
+
+ tailwindcss@3.4.19:
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ arg: 5.0.2
+ chokidar: 3.6.0
+ didyoumean: 1.2.2
+ dlv: 1.1.3
+ fast-glob: 3.3.3
+ glob-parent: 6.0.2
+ is-glob: 4.0.3
+ jiti: 1.21.7
+ lilconfig: 3.1.3
+ micromatch: 4.0.8
+ normalize-path: 3.0.0
+ object-hash: 3.0.0
+ picocolors: 1.1.1
+ postcss: 8.5.15
+ postcss-import: 15.1.0(postcss@8.5.15)
+ postcss-js: 4.1.0(postcss@8.5.15)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)
+ postcss-nested: 6.2.0(postcss@8.5.15)
+ postcss-selector-parser: 6.1.2
+ resolve: 1.22.12
+ sucrase: 3.35.1
+ transitivePeerDependencies:
+ - tsx
+ - yaml
+
+ thenify-all@1.6.0:
+ dependencies:
+ thenify: 3.3.1
+
+ thenify@3.3.1:
+ dependencies:
+ any-promise: 1.3.0
+
+ tinyglobby@0.2.16:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
+ ts-api-utils@2.5.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ ts-interface-checker@0.1.13: {}
+
+ tslib@2.8.1: {}
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ typed-array-buffer@1.0.3:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-length@1.0.3:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-offset@1.0.4:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+ reflect.getprototypeof: 1.0.10
+
+ typed-array-length@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ is-typed-array: 1.1.15
+ possible-typed-array-names: 1.1.0
+ reflect.getprototypeof: 1.0.10
+
+ typescript-eslint@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ typescript@5.9.3: {}
+
+ unbox-primitive@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-bigints: 1.1.0
+ has-symbols: 1.1.0
+ which-boxed-primitive: 1.1.1
+
+ undici-types@7.24.6: {}
+
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
+ dependencies:
+ browserslist: 4.28.2
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ use-callback-ref@1.3.3(@types/react@18.3.29)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ use-sidecar@1.1.3(@types/react@18.3.29)(react@18.3.1):
+ dependencies:
+ detect-node-es: 1.1.0
+ react: 18.3.1
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 18.3.29
+
+ use-sync-external-store@1.6.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
+ util-deprecate@1.0.2: {}
+
+ vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.15
+ rollup: 4.60.4
+ tinyglobby: 0.2.16
+ optionalDependencies:
+ '@types/node': 25.9.1
+ fsevents: 2.3.3
+ jiti: 1.21.7
+
+ void-elements@3.1.0: {}
+
+ which-boxed-primitive@1.1.1:
+ dependencies:
+ is-bigint: 1.1.0
+ is-boolean-object: 1.2.2
+ is-number-object: 1.1.1
+ is-string: 1.1.1
+ is-symbol: 1.1.1
+
+ which-builtin-type@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ function.prototype.name: 1.2.0
+ has-tostringtag: 1.0.2
+ is-async-function: 2.1.1
+ is-date-object: 1.1.0
+ is-finalizationregistry: 1.1.1
+ is-generator-function: 1.1.2
+ is-regex: 1.2.1
+ is-weakref: 1.1.1
+ isarray: 2.0.5
+ which-boxed-primitive: 1.1.1
+ which-collection: 1.0.2
+ which-typed-array: 1.1.22
+
+ which-collection@1.0.2:
+ dependencies:
+ is-map: 2.0.3
+ is-set: 2.0.3
+ is-weakmap: 2.0.2
+ is-weakset: 2.0.4
+
+ which-typed-array@1.1.22:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ for-each: 0.3.5
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ word-wrap@1.2.5: {}
+
+ yallist@3.1.1: {}
+
+ yocto-queue@0.1.0: {}
+
+ zod-validation-error@4.0.2(zod@4.4.3):
+ dependencies:
+ zod: 4.4.3
+
+ zod@4.4.3: {}
diff --git a/client/ui/frontend/pnpm-workspace.yaml b/client/ui/frontend/pnpm-workspace.yaml
new file mode 100644
index 000000000..5ed0b5af0
--- /dev/null
+++ b/client/ui/frontend/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+allowBuilds:
+ esbuild: true
diff --git a/client/ui/frontend/postcss.config.js b/client/ui/frontend/postcss.config.js
new file mode 100644
index 000000000..2aa7205d4
--- /dev/null
+++ b/client/ui/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx
new file mode 100644
index 000000000..7f1359510
--- /dev/null
+++ b/client/ui/frontend/src/app.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import "./globals.css";
+import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
+import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx";
+import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
+import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx";
+import ErrorDialog from "@/modules/error/ErrorDialog.tsx";
+import { AppLayout } from "@/layouts/AppLayout.tsx";
+import { MainPage } from "@/modules/main/MainPage.tsx";
+import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
+import { SkeletonTheme } from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { welcome } from "@/lib/welcome";
+import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx";
+import { initI18n } from "@/lib/i18n";
+import { initPlatform } from "@/lib/platform";
+import { initLogForwarding } from "@/lib/logs";
+import { initStallWatch } from "@/lib/stallwatch";
+
+// Must run first so even init-time logs reach the Go log pipeline.
+initLogForwarding();
+
+initStallWatch();
+
+welcome();
+
+Promise.all([
+ initI18n().catch((e) => {
+ console.error("i18n init failed:", e);
+ }),
+ initPlatform().catch((e) => {
+ console.error("platform init failed:", e);
+ }),
+]).finally(() => {
+ ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+ }
+ />
+ } />
+ }
+ />
+ } />
+ } />
+
+ }>
+ } />
+ } />
+ } />
+
+
+
+
+ ,
+ );
+});
diff --git a/client/ui/frontend/src/assets/fonts/inter-variable.ttf b/client/ui/frontend/src/assets/fonts/inter-variable.ttf
new file mode 100644
index 000000000..4ab79e010
Binary files /dev/null and b/client/ui/frontend/src/assets/fonts/inter-variable.ttf differ
diff --git a/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf b/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf
new file mode 100644
index 000000000..b60e77f5d
Binary files /dev/null and b/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf differ
diff --git a/client/ui/frontend/src/assets/img/tray-darwin.png b/client/ui/frontend/src/assets/img/tray-darwin.png
new file mode 100644
index 000000000..75df803d8
Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-darwin.png differ
diff --git a/client/ui/frontend/src/assets/img/tray-linux.png b/client/ui/frontend/src/assets/img/tray-linux.png
new file mode 100644
index 000000000..08cea81af
Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-linux.png differ
diff --git a/client/ui/frontend/src/assets/img/tray-windows.png b/client/ui/frontend/src/assets/img/tray-windows.png
new file mode 100644
index 000000000..cebbf6153
Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-windows.png differ
diff --git a/client/ui/frontend/src/assets/logos/netbird-full.svg b/client/ui/frontend/src/assets/logos/netbird-full.svg
new file mode 100644
index 000000000..f925d5761
--- /dev/null
+++ b/client/ui/frontend/src/assets/logos/netbird-full.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/ui/frontend/src/assets/logos/netbird.svg b/client/ui/frontend/src/assets/logos/netbird.svg
new file mode 100644
index 000000000..6254931c6
--- /dev/null
+++ b/client/ui/frontend/src/assets/logos/netbird.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx
new file mode 100644
index 000000000..c5e2b5f22
--- /dev/null
+++ b/client/ui/frontend/src/components/Badge.tsx
@@ -0,0 +1,43 @@
+import { forwardRef, type ComponentType, type HTMLAttributes } from "react";
+import type { LucideProps } from "lucide-react";
+import { cn } from "@/lib/cn";
+
+export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger";
+
+type Props = HTMLAttributes & {
+ variant?: BadgeVariant;
+ icon?: ComponentType;
+ iconSize?: number;
+};
+
+const VARIANT_CLASSES: Record = {
+ info: "bg-sky-900 border border-sky-700 text-sky-200",
+ neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200",
+ brand: "bg-netbird/15 border border-netbird/30 text-netbird",
+ success: "bg-green-900 border border-green-700 text-green-200",
+ warning: "bg-yellow-900 border border-yellow-700 text-yellow-200",
+ danger: "bg-red-900 border border-red-700 text-red-200",
+};
+
+export const Badge = forwardRef(function Badge(
+ { variant = "info", icon: Icon, iconSize = 10, className, children, ...rest },
+ ref,
+) {
+ return (
+
+ {Icon && }
+ {children}
+
+ );
+});
+
+export default Badge;
diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx
new file mode 100644
index 000000000..3cf681a1c
--- /dev/null
+++ b/client/ui/frontend/src/components/CopyToClipboard.tsx
@@ -0,0 +1,131 @@
+import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { Check, Copy } from "lucide-react";
+import { cn } from "@/lib/cn";
+
+const VARIANT_HOVER = {
+ default: "group-hover/copy:[&_*]:text-nb-gray-300",
+ bright: "group-hover/copy:[&_*]:text-nb-gray-200",
+} as const;
+
+type CopyToClipboardVariant = keyof typeof VARIANT_HOVER;
+
+type CopyToClipboardProps = {
+ children: ReactNode;
+ message?: string;
+ size?: number;
+ iconAlignment?: "left" | "right";
+ className?: string;
+ iconClassName?: string;
+ alwaysShowIcon?: boolean;
+ // wrap lets long content (a shell command, a path) break across lines
+ // instead of being truncated to one line.
+ wrap?: boolean;
+ variant?: CopyToClipboardVariant;
+ "aria-label"?: string;
+ tabIndex?: number;
+ onKeyDown?: (e: KeyboardEvent) => void;
+};
+
+export const CopyToClipboard = ({
+ children,
+ message,
+ size = 10,
+ iconAlignment = "right",
+ className,
+ iconClassName,
+ alwaysShowIcon = false,
+ wrap = false,
+ variant = "default",
+ "aria-label": ariaLabel,
+ tabIndex = 0,
+ onKeyDown,
+}: CopyToClipboardProps) => {
+ const { t } = useTranslation();
+ const wrapperRef = useRef(null);
+ const [copied, setCopied] = useState(false);
+ const copyTimer = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (copyTimer.current) clearTimeout(copyTimer.current);
+ },
+ [],
+ );
+
+ const handleClick = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ const text = message ?? wrapperRef.current?.innerText ?? "";
+ if (!text) return;
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ if (copyTimer.current) clearTimeout(copyTimer.current);
+ copyTimer.current = setTimeout(() => setCopied(false), 500);
+ } catch (e) {
+ console.warn("copy to clipboard failed", e);
+ }
+ };
+
+ const resolvedLabel =
+ ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy"));
+
+ return (
+
+
+ {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..a0a131fbf
--- /dev/null
+++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx
@@ -0,0 +1,314 @@
+import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
+import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
+import type { DebugBundleResult } from "@bindings/services/models.js";
+import i18next from "@/lib/i18n";
+import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
+import { startConnection } from "@/lib/connection.ts";
+
+const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
+const TRACE_LOG_FILE_COUNT = 5;
+const PLAIN_LOG_FILE_COUNT = 1;
+const TRACE_LOG_LEVEL = "trace";
+const DEFAULT_LOG_LEVEL = "info";
+
+export type DebugStage =
+ | { kind: "idle" }
+ | { kind: "preparing-trace" }
+ | { kind: "reconnecting" }
+ | { kind: "capturing"; remainingSec: number; totalSec: number }
+ | { kind: "restoring-level" }
+ | { kind: "bundling" }
+ | { kind: "uploading" }
+ | { kind: "cancelling" }
+ | { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean };
+
+const sleep = (ms: number, signal: AbortSignal) =>
+ new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new DOMException("aborted", "AbortError"));
+ return;
+ }
+ const onAbort = () => {
+ clearTimeout(id);
+ reject(new DOMException("aborted", "AbortError"));
+ };
+ const id = setTimeout(() => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+ signal.addEventListener("abort", onAbort);
+ });
+
+const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
+
+const throwIfAborted = (signal: AbortSignal) => {
+ if (signal.aborted) throw new DOMException("aborted", "AbortError");
+};
+
+const setLogLevelBestEffort = async (level: string) => {
+ try {
+ await DebugSvc.SetLogLevel({ level });
+ } catch (e) {
+ console.warn("[DebugBundle] best-effort set log level failed", e);
+ }
+};
+
+const stopCaptureBestEffort = async () => {
+ try {
+ await DebugSvc.StopBundleCapture();
+ } catch (e) {
+ console.warn("[DebugBundle] best-effort stop packet capture failed", e);
+ }
+};
+
+type LevelState = { original: string; raised: boolean };
+type CaptureState = { started: boolean };
+
+type BundleOptions = {
+ trace: boolean;
+ capture: boolean;
+ capturePackets: boolean;
+ hasWindow: boolean;
+ totalSec: number;
+ uploadUrl: string;
+ anonymize: boolean;
+ systemInfo: boolean;
+};
+
+const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
+ try {
+ // Mirror the CLI's safety margin: window + 30s, server caps at 10m.
+ await DebugSvc.StartBundleCapture(totalSec + 30);
+ pcap.started = true;
+ } catch (e) {
+ console.warn("[DebugBundle] start packet capture failed", e);
+ }
+};
+
+const cleanupBestEffort = async (pcap: CaptureState, level: LevelState, restoreLevel: boolean) => {
+ if (pcap.started) {
+ await stopCaptureBestEffort();
+ pcap.started = false;
+ }
+ if (restoreLevel && level.raised) {
+ await setLogLevelBestEffort(level.original);
+ }
+};
+
+const raiseToTrace = async (
+ signal: AbortSignal,
+ level: LevelState,
+ setStage: (s: DebugStage) => void,
+) => {
+ setStage({ kind: "preparing-trace" });
+ try {
+ const cur = await DebugSvc.GetLogLevel();
+ if (cur?.level) level.original = cur.level;
+ } catch (e) {
+ console.warn("[DebugBundle] read current log level failed", e);
+ }
+ throwIfAborted(signal);
+ await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL });
+ level.raised = true;
+};
+
+const cycleConnection = async (signal: AbortSignal, setStage: (s: DebugStage) => void) => {
+ throwIfAborted(signal);
+ setStage({ kind: "reconnecting" });
+ try {
+ await ConnectionSvc.Down();
+ } catch (e) {
+ console.warn("[DebugBundle] disconnect before capture failed", e);
+ }
+ throwIfAborted(signal);
+ await startConnection(undefined, signal);
+};
+
+const restoreLogLevel = async (level: LevelState, setStage: (s: DebugStage) => void) => {
+ setStage({ kind: "restoring-level" });
+ try {
+ await DebugSvc.SetLogLevel({ level: level.original });
+ level.raised = false;
+ } catch (e) {
+ console.warn("[DebugBundle] restore log level failed", e);
+ }
+};
+
+const waitCaptureWindow = async (
+ signal: AbortSignal,
+ setStage: (s: DebugStage) => void,
+ totalSec: number,
+) => {
+ for (let remaining = totalSec; remaining > 0; remaining--) {
+ setStage({ kind: "capturing", remainingSec: remaining, totalSec });
+ await sleep(1000, signal);
+ }
+};
+
+const runBundleFlow = async (
+ signal: AbortSignal,
+ opts: BundleOptions,
+ level: LevelState,
+ pcap: CaptureState,
+ setStage: (s: DebugStage) => void,
+ setLastBundlePath: (p: string) => void,
+) => {
+ if (opts.trace) {
+ await raiseToTrace(signal, level, setStage);
+ }
+ throwIfAborted(signal);
+
+ if (opts.capture) {
+ await cycleConnection(signal, setStage);
+ }
+ throwIfAborted(signal);
+
+ if (opts.hasWindow && opts.capturePackets) {
+ await startCaptureBestEffort(opts.totalSec, pcap);
+ }
+ throwIfAborted(signal);
+
+ if (opts.hasWindow) {
+ await waitCaptureWindow(signal, setStage, opts.totalSec);
+ }
+
+ if (pcap.started) {
+ await stopCaptureBestEffort();
+ pcap.started = false;
+ }
+
+ if (level.raised) {
+ await restoreLogLevel(level, setStage);
+ }
+
+ throwIfAborted(signal);
+ setStage({ kind: "bundling" });
+ const logFileCount = opts.trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT;
+
+ if (opts.uploadUrl) setStage({ kind: "uploading" });
+ const result = await DebugSvc.Bundle({
+ anonymize: opts.anonymize,
+ systemInfo: opts.systemInfo,
+ uploadUrl: opts.uploadUrl,
+ logFileCount,
+ });
+ throwIfAborted(signal);
+ if (result.path) setLastBundlePath(result.path);
+ setStage({ kind: "done", result, uploadAttempted: Boolean(opts.uploadUrl) });
+};
+
+const useDebugBundle = () => {
+ const [anonymize, setAnonymize] = useState(false);
+ const [systemInfo, setSystemInfo] = useState(true);
+ const [upload, setUpload] = useState(true);
+ const [trace, setTrace] = useState(true);
+ const [capture, setCapture] = useState(false);
+ const [traceMinutes, setTraceMinutes] = useState(1);
+ const [capturePackets, setCapturePackets] = useState(true);
+ const [stage, setStage] = useState({ kind: "idle" });
+ const [lastBundlePath, setLastBundlePath] = useState("");
+ const abortRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ abortRef.current?.abort();
+ };
+ }, []);
+
+ const isRunning = stage.kind !== "idle" && stage.kind !== "done";
+
+ const reset = () => setStage({ kind: "idle" });
+
+ const cancel = () => {
+ if (!abortRef.current || abortRef.current.signal.aborted) return;
+ abortRef.current.abort();
+ setStage({ kind: "cancelling" });
+ };
+
+ const run = async () => {
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+ const signal = ctrl.signal;
+
+ const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
+ const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false };
+ const pcap: CaptureState = { started: false };
+ const opts: BundleOptions = {
+ trace,
+ capture,
+ capturePackets,
+ hasWindow: capture && totalSec > 0,
+ totalSec,
+ uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
+ anonymize,
+ systemInfo,
+ };
+
+ try {
+ await runBundleFlow(signal, opts, level, pcap, setStage, setLastBundlePath);
+ } catch (e) {
+ if (isAbort(e)) {
+ setStage({ kind: "cancelling" });
+ await cleanupBestEffort(pcap, level, true);
+ setStage({ kind: "idle" });
+ return;
+ }
+ await cleanupBestEffort(pcap, level, false);
+ setStage({ kind: "idle" });
+ await errorDialog({
+ Title: i18next.t("settings.error.debugBundleTitle"),
+ Message: formatErrorMessage(e),
+ });
+ } finally {
+ if (abortRef.current === ctrl) abortRef.current = null;
+ }
+ };
+
+ const openBundleDir = () => {
+ if (!lastBundlePath) return;
+ DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) =>
+ console.error("[DebugBundleContext] reveal failed", err),
+ );
+ };
+
+ return {
+ anonymize,
+ setAnonymize,
+ systemInfo,
+ setSystemInfo,
+ upload,
+ setUpload,
+ trace,
+ setTrace,
+ capture,
+ setCapture,
+ traceMinutes,
+ setTraceMinutes,
+ capturePackets,
+ setCapturePackets,
+ stage,
+ isRunning,
+ lastBundlePath,
+ run,
+ cancel,
+ reset,
+ openBundleDir,
+ };
+};
+
+export type DebugBundleContextValue = ReturnType;
+
+const DebugBundleContext = createContext(null);
+
+export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
+ const value = useDebugBundle();
+ return {children} ;
+};
+
+export const useDebugBundleContext = () => {
+ const ctx = useContext(DebugBundleContext);
+ if (!ctx) {
+ throw new Error("useDebugBundleContext must be used inside DebugBundleProvider");
+ }
+ return ctx;
+};
diff --git a/client/ui/frontend/src/contexts/DialogContext.tsx b/client/ui/frontend/src/contexts/DialogContext.tsx
new file mode 100644
index 000000000..8a52e0dd0
--- /dev/null
+++ b/client/ui/frontend/src/contexts/DialogContext.tsx
@@ -0,0 +1,68 @@
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { ConfirmModal } from "@/components/dialog/ConfirmModal";
+
+export type ConfirmOptions = {
+ title: ReactNode;
+ description: ReactNode;
+ confirmLabel: string;
+ cancelLabel?: string;
+ danger?: boolean;
+};
+
+type DialogContextValue = {
+ confirm: (options: ConfirmOptions) => Promise;
+};
+
+const DialogContext = createContext(null);
+
+export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
+ const [open, setOpen] = useState(false);
+ const [options, setOptions] = useState(null);
+ const resolverRef = useRef<((result: boolean) => void) | null>(null);
+
+ const confirm = useCallback((opts: ConfirmOptions) => {
+ setOptions(opts);
+ setOpen(true);
+ return new Promise((resolve) => {
+ resolverRef.current = resolve;
+ });
+ }, []);
+
+ const settle = (result: boolean) => {
+ resolverRef.current?.(result);
+ resolverRef.current = null;
+ setOpen(false);
+ };
+
+ const value = useMemo(() => ({ confirm }), [confirm]);
+
+ return (
+
+ {children}
+ settle(true)}
+ onCancel={() => settle(false)}
+ />
+
+ );
+}
+
+export const useConfirm = () => {
+ const ctx = useContext(DialogContext);
+ if (!ctx) throw new Error("useConfirm must be used within a DialogProvider");
+ return ctx.confirm;
+};
diff --git a/client/ui/frontend/src/contexts/NavSectionContext.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx
new file mode 100644
index 000000000..c08a3c824
--- /dev/null
+++ b/client/ui/frontend/src/contexts/NavSectionContext.tsx
@@ -0,0 +1,24 @@
+import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
+
+export type NavSection = "peers" | "networks";
+
+type NavSectionContextValue = {
+ section: NavSection;
+ setSection: (s: NavSection) => void;
+};
+
+const NavSectionContext = createContext(null);
+
+export const useNavSection = (): NavSectionContextValue => {
+ const ctx = useContext(NavSectionContext);
+ if (!ctx) {
+ throw new Error("useNavSection must be used inside NavSectionProvider");
+ }
+ return ctx;
+};
+
+export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
+ const [section, setSection] = useState("peers");
+ const value = useMemo(() => ({ section, setSection }), [section]);
+ return {children} ;
+};
diff --git a/client/ui/frontend/src/contexts/NetworksContext.tsx b/client/ui/frontend/src/contexts/NetworksContext.tsx
new file mode 100644
index 000000000..ef7231700
--- /dev/null
+++ b/client/ui/frontend/src/contexts/NetworksContext.tsx
@@ -0,0 +1,222 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Networks as NetworksSvc } from "@bindings/services";
+import type { Network } from "@bindings/services/models.js";
+import { useStatus } from "@/contexts/StatusContext";
+
+// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node.
+// The daemon may merge a v4+v6 pair into a single comma-joined range string.
+export const isExitNode = (range: string): boolean =>
+ range.split(",").some((part) => {
+ const trimmed = part.trim();
+ return trimmed === "0.0.0.0/0" || trimmed === "::/0";
+ });
+
+type NetworksContextValue = {
+ routes: Network[];
+ networkRoutes: Network[];
+ exitNodes: Network[];
+ activeExitNode: Network | null;
+ refresh: () => Promise;
+ toggleNetwork: (id: string, selected: boolean) => Promise;
+ toggleExitNode: (id: string, selected: boolean) => Promise;
+ setNetworksSelected: (ids: string[], selected: boolean) => Promise;
+};
+
+const NetworksContext = createContext(null);
+
+export const useNetworks = () => {
+ const ctx = useContext(NetworksContext);
+ if (!ctx) {
+ throw new Error("useNetworks must be used inside NetworksProvider");
+ }
+ return ctx;
+};
+
+export const NetworksProvider = ({ children }: { children: ReactNode }) => {
+ const { status } = useStatus();
+ const [routes, setRoutes] = useState([]);
+ const [pending, setPending] = useState>(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/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..fca03fc87
--- /dev/null
+++ b/client/ui/frontend/src/lib/connection.ts
@@ -0,0 +1,128 @@
+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 },
+ 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: "" },
+ { profileName: "", username: "" },
+ );
+
+ try {
+ await Promise.race([waitPromise, cancelPromise]);
+ } finally {
+ WindowManager.CloseBrowserLogin().catch(console.error);
+ }
+
+ if (state.cancelled) {
+ waitPromise.cancel?.();
+ waitPromise.catch(() => {});
+ }
+}
+
+export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise {
+ if (connectionInFlight || signal?.aborted) {
+ onSettled?.();
+ return;
+ }
+ connectionInFlight = true;
+
+ const state: SsoState = { cancelled: false };
+ let connectError: unknown;
+
+ try {
+ const result = await Connection.Login({
+ profileName: "",
+ username: "",
+ managementUrl: "",
+ setupKey: "",
+ preSharedKey: "",
+ hostname: "",
+ hint: "",
+ });
+
+ if (signal?.aborted) state.cancelled = true;
+
+ if (!state.cancelled && result.needsSsoLogin) {
+ // runSsoLogin brings the connection up in Go once SSO completes.
+ await runSsoLogin(result, state, signal);
+ } else {
+ if (!state.cancelled && signal?.aborted) state.cancelled = true;
+ if (!state.cancelled) {
+ await Connection.Up({ profileName: "", username: "" });
+ }
+ }
+ } catch (e) {
+ WindowManager.CloseBrowserLogin().catch(console.error);
+ if (!state.cancelled) connectError = e;
+ } finally {
+ state.offCancel?.();
+ state.offSignal?.();
+ connectionInFlight = false;
+ onSettled?.();
+ }
+
+ if (connectError !== undefined) {
+ await errorDialog({
+ Title: i18next.t("connect.error.loginTitle"),
+ Message: formatErrorMessage(connectError),
+ });
+ return;
+ }
+
+ if (state.cancelled && signal) {
+ throw new DOMException("aborted", "AbortError");
+ }
+}
diff --git a/client/ui/frontend/src/lib/errors.ts b/client/ui/frontend/src/lib/errors.ts
new file mode 100644
index 000000000..b4dee2717
--- /dev/null
+++ b/client/ui/frontend/src/lib/errors.ts
@@ -0,0 +1,73 @@
+import { WindowManager } from "@bindings/services";
+
+type ClassifiedError = { short: string; long: string; command: string };
+
+const asObject = (v: unknown): Record | null =>
+ v && typeof v === "object" ? (v as Record) : null;
+
+const parseJsonObject = (s: unknown): Record | null => {
+ if (typeof s !== "string") return null;
+ const t = s.trim();
+ if (!t.startsWith("{") || !t.endsWith("}")) return null;
+ try {
+ return asObject(JSON.parse(t));
+ } catch {
+ return null;
+ }
+};
+
+const toWailsEnvelope = (e: unknown): Record | null => {
+ const obj = asObject(e);
+ if (!obj) return null;
+ return asObject(obj.cause) ?? parseJsonObject(obj.message);
+};
+
+// Read { short, long, command } from wherever the classified error sits in the envelope
+const toClassifiedError = (v: unknown): ClassifiedError | null => {
+ const o = asObject(v);
+ if (!o) return null;
+ const short = typeof o.short === "string" ? o.short : "";
+ const long = typeof o.long === "string" ? o.long : "";
+ const command = typeof o.command === "string" ? o.command : "";
+ return short || long ? { short, long, command } : null;
+};
+
+const classify = (e: unknown): ClassifiedError | null => {
+ const envelope = toWailsEnvelope(e);
+ return toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
+};
+
+export const formatErrorMessage = (e: unknown): string => {
+ // Prefer the structured { short, long } the daemon classifier produced.
+ const classified = classify(e);
+ if (classified) {
+ const { short, long } = classified;
+ if (short && long && long !== short) return `${short} Details: ${long}`;
+ if (short) return short;
+ if (long) return long;
+ }
+
+ // Unclassified (a service returned the raw daemon error)
+ const envelope = toWailsEnvelope(e);
+ const message = envelope?.message;
+ if (typeof message === "string" && message) return message;
+ if (e instanceof Error) return e.message;
+ return String(e);
+};
+
+// errorCommand returns a command the user can run to complete an operation the
+// daemon refused, when the error carries one (a change that needs elevated
+// privileges). Empty for every other error.
+export const errorCommand = (e: unknown): string => classify(e)?.command ?? "";
+
+export type ErrorDialogOptions = {
+ Title: string;
+ Message: string;
+ // Command is shown for copying below the message. Defaults to the one the
+ // error carries, so callers only pass it to override.
+ Command?: string;
+};
+
+export function errorDialog(options: ErrorDialogOptions): Promise {
+ return WindowManager.OpenError(options.Title, options.Message, options.Command ?? "");
+}
diff --git a/client/ui/frontend/src/lib/formatters.ts b/client/ui/frontend/src/lib/formatters.ts
new file mode 100644
index 000000000..231967a38
--- /dev/null
+++ b/client/ui/frontend/src/lib/formatters.ts
@@ -0,0 +1,44 @@
+export const formatBytes = (bytes: number, decimals: number = 2): string => {
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
+
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
+
+ return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
+};
+
+export const latencyColor = (ms: number): string => {
+ if (ms <= 0) return "text-nb-gray-400";
+ if (ms < 100) return "text-green-400";
+ return "text-yellow-400";
+};
+
+export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => {
+ if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null;
+ const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds));
+ if (diff < 60) return `${diff}s ago`;
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+ return `${Math.floor(diff / 86400)}d ago`;
+};
+
+// Base domain is operator-configurable, so cut at the first dot rather than match a known suffix.
+export const shortenDns = (fqdn: string | undefined | null): string => {
+ if (!fqdn) return "";
+ const dot = fqdn.indexOf(".");
+ return dot === -1 ? fqdn : fqdn.slice(0, dot);
+};
+
+// Countdown clock: mm:ss, widening to hh:mm:ss / dd:hh:mm:ss as the duration grows.
+export const formatRemaining = (seconds: number): string => {
+ const s = Math.max(0, Math.trunc(seconds));
+ const days = Math.floor(s / 86400);
+ const hours = Math.floor((s % 86400) / 3600);
+ const minutes = Math.floor((s % 3600) / 60);
+ const secs = s % 60;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
+ if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
+ return `${pad(minutes)}:${pad(secs)}`;
+};
diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts
new file mode 100644
index 000000000..dc7fc7902
--- /dev/null
+++ b/client/ui/frontend/src/lib/i18n.ts
@@ -0,0 +1,103 @@
+import i18next from "i18next";
+import { initReactI18next } from "react-i18next";
+import { Events } from "@wailsio/runtime";
+
+import { Preferences, I18n } from "@bindings/services";
+import { type LanguageCode } from "@bindings/i18n/models.js";
+
+// Relative path on purpose — alias globs (`@/…`) silently match nothing in some Vite dev setups.
+type BundleEntry = { message: string; description?: string };
+const bundleModules = import.meta.glob>(
+ "../../../i18n/locales/*/common.json",
+ { eager: true, import: "default" },
+);
+
+const resources: Record }> = {};
+for (const path in bundleModules) {
+ const match = /locales\/([^/]+)\/common\.json$/.exec(path);
+ if (match) {
+ const entries = bundleModules[path];
+ const messages: Record = {};
+ for (const key in entries) {
+ messages[key] = entries[key].message;
+ }
+ resources[match[1]] = { common: messages };
+ }
+}
+
+function detectBrowserLanguage(available: string[]): string | null {
+ const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
+ (tag): tag is string => typeof tag === "string" && tag.length > 0,
+ );
+ const byLower = new Map(available.map((code) => [code.toLowerCase(), code]));
+ for (const tag of tags) {
+ const lower = tag.toLowerCase();
+ const exact = byLower.get(lower);
+ if (exact) return exact;
+ const base = byLower.get(lower.split("-")[0]);
+ if (base) return base;
+ }
+ return null;
+}
+
+// An empty persisted language code is the Go-side signal for first run.
+export async function initI18n(): Promise {
+ const available = Object.keys(resources);
+ let language = "en";
+ let firstRun = false;
+ try {
+ const prefs = await Preferences.Get();
+ if (prefs?.language) {
+ language = prefs.language;
+ } else {
+ firstRun = true;
+ language = detectBrowserLanguage(available) ?? "en";
+ }
+ } catch (e) {
+ console.warn("read preferences for language failed, defaulting to en", e);
+ }
+
+ if (firstRun) {
+ Preferences.SetLanguage(language as LanguageCode).catch((err: unknown) =>
+ console.warn("persist detected language failed", err),
+ );
+ }
+
+ await i18next.use(initReactI18next).init({
+ lng: language,
+ fallbackLng: "en",
+ defaultNS: "common",
+ ns: ["common"],
+ resources,
+ interpolation: {
+ prefix: "{",
+ suffix: "}",
+ escapeValue: false,
+ },
+ returnNull: false,
+ });
+
+ syncDocumentLang();
+ i18next.on("languageChanged", syncDocumentLang);
+
+ Events.On("netbird:preferences:changed", (e) => {
+ const next = e.data?.language;
+ if (next && next !== i18next.language) {
+ i18next.changeLanguage(next).catch((err: unknown) => {
+ console.error("changeLanguage failed", err);
+ });
+ }
+ });
+}
+
+function syncDocumentLang() {
+ if (typeof document !== "undefined") {
+ document.documentElement.lang = i18next.language;
+ }
+}
+
+export async function loadLanguages() {
+ return I18n.Languages();
+}
+
+export default i18next;
diff --git a/client/ui/frontend/src/lib/logs.ts b/client/ui/frontend/src/lib/logs.ts
new file mode 100644
index 000000000..499023fa3
--- /dev/null
+++ b/client/ui/frontend/src/lib/logs.ts
@@ -0,0 +1,139 @@
+import { UILog } from "@bindings/services";
+
+type Level = "trace" | "debug" | "info" | "warn" | "error";
+
+const METHOD_LEVELS: Record = {
+ trace: "trace",
+ debug: "debug",
+ log: "info",
+ info: "info",
+ warn: "warn",
+ error: "error",
+};
+
+const IGNORED_SOURCES = new Set(["welcome.ts"]);
+
+const RATE_LIMIT = 50;
+const RATE_WINDOW_MS = 1000;
+
+let installed = false;
+let inForward = false;
+let windowStart = 0;
+let windowCount = 0;
+
+function describeCause(rawCause: unknown): string {
+ if (rawCause instanceof Error) return `${rawCause.name}: ${rawCause.message}`;
+ if (typeof rawCause === "object" && rawCause !== null) {
+ try {
+ return JSON.stringify(rawCause);
+ } catch {
+ // Circular ref — fall through to a tag instead of "[object Object]".
+ return `<${rawCause.constructor?.name ?? "object"}>`;
+ }
+ }
+ return String(rawCause);
+}
+
+function formatCause(rawCause: unknown): string {
+ if (rawCause === undefined) return "";
+ return `\ncaused by ${describeCause(rawCause)}`;
+}
+
+// WebKit (macOS WKWebView) omits the "Name: message" header from Error.stack,
+// so a bare stack hides the real cause. Prepend name+message, then the stack.
+function formatError(e: Error): string {
+ const head = `${e.name}: ${e.message}`;
+ const cause = formatCause((e as { cause?: unknown }).cause);
+ if (!e.stack) return `${head}${cause}`;
+ if (e.stack.startsWith(head)) return `${head}${cause}`;
+ return `${head}${cause}\n${e.stack}`;
+}
+
+function format(args: unknown[]): string {
+ return args
+ .map((a) => {
+ if (typeof a === "string") return a;
+ if (a instanceof Error) return formatError(a);
+ try {
+ return JSON.stringify(a);
+ } catch {
+ return String(a);
+ }
+ })
+ .join(" ");
+}
+
+function parseStackLine(line: string): string {
+ // Find the file:line:col tail at the end of the path.
+ const colonCol = line.lastIndexOf(":");
+ if (colonCol <= 0) return "";
+ const colonLine = line.lastIndexOf(":", colonCol - 1);
+ if (colonLine <= 0) return "";
+ const col = line.slice(colonCol + 1);
+ const lineNo = line.slice(colonLine + 1, colonCol);
+ if (!/^\d+$/.test(col) || !/^\d+$/.test(lineNo)) return "";
+ const before = line.slice(0, colonLine);
+ const sep = Math.max(
+ before.lastIndexOf("/"),
+ before.lastIndexOf("\\"),
+ before.lastIndexOf("("),
+ before.lastIndexOf(" "),
+ );
+ const file = before.slice(sep + 1);
+ if (!file.includes(".")) return "";
+ return `${file}:${lineNo}`;
+}
+
+function callerSource(): string {
+ const stack = new Error().stack;
+ if (!stack) return "";
+ for (const line of stack.split("\n").slice(1)) {
+ if (line.includes("/logs.ts")) continue;
+ const parsed = parseStackLine(line);
+ if (parsed) return parsed;
+ }
+ return "";
+}
+
+function forward(level: Level, args: unknown[]) {
+ if (inForward) return;
+ inForward = true;
+ try {
+ const now = Date.now();
+ if (now - windowStart >= RATE_WINDOW_MS) {
+ windowStart = now;
+ windowCount = 0;
+ }
+ if (++windowCount > RATE_LIMIT) return;
+
+ const source = callerSource();
+ if (IGNORED_SOURCES.has(source.split(":")[0])) return;
+ // Don't touch console here — it would recurse back into forward().
+ UILog.Log(level, source, format(args)).catch(() => {});
+ } catch {
+ // Swallow — log forwarding must never throw back into the caller.
+ } finally {
+ inForward = false;
+ }
+}
+
+export function initLogForwarding() {
+ if (installed) return;
+ installed = true;
+
+ const c = console as unknown as Record void>;
+ for (const [method, level] of Object.entries(METHOD_LEVELS)) {
+ const original = c[method]?.bind(console);
+ c[method] = (...args: unknown[]) => {
+ original?.(...args);
+ forward(level, args);
+ };
+ }
+
+ globalThis.addEventListener("error", (e) => {
+ forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]);
+ });
+ globalThis.addEventListener("unhandledrejection", (e) => {
+ forward("error", ["unhandled promise rejection:", e.reason]);
+ });
+}
diff --git a/client/ui/frontend/src/lib/platform.ts b/client/ui/frontend/src/lib/platform.ts
new file mode 100644
index 000000000..c256dde23
--- /dev/null
+++ b/client/ui/frontend/src/lib/platform.ts
@@ -0,0 +1,39 @@
+import { System } from "@wailsio/runtime";
+
+export type Platform = {
+ isWindows: boolean;
+ isMacOS: boolean;
+};
+
+let cached: Platform | null = null;
+
+export async function initPlatform(): Promise {
+ if (cached) return;
+
+ const syncIsMac = System.IsMac();
+ const syncIsWindows = System.IsWindows();
+
+ let env: Awaited> | null = null;
+ try {
+ env = await System.Environment();
+ } catch (e) {
+ console.error("[platform] System.Environment() threw:", e);
+ }
+
+ const os = (env?.OS ?? "").toLowerCase();
+ cached = {
+ isWindows: os ? os === "windows" : syncIsWindows,
+ isMacOS: os ? os === "darwin" : syncIsMac,
+ };
+}
+
+function get(): Platform {
+ if (!cached) {
+ throw new Error("platform: initPlatform() must complete before sync getters are used");
+ }
+ return cached;
+}
+
+export const isWindows = (): boolean => get().isWindows;
+export const isMacOS = (): boolean => get().isMacOS;
+export const isLinux = (): boolean => !get().isWindows && !get().isMacOS;
diff --git a/client/ui/frontend/src/lib/sorting.ts b/client/ui/frontend/src/lib/sorting.ts
new file mode 100644
index 000000000..70622f705
--- /dev/null
+++ b/client/ui/frontend/src/lib/sorting.ts
@@ -0,0 +1,27 @@
+// Stable, order-preserving reconciliation for lists that re-fetch from the daemon
+// on every status push (peers, networks, profiles). Re-sorting on each refresh would
+// make rows jump around under the user, so instead:
+// - items already on screen keep their existing order (from `prev`),
+// - items that vanished are dropped,
+// - newly-arrived items are sorted among themselves (`compareFresh`) and appended.
+// Net effect: the only visible movement is new rows landing at the bottom.
+//
+// Must stay pure and idempotent: callers write the returned `order` into a ref
+// during render (useMemo), so a rerun must reproduce the first pass — never
+// branch on run count or read external mutable state.
+export function reconcileOrder(
+ prev: string[],
+ items: T[],
+ keyOf: (item: T) => string,
+ compareFresh: (a: T, b: T) => number,
+): { order: string[]; items: T[] } {
+ const byKey = new Map(items.map((i) => [keyOf(i), i]));
+ const kept = prev.filter((k) => byKey.has(k));
+ const known = new Set(kept);
+ const fresh = items
+ .filter((i) => !known.has(keyOf(i)))
+ .sort(compareFresh)
+ .map(keyOf);
+ const order = [...kept, ...fresh];
+ return { order, items: order.map((k) => byKey.get(k)!) };
+}
diff --git a/client/ui/frontend/src/lib/stallwatch.ts b/client/ui/frontend/src/lib/stallwatch.ts
new file mode 100644
index 000000000..aca7d75bb
--- /dev/null
+++ b/client/ui/frontend/src/lib/stallwatch.ts
@@ -0,0 +1,31 @@
+// Detects webview suspension (macOS App Nap / hidden-window timer throttling).
+// While the webview is suspended no JS runs at all, so detection happens on
+// resume: a 1s interval measures wall-clock drift and reports how long timers
+// were frozen. Silent unless a stall actually occurred; a stalled webview is
+// what delays promise continuations such as the WaitSSOLogin → Up handoff.
+
+const INTERVAL_MS = 1000;
+const STALL_THRESHOLD_MS = 5000;
+const REPORT_COOLDOWN_MS = 60_000;
+
+let started = false;
+
+export function initStallWatch() {
+ if (started) return;
+ started = true;
+
+ let last = Date.now();
+ let lastReport = 0;
+ setInterval(() => {
+ const now = Date.now();
+ const stall = now - last - INTERVAL_MS;
+ last = now;
+ if (stall < STALL_THRESHOLD_MS) return;
+ if (now - lastReport < REPORT_COOLDOWN_MS) return;
+ lastReport = now;
+ console.warn(
+ `webview timers were suspended for ${(stall / 1000).toFixed(1)}s ` +
+ `(App Nap / hidden-window throttling); pending UI work ran late`,
+ );
+ }, INTERVAL_MS);
+}
diff --git a/client/ui/frontend/src/lib/welcome.ts b/client/ui/frontend/src/lib/welcome.ts
new file mode 100644
index 000000000..d9df3fc8d
--- /dev/null
+++ b/client/ui/frontend/src/lib/welcome.ts
@@ -0,0 +1,25 @@
+const ART = `
+ _ __ __ ____ _ __ ______ __ __ __
+ / | / /__ / /_/ __ )(_)________/ / / ____/___ ___ / /_ / / / /
+ / |/ / _ \\/ __/ __ / / ___/ __ / / / __/ __ \`__ \\/ __ \\/ /_/ /
+ / /| / __/ /_/ /_/ / / / / /_/ / / /_/ / / / / / / /_/ / __ /
+/_/ |_/\\___/\\__/_____/_/_/ \\__,_/ \\____/_/ /_/ /_/_.___/_/ /_/
+`;
+
+export function welcome() {
+ const message = `%c${ART}%c
+NetBird — The Only Secure Access Platform You'll Ever Need.
+
+WEBSITE: https://netbird.io/
+WE'RE HIRING: https://netbird.io/careers
+OPEN SOURCE: https://github.com/netbirdio/netbird
+`;
+
+ // Intentional NetBird ASCII banner in the devtools console.
+ // eslint-disable-next-line no-console
+ console.log(
+ message,
+ "color: #f68330; font-family: monospace; font-weight: normal; line-height: 1;",
+ "color: #f5f5f5; font-family: monospace; font-weight: normal; line-height: 1.4;",
+ );
+}
diff --git a/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx
new file mode 100644
index 000000000..3345e0a9f
--- /dev/null
+++ b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx
@@ -0,0 +1,27 @@
+import { forwardRef, type HTMLAttributes } from "react";
+import { ArrowUpCircleIcon } from "lucide-react";
+import { cn } from "@/lib/cn";
+
+type Props = HTMLAttributes & {
+ size?: number;
+};
+
+export const UpdateBadge = forwardRef(function UpdateBadge(
+ { size = 15, className, ...rest },
+ ref,
+) {
+ return (
+
+ );
+});
diff --git a/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx
new file mode 100644
index 000000000..1519613e0
--- /dev/null
+++ b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx
@@ -0,0 +1,187 @@
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useSearchParams } from "react-router-dom";
+import { Loader2, XCircle } from "lucide-react";
+import { Update as UpdateSvc, WindowManager } from "@bindings/services";
+import { Button } from "@/components/buttons/Button";
+import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
+import { DialogActions } from "@/components/dialog/DialogActions";
+import { DialogDescription } from "@/components/dialog/DialogDescription";
+import { DialogHeading } from "@/components/dialog/DialogHeading";
+import { SquareIcon } from "@/components/SquareIcon";
+import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
+
+const TIMEOUT_MS = 15 * 60 * 1000;
+const POLL_INTERVAL_MS = 2000;
+// Sustained gRPC failure during install is taken as success (installer restarts the daemon mid-flight).
+const DAEMON_DOWN_GRACE_MS = 5000;
+const WINDOW_WIDTH = 360;
+
+type Phase =
+ | { kind: "running" }
+ | { kind: "timeout" }
+ | { kind: "canceled" }
+ | { kind: "failed"; message: string };
+
+export default function UpdateInProgressDialog() {
+ const { t } = useTranslation();
+ const [params] = useSearchParams();
+ const version = params.get("version") ?? "";
+ const [phase, setPhase] = useState({ kind: "running" });
+ const phaseRef = useRef(phase);
+ phaseRef.current = phase;
+ const contentRef = useAutoSizeWindow(WINDOW_WIDTH);
+
+ useEffect(() => {
+ let cancelled = false;
+ let done = false;
+ let timer: ReturnType | null = null;
+ const start = Date.now();
+ let firstUnreachableAt: number | null = null;
+
+ const poll = async () => {
+ if (cancelled || done) return;
+ if (phaseRef.current.kind !== "running") return;
+
+ if (Date.now() - start > TIMEOUT_MS) {
+ done = true;
+ setPhase({ kind: "timeout" });
+ return;
+ }
+
+ try {
+ const r = await UpdateSvc.GetInstallerResult();
+ if (cancelled || done || phaseRef.current.kind !== "running") return;
+ firstUnreachableAt = null;
+ if (r.success) {
+ done = true;
+ UpdateSvc.Quit().catch(console.error);
+ return;
+ }
+ if (r.errorMsg) {
+ done = true;
+ setPhase(mapInstallError(r.errorMsg));
+ return;
+ }
+ } catch {
+ if (cancelled || done || phaseRef.current.kind !== "running") return;
+ const now = Date.now();
+ if (firstUnreachableAt === null) {
+ firstUnreachableAt = now;
+ } else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
+ done = true;
+ UpdateSvc.Quit().catch(console.error);
+ return;
+ }
+ }
+
+ if (!cancelled && !done) {
+ timer = setTimeout(poll, POLL_INTERVAL_MS);
+ }
+ };
+
+ timer = setTimeout(poll, POLL_INTERVAL_MS);
+
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, []);
+
+ const isError = phase.kind !== "running";
+ const errorInfo = isError ? classifyPhase(phase, version, t) : null;
+ const updatingHeading = version
+ ? t("update.overlay.updatingVersion", { version })
+ : t("update.overlay.updating");
+
+ return (
+
+ {isError ? (
+
+ ) : (
+
+ )}
+
+
+
+ {errorInfo ? errorInfo.title : updatingHeading}
+
+
+ {errorInfo ? (
+ <>
+ {errorInfo.description}
+ {errorInfo.message && (
+ <>
+
+
+ {errorInfo.message}
+
+ >
+ )}
+ >
+ ) : (
+ t("update.overlay.description")
+ )}
+
+
+
+ {isError && (
+
+ WindowManager.CloseInstallProgress().catch(console.error)}
+ >
+ {t("common.close")}
+
+
+ )}
+
+ );
+}
+
+function mapInstallError(msg: string): Phase {
+ const m = msg.trim().toLowerCase();
+ if (m === "") return { kind: "failed", message: "" };
+ if (m.includes("deadline exceeded") || m.includes("timeout") || m.includes("timed out")) {
+ return { kind: "timeout" };
+ }
+ if (m.includes("canceled") || m.includes("cancelled") || m.includes("cancel")) {
+ return { kind: "canceled" };
+ }
+ return { kind: "failed", message: msg };
+}
+
+type Variant = { title: string; description: string; message?: string };
+
+function classifyPhase(
+ phase: Phase,
+ version: string,
+ t: (key: string, options?: Record) => string,
+): Variant {
+ const target = version
+ ? t("update.overlay.error.targetVersion", { version })
+ : t("update.overlay.error.targetFallback");
+ switch (phase.kind) {
+ case "timeout":
+ return {
+ title: t("update.overlay.error.timeoutTitle"),
+ description: t("update.overlay.error.timeoutDescription", { target }),
+ };
+ case "canceled":
+ return {
+ title: t("update.overlay.error.canceledTitle"),
+ description: t("update.overlay.error.canceledDescription", { target }),
+ };
+ case "failed":
+ return {
+ title: t("update.overlay.error.failTitle"),
+ description: t("update.overlay.error.failDescription", { target }),
+ message: phase.message || t("update.overlay.error.unknownMessage"),
+ };
+ default:
+ return { title: "", description: "" };
+ }
+}
diff --git a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx
new file mode 100644
index 000000000..4861c492a
--- /dev/null
+++ b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx
@@ -0,0 +1,99 @@
+import { type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { Browser } from "@wailsio/runtime";
+import { DownloadIcon, NotepadText } from "lucide-react";
+import { Update as UpdateSvc } from "@bindings/services";
+import { Button } from "@/components/buttons/Button";
+import { useClientVersion } from "@/contexts/ClientVersionContext";
+import { cn } from "@/lib/cn";
+
+const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest";
+
+function openUrl(url: string) {
+ Browser.OpenURL(url).catch(() => {
+ window.open(url, "_blank");
+ });
+}
+
+function openInstallerDownload() {
+ UpdateSvc.DownloadURL()
+ .then(openUrl)
+ .catch(() => openUrl(GITHUB_RELEASES));
+}
+
+export function UpdateVersionCard() {
+ const { t } = useTranslation();
+ const { updateVersion, enforced, triggerUpdate } = useClientVersion();
+
+ if (updateVersion) {
+ const titleKey = enforced
+ ? "update.card.versionAvailableInstall"
+ : "update.card.versionAvailableDownload";
+ return (
+
+
+
{t(titleKey, { version: updateVersion })}
+
+ {t("update.card.whatsNew")}
+
+
+ {enforced ? (
+
+ {t("update.card.installNow")}
+
+ ) : (
+
+
+ {t("update.card.getInstaller")}
+
+ )}
+
+ );
+ }
+
+ return (
+
+
+
{t("update.card.onLatestVersion")}
+
{t("update.card.autoCheckInterval")}
+
+ openUrl(GITHUB_RELEASES)}>
+
+ {t("update.card.changelog")}
+
+
+ );
+}
+
+function Card({ children, className }: Readonly<{ children: ReactNode; className?: string }>) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Title({ children }: Readonly<{ children: ReactNode }>) {
+ return {children}
;
+}
+
+function Link({ url, children }: Readonly<{ url: string; children: ReactNode }>) {
+ return (
+ openUrl(url)}
+ className={
+ "text-sm font-medium text-netbird hover:underline hover:decoration-[0.5px] hover:underline-offset-4"
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx
new file mode 100644
index 000000000..4fbb78052
--- /dev/null
+++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx
@@ -0,0 +1,95 @@
+import { useCallback, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { useSearchParams } from "react-router-dom";
+import { AlertCircleIcon } from "lucide-react";
+import { Button } from "@/components/buttons/Button";
+import { CopyToClipboard } from "@/components/CopyToClipboard";
+import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
+import { DialogActions } from "@/components/dialog/DialogActions";
+import { DialogDescription } from "@/components/dialog/DialogDescription";
+import { DialogHeading } from "@/components/dialog/DialogHeading";
+import { SquareIcon } from "@/components/SquareIcon";
+import { WindowManager } from "@bindings/services";
+import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
+
+const WINDOW_WIDTH = 380;
+// A command needs the room to wrap at a sensible number of characters instead of
+// breaking every few words.
+const WINDOW_WIDTH_WITH_COMMAND = 460;
+
+export default function ErrorDialog() {
+ const { t } = useTranslation();
+ const [params] = useSearchParams();
+
+ const title = params.get("title") || t("window.title.error");
+ const message = params.get("message") || "";
+ // Set when the daemon refused an operation that needs elevated privileges:
+ // the command that performs it, offered for copying.
+ const command = params.get("command") || "";
+ const contentRef = useAutoSizeWindow(
+ command ? WINDOW_WIDTH_WITH_COMMAND : WINDOW_WIDTH,
+ );
+
+ const close = useCallback(() => {
+ WindowManager.CloseError().catch(console.error);
+ }, []);
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") close();
+ };
+ globalThis.addEventListener("keydown", onKey);
+ return () => globalThis.removeEventListener("keydown", onKey);
+ }, [close]);
+
+ return (
+
+
+
+
+
+ {title}
+
+ {message && (
+
+ {/* select-text: the message often names a path, a flag or an
+ address the user needs to act on. */}
+
+ {message}
+
+
+ )}
+ {command && (
+
+
+ {command}
+
+
+ )}
+
+
+
+
+ {t("common.close")}
+
+
+
+ );
+}
diff --git a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx
new file mode 100644
index 000000000..efbd1ee84
--- /dev/null
+++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx
@@ -0,0 +1,90 @@
+import { useCallback, useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { useSearchParams } from "react-router-dom";
+import { Events } from "@wailsio/runtime";
+import { Loader2 } from "lucide-react";
+import { Connection } from "@bindings/services";
+import { Button } from "@/components/buttons/Button";
+import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
+import { DialogActions } from "@/components/dialog/DialogActions";
+import { DialogDescription } from "@/components/dialog/DialogDescription";
+import { DialogHeading } from "@/components/dialog/DialogHeading";
+import { SquareIcon } from "@/components/SquareIcon";
+import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
+import { errorDialog, formatErrorMessage } from "@/lib/errors";
+
+const EVENT_CANCEL = "browser-login:cancel";
+const WINDOW_WIDTH = 360;
+
+export default function LoginWaitingForBrowserDialog() {
+ const { t } = useTranslation();
+ const [params] = useSearchParams();
+ const uri = params.get("uri") ?? "";
+ const contentRef = useAutoSizeWindow(WINDOW_WIDTH);
+ const openedRef = useRef(false);
+
+ const reportOpenFailure = useCallback(
+ (e: unknown) => {
+ void errorDialog({
+ Title: t("browserLogin.openFailedTitle"),
+ Message: formatErrorMessage(e),
+ });
+ },
+ [t],
+ );
+
+ // Open the browser only after mount, or it lands on top of the still-hidden popup.
+ useEffect(() => {
+ if (!uri || openedRef.current) return;
+ openedRef.current = true;
+ Connection.OpenURL(uri).catch(reportOpenFailure);
+ }, [uri, reportOpenFailure]);
+
+ const tryAgain = useCallback(() => {
+ if (!uri) return;
+ Connection.OpenURL(uri).catch(reportOpenFailure);
+ }, [uri, reportOpenFailure]);
+
+ const cancel = useCallback(() => {
+ Events.Emit(EVENT_CANCEL).catch((err: unknown) =>
+ console.error("emit browser-login cancel", err),
+ );
+ }, []);
+
+ return (
+
+
+
+
+
+ {t("browserLogin.title")}
+
+
+ {t("browserLogin.notSeeing")}{" "}
+
+ {t("browserLogin.tryAgain")}
+
+
+
+
+
+
+ {t("common.cancel")}
+
+
+
+ );
+}
diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx
new file mode 100644
index 000000000..c2a648278
--- /dev/null
+++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx
@@ -0,0 +1,410 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Events } from "@wailsio/runtime";
+import { Connection, WindowManager } from "@bindings/services";
+import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx";
+import { useStatus } from "@/contexts/StatusContext.tsx";
+import { useProfile } from "@/contexts/ProfileContext.tsx";
+import { cn } from "@/lib/cn.ts";
+import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
+import {
+ startConnection,
+ EVENT_BROWSER_LOGIN_CANCEL,
+ EVENT_TRIGGER_LOGIN,
+} from "@/lib/connection.ts";
+import { CopyToClipboard } from "@/components/CopyToClipboard";
+import { TruncatedText } from "@/components/TruncatedText";
+import { shortenDns } from "@/lib/formatters";
+import { contentTop } from "@/components/empty-state/EmptyState";
+import { useFocusVisible } from "@/hooks/useFocusVisible";
+import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
+import * as Popover from "@radix-ui/react-popover";
+import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
+
+enum ConnectionState {
+ Disconnected = "disconnected",
+ Connecting = "connecting",
+ Connected = "connected",
+ Disconnecting = "disconnecting",
+}
+
+const STATUS_KEY: Record = {
+ [ConnectionState.Disconnected]: "connect.status.disconnected",
+ [ConnectionState.Connecting]: "connect.status.connecting",
+ [ConnectionState.Connected]: "connect.status.connected",
+ [ConnectionState.Disconnecting]: "connect.status.disconnecting",
+};
+
+const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
+
+const FORCE_TOGGLE_DELAY_MS = 7000;
+
+const errorMessage = formatErrorMessage;
+
+export const MainConnectionStatusSwitch = () => {
+ const { t } = useTranslation();
+ const { status, refresh } = useStatus();
+ const { activeProfileId, username } = useProfile();
+
+ const daemonState = status?.status ?? "Idle";
+ const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
+ const unreachable = daemonState === "DaemonUnavailable";
+
+ type Action = "connect" | "logging-in" | "disconnect" | null;
+ const [action, setAction] = useState(null);
+
+ const loginGuard = useRef(false);
+ const driveLogin = useCallback(() => {
+ if (loginGuard.current) return;
+ loginGuard.current = true;
+ setAction("logging-in");
+ void startConnection(() => {
+ loginGuard.current = false;
+ setAction(null);
+ refresh().catch((err: unknown) => console.error("refresh after login failed", err));
+ });
+ }, [refresh]);
+
+ const connState: ConnectionState = useMemo(() => {
+ if (action === "disconnect" && daemonState === "Connected") {
+ return ConnectionState.Disconnecting;
+ }
+ if ((action === "connect" || action === "logging-in") && daemonState !== "Connected") {
+ return ConnectionState.Connecting;
+ }
+ switch (daemonState) {
+ case "Connected":
+ return ConnectionState.Connected;
+ case "Connecting":
+ return ConnectionState.Connecting;
+ case "Idle":
+ case "NeedsLogin":
+ case "LoginFailed":
+ case "SessionExpired":
+ case "DaemonUnavailable":
+ return ConnectionState.Disconnected;
+ default:
+ return ConnectionState.Disconnected;
+ }
+ }, [daemonState, action]);
+
+ const connect = async () => {
+ setAction("connect");
+ try {
+ await Connection.Up({
+ profileName: activeProfileId,
+ username,
+ });
+ await refresh();
+ } catch (e) {
+ setAction(null);
+ await refresh();
+ await errorDialog({
+ Title: t("connect.error.connectTitle"),
+ Message: errorMessage(e),
+ });
+ }
+ };
+
+ const disconnect = async () => {
+ setAction("disconnect");
+ try {
+ await Connection.Down();
+ await refresh();
+ } catch (e) {
+ setAction(null);
+ await refresh();
+ await errorDialog({
+ Title: t("connect.error.disconnectTitle"),
+ Message: errorMessage(e),
+ });
+ }
+ };
+
+ const sawConnectingRef = useRef(false);
+
+ useEffect(() => {
+ if (action === null) {
+ sawConnectingRef.current = false;
+ return;
+ }
+ if (daemonState === "Connecting") {
+ sawConnectingRef.current = true;
+ }
+ if (action === "connect") {
+ if (needsLogin) {
+ driveLogin();
+ return;
+ }
+ if (daemonState === "Connected" || unreachable) {
+ setAction(null);
+ return;
+ }
+ if (sawConnectingRef.current && daemonState === "Idle") {
+ setAction(null);
+ }
+ return;
+ }
+ if (action === "disconnect") {
+ if (daemonState === "Idle" || daemonState === "Disconnected" || unreachable) {
+ setAction(null);
+ }
+ }
+ }, [action, daemonState, needsLogin, unreachable, driveLogin]);
+
+ useEffect(() => {
+ const off = Events.On(EVENT_TRIGGER_LOGIN, () => {
+ driveLogin();
+ });
+ return () => off();
+ }, [driveLogin]);
+
+ const handleSwitch = (next: boolean) => {
+ if (unreachable) return;
+ if (isTransitioning) {
+ if (canForceCancel) void forceCancel();
+ return;
+ }
+ if (action !== null) return;
+ if (needsLogin) {
+ driveLogin();
+ return;
+ }
+ if (next && connState === ConnectionState.Disconnected) {
+ void connect();
+ } else if (!next && connState === ConnectionState.Connected) {
+ void disconnect();
+ }
+ };
+
+ const isTransitioning =
+ connState === ConnectionState.Connecting || connState === ConnectionState.Disconnecting;
+ const isOn =
+ connState === ConnectionState.Connected || connState === ConnectionState.Connecting;
+
+ const [canForceCancel, setCanForceCancel] = useState(false);
+ useEffect(() => {
+ if (!isTransitioning) {
+ setCanForceCancel(false);
+ return;
+ }
+ const id = setTimeout(() => setCanForceCancel(true), FORCE_TOGGLE_DELAY_MS);
+ return () => clearTimeout(id);
+ }, [isTransitioning]);
+
+ const forceCancel = async () => {
+ if (action === "logging-in") {
+ Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) =>
+ console.error("emit browser-login cancel failed", err),
+ );
+ }
+ WindowManager.CloseBrowserLogin().catch((err: unknown) =>
+ console.warn("close browser-login window failed", err),
+ );
+ setAction("disconnect");
+ try {
+ await Connection.Down();
+ await refresh();
+ } catch (e) {
+ setAction(null);
+ await refresh();
+ await errorDialog({
+ Title: t("connect.error.disconnectTitle"),
+ Message: errorMessage(e),
+ });
+ }
+ };
+ const show = connState === ConnectionState.Connected;
+ const fqdn = status?.local.fqdn || "";
+ const ip = status?.local.ip || "";
+ const ipv6 = status?.local.ipv6 || "";
+
+ return (
+
+
+
+
+
+
+
+ {t(STATUS_KEY[connState])}
+
+
+
+
+
+
+
+ );
+};
+
+const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const isFocusVisible = useFocusVisible();
+ const hasV6 = !!ipv6;
+
+ if (!hasV6) {
+ return (
+
+
+ {ip || " "}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {ip || " "}
+
+
+
+
+
+ e.preventDefault()}
+ className={cn(
+ "z-50 min-w-64 max-w-[280px] overflow-hidden",
+ "rounded-lg border border-nb-gray-900 bg-nb-gray-935",
+ "p-1 text-nb-gray-200 shadow-lg outline-none",
+ "flex flex-col",
+ )}
+ >
+
+
+
+
+
+
+
+ );
+};
+
+const IpRow = ({ value }: { value: string }) => {
+ const { t } = useTranslation();
+ const [copied, setCopied] = useState(false);
+ const isFocusVisible = useFocusVisible();
+ const handleClick = async () => {
+ if (!value) return;
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 500);
+ } catch (e) {
+ console.warn("copy IP to clipboard failed", e);
+ }
+ };
+ return (
+
+ {value}
+
+ {copied ? : }
+
+
+ );
+};
diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx
new file mode 100644
index 000000000..fc7ce37b2
--- /dev/null
+++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx
@@ -0,0 +1,256 @@
+import { forwardRef, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import * as Popover from "@radix-ui/react-popover";
+import * as ScrollArea from "@radix-ui/react-scroll-area";
+import { Command } from "cmdk";
+import { Check, ChevronsUpDown, type LucideProps, SquareArrowUpRight } from "lucide-react";
+import { cn } from "@/lib/cn";
+import { TruncatedText } from "@/components/TruncatedText";
+import { useNetworks } from "@/contexts/NetworksContext";
+import { useStatus } from "@/contexts/StatusContext";
+import { useFocusVisible } from "@/hooks/useFocusVisible";
+
+const NONE_VALUE = "__none__";
+
+export const MainExitNodeSwitcher = () => {
+ const { t } = useTranslation();
+ const { status } = useStatus();
+ const { exitNodes, toggleExitNode } = useNetworks();
+ const active = exitNodes.find((n) => n.selected) ?? null;
+ const isConnected = status?.status === "Connected";
+ const hasAny = exitNodes.length > 0;
+ const disabled = !isConnected || !hasAny;
+
+ const [open, setOpen] = useState(false);
+ const listRef = useRef(null);
+
+ const handleTriggerKeyDown = (e: React.KeyboardEvent) => {
+ if (open || disabled) return;
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ e.preventDefault();
+ setOpen(true);
+ }
+ };
+
+ const handleSelect = (next: string) => {
+ setOpen(false);
+ if (next === NONE_VALUE) {
+ if (active)
+ toggleExitNode(active.id, true).catch((err: unknown) =>
+ console.error("toggle exit node failed", err),
+ );
+ return;
+ }
+ if (active?.id === next) return;
+ toggleExitNode(next, false).catch((err: unknown) =>
+ console.error("toggle exit node failed", err),
+ );
+ };
+
+ const title = active ? active.id : t("exitNodes.card.title");
+ const activeDescription = active
+ ? t("exitNodes.card.statusActive")
+ : t("exitNodes.card.statusInactive");
+ const description = hasAny ? activeDescription : t("exitNodes.empty.title");
+
+ return (
+
+
+
+
+
+ {
+ e.preventDefault();
+ listRef.current?.focus();
+ }}
+ style={{ width: "var(--radix-popover-trigger-width)" }}
+ className={cn(
+ "wails-no-draggable z-50 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
+ "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ )}
+ >
+ e.stopPropagation()}
+ className={"outline-none focus:outline-none focus-visible:outline-none"}
+ >
+
+ handleSelect(NONE_VALUE)} />
+ {hasAny &&
}
+ {hasAny && (
+
+
+ {exitNodes.map((n) => (
+ handleSelect(n.id)}
+ />
+ ))}
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+type TriggerProps = React.ButtonHTMLAttributes & {
+ title: string;
+ description: string;
+ active?: boolean;
+};
+
+const ExitNodeTriggerCard = forwardRef(
+ function ExitNodeTriggerCard(
+ { title, description, disabled, active = false, className, ...props },
+ ref,
+ ) {
+ const isFocusVisible = useFocusVisible();
+ return (
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+ );
+ },
+);
+
+type NoneRowProps = {
+ isActive: boolean;
+ onSelect: () => void;
+};
+
+const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
+ const { t } = useTranslation();
+ return (
+
+ {t("exitNodes.dropdown.noneTitle")}
+ {isActive && (
+
+ )}
+
+ );
+};
+
+type ExitNodeRowProps = {
+ id: string;
+ label: string;
+ isActive: boolean;
+ onSelect: () => void;
+};
+
+const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
+
+ {label}
+ {isActive && }
+
+);
+
+const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
+
+);
diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx
new file mode 100644
index 000000000..becfde47c
--- /dev/null
+++ b/client/ui/frontend/src/modules/main/MainHeader.tsx
@@ -0,0 +1,192 @@
+import { useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ArrowUpCircleIcon,
+ Check,
+ MoreVertical,
+ PanelsRightBottom,
+ RectangleVertical,
+ Settings,
+ type LucideIcon,
+} from "lucide-react";
+import { WindowManager } from "@bindings/services";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/DropdownMenu";
+import { IconButton } from "@/components/buttons/IconButton";
+import { ProfileDropdown } from "@/modules/profiles/ProfileDropdown";
+import { useClientVersion } from "@/contexts/ClientVersionContext";
+import { cn } from "@/lib/cn";
+import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
+import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
+import { useRestrictions } from "@/contexts/RestrictionsContext";
+import { isWindows } from "@/lib/platform.ts";
+
+const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
+
+export const MainHeader = () => {
+ const { t } = useTranslation();
+ const [menuOpen, setMenuOpen] = useState(false);
+ const { viewMode, setViewMode } = useViewMode();
+ const { updateAvailable } = useClientVersion();
+ const { mdm, features } = useRestrictions();
+
+ const openSettings = useCallback(() => {
+ setMenuOpen(false);
+ WindowManager.OpenSettings("").catch((err: unknown) =>
+ console.error("open settings window failed", err),
+ );
+ }, []);
+
+ useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings);
+
+ const openAbout = () => {
+ setMenuOpen(false);
+ WindowManager.OpenSettings("about").catch((err: unknown) =>
+ console.error("open settings (about) window failed", err),
+ );
+ };
+
+ const openManageProfiles = () => {
+ WindowManager.OpenSettings("profiles").catch((err: unknown) =>
+ console.error("open settings (profiles) window failed", err),
+ );
+ };
+
+ const selectMode = (mode: ViewMode) => {
+ setMenuOpen(false);
+ setViewMode(mode);
+ };
+
+ const profileSlot = features.disableProfiles ? null : (
+
+ );
+
+ const settingsSlot = (
+
+
+
+
+
+
+ {updateAvailable && (
+ <>
+
+
+
+
+ {t("header.menu.updateAvailable")}
+
+
+
+
+ >
+ )}
+
+
+
+ {t("header.menu.settings")}
+
+ {formatShortcut(SETTINGS_SHORTCUT)}
+
+
+
+ {!mdm.disableAdvancedView && (
+ <>
+
+ selectMode("default")}
+ />
+ selectMode("advanced")}
+ />
+ >
+ )}
+
+
+ {updateAvailable && (
+
+
+
+
+ )}
+
+ );
+
+ return (
+
+ {/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
+ See https://github.com/wailsapp/wails/issues/3260 */}
+
+ {settingsSlot}
+
+ );
+};
+
+type ViewModeItemProps = {
+ icon: LucideIcon;
+ label: string;
+ selected: boolean;
+ onSelect: () => void;
+};
+
+const ViewModeItem = ({ icon: Icon, label, selected, onSelect }: ViewModeItemProps) => (
+
+
+
+ {label}
+ {selected && }
+
+
+);
diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx
new file mode 100644
index 000000000..c05b3a025
--- /dev/null
+++ b/client/ui/frontend/src/modules/main/MainPage.tsx
@@ -0,0 +1,114 @@
+import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
+import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx";
+import { MainHeader } from "@/modules/main/MainHeader.tsx";
+import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
+import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
+import { cn } from "@/lib/cn";
+import { NavSectionProvider, useNavSection } from "@/contexts/NavSectionContext";
+import { ViewModeProvider, useViewMode } from "@/contexts/ViewModeContext";
+import { useEffect } from "react";
+import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
+import { useStatus } from "@/contexts/StatusContext";
+import { Peers } from "@/modules/main/advanced/peers/Peers";
+import { Networks } from "@/modules/main/advanced/networks/Networks";
+import { NetworksProvider } from "@/contexts/NetworksContext";
+import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
+import { useRestrictions } from "@/contexts/RestrictionsContext";
+import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
+import { isWindows } from "@/lib/platform.ts";
+
+export const MainPage = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+const MainBody = () => {
+ const { viewMode, setViewMode } = useViewMode();
+ const { mdm, features } = useRestrictions();
+
+ // Force flip the view if MDM disabled advanced
+ useEffect(() => {
+ if (mdm.disableAdvancedView && viewMode === "advanced") {
+ setViewMode("default");
+ }
+ }, [mdm.disableAdvancedView, viewMode, setViewMode]);
+
+ const isAdvanced = viewMode === "advanced";
+
+ return (
+
+ {/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
+ See https://github.com/wailsapp/wails/issues/3260 */}
+
+
+ {!features.disableNetworks && (
+
+
+
+ )}
+
+ {isAdvanced && (
+
+
+
+ )}
+
+ );
+};
+
+const AdvancedAppRightPanel = () => {
+ const { section } = useNavSection();
+ const { selected } = usePeerDetail();
+ const { status } = useStatus();
+ const isConnected = status?.status === "Connected";
+
+ return (
+ }
+ overlayOpen={selected !== null}
+ className={"m-5 ml-0"}
+ >
+ {
+ if (!el) return;
+ if (isConnected) el.removeAttribute("inert");
+ else el.setAttribute("inert", "");
+ }}
+ className={cn(
+ "flex min-h-0 min-w-0 flex-1 flex-col",
+ !isConnected && "pointer-events-none select-none",
+ )}
+ aria-hidden={!isConnected}
+ >
+
+
+ {section === "peers" &&
}
+ {section === "networks" &&
}
+
+
+ {!isConnected && (
+
+
+
+ )}
+
+ );
+};
diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx
new file mode 100644
index 000000000..8d69f3580
--- /dev/null
+++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx
@@ -0,0 +1,134 @@
+import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
+import { cn } from "@/lib/cn";
+import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
+import { useStatus } from "@/contexts/StatusContext";
+import { useRestrictions } from "@/contexts/RestrictionsContext";
+
+type TabEntry = {
+ value: NavSection;
+ label: string;
+ icon: ComponentType;
+};
+
+export const Navigation = () => {
+ const { t } = useTranslation();
+ const { section, setSection } = useNavSection();
+ const { status } = useStatus();
+ const { features } = useRestrictions();
+ const isConnected = status?.status === "Connected";
+
+ // Reset back to peers tab if mdm or feature flag flipped it
+ useEffect(() => {
+ if (features.disableNetworks && section === "networks") {
+ setSection("peers");
+ }
+ }, [features.disableNetworks, section, setSection]);
+
+ const tabs: TabEntry[] = [
+ {
+ value: "peers",
+ label: t("nav.peers.title"),
+ icon: MonitorSmartphoneIcon,
+ },
+ ];
+ if (!features.disableNetworks) {
+ tabs.push({
+ value: "networks",
+ label: t("nav.resources.title"),
+ icon: Layers3Icon,
+ });
+ }
+
+ const tabRefs = useRef>({});
+
+ const focusTab = (value: NavSection) => {
+ setSection(value);
+ requestAnimationFrame(() => tabRefs.current[value]?.focus());
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ const enabled = tabs.filter((t) => isConnected || t.value === section);
+ if (enabled.length < 2) return;
+ const currentIndex = enabled.findIndex((t) => t.value === section);
+ if (currentIndex === -1) return;
+ let nextIndex: number;
+ switch (e.key) {
+ case "ArrowRight":
+ nextIndex = (currentIndex + 1) % enabled.length;
+ break;
+ case "ArrowLeft":
+ nextIndex = (currentIndex - 1 + enabled.length) % enabled.length;
+ break;
+ case "Home":
+ nextIndex = 0;
+ break;
+ case "End":
+ nextIndex = enabled.length - 1;
+ break;
+ default:
+ return;
+ }
+ e.preventDefault();
+ focusTab(enabled[nextIndex].value);
+ };
+
+ return (
+
+ {tabs.map((tab, index) => {
+ const isActive = tab.value === section;
+ const isDisabled = !isConnected && !isActive;
+ const isFirst = index === 0;
+ const isLast = index === tabs.length - 1;
+ const Icon = tab.icon;
+ return (
+ {
+ tabRefs.current[tab.value] = el;
+ }}
+ type={"button"}
+ role={"tab"}
+ aria-selected={isActive}
+ aria-controls={`nb-tabpanel-${tab.value}`}
+ id={`nb-tab-${tab.value}`}
+ tabIndex={isActive ? 0 : -1}
+ onClick={() => setSection(tab.value)}
+ onKeyDown={handleKeyDown}
+ disabled={isDisabled}
+ className={cn(
+ "group relative flex flex-1 items-center justify-center",
+ "gap-2.5 px-5 py-3.5",
+ "outline-none transition-all",
+ isFirst && "rounded-tl-xl",
+ isLast && "rounded-tr-xl",
+ "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
+ isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
+ isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
+ )}
+ >
+
+ {tab.label}
+
+
+ );
+ })}
+
+ );
+};
+
+export type { NavSection } from "@/contexts/NavSectionContext";
diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx
new file mode 100644
index 000000000..ec1823f0b
--- /dev/null
+++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx
@@ -0,0 +1,84 @@
+import { useState } from "react";
+import { CheckIcon, ChevronDown, ListFilter } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/cn";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/DropdownMenu";
+
+export type NetworkFilter = "all" | "active" | "overlapping";
+
+type Props = {
+ value: NetworkFilter;
+ onChange: (value: NetworkFilter) => void;
+ counts: Record;
+ disabled?: boolean;
+};
+
+export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const filters: { value: NetworkFilter; label: string }[] = [
+ { value: "all", label: t("networks.filter.all") },
+ { value: "active", label: t("networks.filter.active") },
+ { value: "overlapping", label: t("networks.filter.overlapping") },
+ ];
+ const active = filters.find((f) => f.value === value) ?? filters[0];
+
+ const handleSelect = (v: NetworkFilter) => {
+ onChange(v);
+ setOpen(false);
+ };
+
+ return (
+
+
+
+
+ {active.label} ({counts[active.value]})
+
+
+
+
+ {filters.map((f) => {
+ const checked = f.value === value;
+ return (
+ handleSelect(f.value)}
+ role={"menuitemradio"}
+ aria-checked={checked}
+ className={"gap-2"}
+ >
+
+ {f.label}{" "}
+ ({counts[f.value]})
+
+
+ {checked && }
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx
new file mode 100644
index 000000000..f6fe23aaf
--- /dev/null
+++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx
@@ -0,0 +1,528 @@
+import {
+ type KeyboardEvent,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ComponentType,
+ type ReactNode,
+} from "react";
+import { useTranslation } from "react-i18next";
+import * as ScrollArea from "@radix-ui/react-scroll-area";
+import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
+import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
+import type { Network } from "@bindings/services/models.js";
+import { cn } from "@/lib/cn";
+import { reconcileOrder } from "@/lib/sorting";
+import { CopyToClipboard } from "@/components/CopyToClipboard";
+import { Tooltip } from "@/components/Tooltip";
+import { TruncatedText } from "@/components/TruncatedText";
+import { SearchInput } from "@/components/inputs/SearchInput";
+import { EmptyState } from "@/components/empty-state/EmptyState";
+import { NoResults } from "@/components/empty-state/NoResults";
+import { useStatus } from "@/contexts/StatusContext";
+import { useNetworks } from "@/contexts/NetworksContext";
+import { type NetworkFilter, NetworkFilters } from "./NetworkFilters";
+
+// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix".
+const INVALID_PREFIX = "invalid Prefix";
+
+const isDnsRoute = (n: Network): boolean =>
+ n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX);
+
+type ResourceType = "host" | "subnet" | "domain";
+
+const isHostCidr = (cidr: string): boolean => {
+ const [addr, bitsStr] = cidr.split("/");
+ if (!addr || !bitsStr) return false;
+ const bits = Number(bitsStr);
+ const isV6 = addr.includes(":");
+ return isV6 ? bits === 128 : bits === 32;
+};
+
+const resourceTypeOf = (n: Network): ResourceType => {
+ if (isDnsRoute(n)) return "domain";
+ const primary = n.range.split(",")[0].trim();
+ return isHostCidr(primary) ? "host" : "subnet";
+};
+
+const resourceIconFor = (type: ResourceType): ComponentType => {
+ if (type === "host") return WorkflowIcon;
+ if (type === "domain") return GlobeIcon;
+ return NetworkIcon;
+};
+
+const buildOverlapMap = (
+ routes: { id: string; range: string; domains: string[] }[],
+): Map => {
+ const byRange = new Map();
+ for (const r of routes) {
+ if (r.domains.length > 0) continue;
+ const arr = byRange.get(r.range) ?? [];
+ arr.push(r.id);
+ byRange.set(r.range, arr);
+ }
+ const out = new Map();
+ for (const [range, ids] of byRange) {
+ if (ids.length > 1) out.set(range, ids);
+ }
+ return out;
+};
+
+export const Networks = () => {
+ const { t } = useTranslation();
+ const { status } = useStatus();
+ const isConnected = status?.status === "Connected";
+ const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
+ const [search, setSearch] = useState("");
+ const [filter, setFilter] = useState("all");
+ const [scrollParent, setScrollParent] = useState