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/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml
new file mode 100644
index 000000000..d78e3bbd3
--- /dev/null
+++ b/.github/workflows/agent-network-e2e.yml
@@ -0,0 +1,68 @@
+name: Agent Network E2E
+
+on:
+ # Nightly at 03:00 UTC, plus on demand from the Actions tab.
+ schedule:
+ - cron: "0 3 * * *"
+ workflow_dispatch:
+
+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 }}
+ 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 }}
+ # 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/frontend-ui.yml b/.github/workflows/frontend-ui.yml
new file mode 100644
index 000000000..bb5bb4528
--- /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
+
+ - 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 748e3f996..420749a0e 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -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 privileged' -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 -e /client/testutil/privileged)
+ # 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-linux.yml b/.github/workflows/golang-test-linux.yml
index 37c780a33..0af506bba 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -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'
@@ -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 -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'
@@ -228,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 privileged" -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 -e /client/testutil/privileged)
+ 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:
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index b61c87cf6..50a5ba4d6 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -65,8 +65,15 @@ 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 privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 5d26d678d..b444a9900 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
@@ -54,7 +62,16 @@ jobs:
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:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 16eae31fb..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"
@@ -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
@@ -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
@@ -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/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml
index 35855918d..e8a12cdaf 100644
--- a/.github/workflows/wasm-build-validation.yml
+++ b/.github/workflows/wasm-build-validation.yml
@@ -27,7 +27,7 @@ jobs:
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:
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..0753b1012 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:
diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml
index 6f9b7c059..197fcd440 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
@@ -62,6 +71,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 +82,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 +92,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 +103,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..96e15371a 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
@@ -20,8 +29,6 @@ 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:
- - load_wgnt_from_rsrc
universal_binaries:
- id: netbird-ui-darwin
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cd1c087bb..261083783 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -79,13 +79,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 +222,39 @@ 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 `--`:
+
+```
+task dev -- --daemon-addr=tcp://127.0.0.1:41731
+```
+
+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 +292,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
@@ -291,8 +332,6 @@ go test -exec sudo ./...
```
> 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:
- Keep functions as simple as possible, with a single purpose
diff --git a/README.md b/README.md
index c9a51b6f1..40c6b9ed5 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@
- 🚀 We are hiring! Join us at careers.netbird.io
+ 🚀 We are hiring! Join us at https://netbird.io/careers
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/cmd/login.go b/client/cmd/login.go
index a7ee960b1..ee32a3727 100644
--- a/client/cmd/login.go
+++ b/client/cmd/login.go
@@ -22,11 +22,19 @@ import (
"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 +69,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 +170,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
diff --git a/client/cmd/root.go b/client/cmd/root.go
index f3fde2f1c..f1ef32717 100644
--- a/client/cmd/root.go
+++ b/client/cmd/root.go
@@ -71,12 +71,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",
@@ -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")
}
diff --git a/client/cmd/service.go b/client/cmd/service.go
index 56d8a8726..b0a56c71a 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,21 @@ 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
+ jsonServ *http.Server
+ jsonServMu sync.Mutex
serverInstance *server.Server
serverInstanceMu sync.Mutex
}
@@ -46,6 +53,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..5ef13a0a6 100644
--- a/client/cmd/service_controller.go
+++ b/client/cmd/service_controller.go
@@ -5,9 +5,6 @@ package cmd
import (
"context"
"fmt"
- "net"
- "os"
- "strings"
"time"
"github.com/kardianos/service"
@@ -22,41 +19,56 @@ import (
"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
+}
+
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])
- }
-
- listen, err := net.Listen(split[0], split[1])
+ daemonListener, err := listenOnAddress(daemonAddr)
if err != nil {
return fmt.Errorf("listen daemon interface: %w", 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])
+ var jsonListener *socketListener
+ if enableJSONSocket {
+ jsonListener, err = listenOnAddress(jsonSocket)
+ if err != nil {
+ _ = daemonListener.Close()
+ return fmt.Errorf("listen daemon JSON interface: %w", err)
+ }
+ } else {
+ removeStaleUnixSocketForAddress(jsonSocket)
+ }
+
+ go func() {
+ defer daemonListener.Close()
+ if jsonListener != nil {
+ defer jsonListener.Close()
+ }
+
+ if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
+ log.Error(err)
+ return
+ }
+ if jsonListener != nil {
+ if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
+ log.Error(err)
return
}
}
@@ -71,8 +83,16 @@ func (p *program) Start(svc service.Service) error {
p.serverInstance = serverInstance
p.serverInstanceMu.Unlock()
- log.Printf("started daemon server: %v", split[1])
- if err := p.serv.Serve(listen); err != nil {
+ if jsonListener != nil {
+ if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
+ log.Fatalf("failed to start daemon JSON server: %v", err)
+ }
+ } else {
+ log.Debug("daemon JSON socket disabled")
+ }
+
+ 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)
}
}()
@@ -92,6 +112,20 @@ func (p *program) Stop(srv service.Service) error {
p.cancel()
+ p.jsonServMu.Lock()
+ jsonServ := p.jsonServ
+ p.jsonServMu.Unlock()
+ 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 +182,9 @@ var runCmd = &cobra.Command{
if err != nil {
return err
}
+ if err := validateJSONSocketFlags(); err != nil {
+ return err
+ }
return s.Run()
},
@@ -162,6 +199,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 +238,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..29c1a6456
--- /dev/null
+++ b/client/cmd/service_json_gateway.go
@@ -0,0 +1,52 @@
+//go:build !ios && !android
+
+package cmd
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+func grpcGatewayEndpoint(addr string) string {
+ return strings.TrimPrefix(addr, "tcp://")
+}
+
+func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
+ mux := runtime.NewServeMux()
+ opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
+ if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
+ return err
+ }
+
+ jsonServer := &http.Server{
+ Handler: mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ BaseContext: func(net.Listener) context.Context {
+ return p.ctx
+ },
+ }
+
+ p.jsonServMu.Lock()
+ p.jsonServ = jsonServer
+ 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_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..f25087a69 100644
--- a/client/cmd/service_params.go
+++ b/client/cmd/service_params.go
@@ -23,6 +23,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 +31,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 +77,7 @@ func currentServiceParams() *serviceParams {
params := &serviceParams{
LogLevel: logLevel,
DaemonAddr: daemonAddr,
+ JSONSocket: jsonSocket,
ManagementURL: managementURL,
ConfigPath: configPath,
LogFiles: logFiles,
@@ -82,6 +85,7 @@ func currentServiceParams() *serviceParams {
DisableUpdateSettings: updateSettingsDisabled,
EnableCapture: captureEnabled,
DisableNetworks: networksDisabled,
+ EnableJSONSocket: enableJSONSocket,
}
if len(serviceEnvVars) > 0 {
@@ -113,9 +117,8 @@ 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
}
@@ -124,6 +127,14 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
daemonAddr = params.DaemonAddr
}
+ 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
// that an explicit clear (--management-url "") persists across reinstalls.
if !rootCmd.PersistentFlags().Changed("management-url") {
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_socket.go b/client/cmd/service_socket.go
new file mode 100644
index 000000000..f825a4062
--- /dev/null
+++ b/client/cmd/service_socket.go
@@ -0,0 +1,111 @@
+//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 == "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]://[path|host:port] format: %q", addr)
+ }
+
+ switch network {
+ case "unix", "tcp":
+ 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/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..8b3de3c66 100644
--- a/client/cmd/up.go
+++ b/client/cmd/up.go
@@ -479,10 +479,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
req.DisableIpv6 = &disableIPv6
}
- if cmd.Flag(enableLazyConnectionFlag).Changed {
- req.LazyConnectionEnabled = &lazyConnEnabled
- }
-
return &req
}
@@ -600,9 +596,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 +711,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/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/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/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/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 f196b51e5..487b2c2c0 100644
--- a/client/internal/acl/manager.go
+++ b/client/internal/acl/manager.go
@@ -9,6 +9,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"
@@ -32,10 +33,12 @@ type Manager interface {
// DefaultManager uses firewall manager to handle
type DefaultManager struct {
- firewall firewall.Manager
- peerRulesPairs map[id.RuleID][]firewall.Rule
- routeRules map[id.RuleID]firewall.Rule
- mutex sync.Mutex
+ firewall firewall.Manager
+ peerRulesPairs map[id.RuleID][]firewall.Rule
+ routeRules map[id.RuleID]firewall.Rule
+ previousConfigHash uint64
+ hasAppliedConfig bool
+ mutex sync.Mutex
}
// peerRuleGroup collapses a set of single-source FirewallRules sharing
@@ -88,6 +91,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
@@ -99,17 +119,54 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
time.Since(start), total)
}()
- if err := d.applyPeerACLs(networkMap); err != nil {
- log.Errorf("apply peer ACLs: %v", err)
+ peerErr := d.applyPeerACLs(networkMap)
+ if peerErr != nil {
+ log.Errorf("apply peer ACLs: %v", peerErr)
}
- if err := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag); err != nil {
- log.Errorf("apply route ACLs: %v", err)
+ routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag)
+ if routeErr != nil {
+ log.Errorf("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 && peerErr == 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) error {
@@ -177,7 +234,7 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) error {
var remaining []firewall.Rule
for _, rule := range rules {
if err := d.firewall.DeleteFilterRule(rule); err != nil {
- log.Errorf("failed to delete peer firewall rule, will retry: %v", err)
+ merr = multierror.Append(merr, fmt.Errorf("delete peer rule, will retry: %w", err))
remaining = append(remaining, rule)
}
}
diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go
index 74a7af7e0..f4ec46ee8 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"
@@ -486,3 +487,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..51f56b644 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,6 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
a.config.BlockLANAccess,
a.config.BlockInbound,
a.config.DisableIPv6,
- a.config.LazyConnectionEnabled,
a.config.EnableSSHRoot,
a.config.EnableSSHSFTP,
a.config.EnableSSHLocalPortForwarding,
@@ -474,3 +502,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/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/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..e75a7022e
--- /dev/null
+++ b/client/internal/auth/sessionwatch/watcher.go
@@ -0,0 +1,387 @@
+// 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 (
+ // Skew tolerates a small clock difference between the management
+ // server and this peer before treating a deadline as "in the past".
+ // Slightly above typical NTP drift; tight enough that the UI doesn't
+ // paint a stale expiry as if it were valid.
+ Skew = 30 * time.Second
+
+ // 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 Skew 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.
+//
+// The watcher is the single owner of the deadline propagated to the
+// recorder: every set, clear, sanity-check rejection and Close 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 — without the Close-time clear a teardown (Down, or the Down+Up of
+// a profile switch) would leave the next session showing the previous one's
+// stale "expires in" value.
+//
+// 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" guard, and arms a new one.
+//
+// Returns one of the sentinel Err* values when the deadline fails the
+// sanity checks (pre-epoch, far future, or in the past beyond Skew).
+// 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(-Skew)):
+ 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{}
+
+ 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 and drops the deadline on the status
+// recorder. Update calls after Close are ignored. Clearing the recorder
+// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
+// from leaving the next session showing this one's stale "expires in"
+// value — the recorder is server-scoped and outlives this engine-scoped
+// watcher, so nothing else drops the anchor on teardown.
+func (w *Watcher) Close() {
+ w.mu.Lock()
+ if w.closed {
+ w.mu.Unlock()
+ return
+ }
+ w.closed = true
+ w.stopTimerLocked()
+ hadDeadline := !w.current.IsZero()
+ 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 && hadDeadline {
+ recorder.SetSessionExpiresAt(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..da2b6add6
--- /dev/null
+++ b/client/internal/auth/sessionwatch/watcher_test.go
@@ -0,0 +1,519 @@
+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 := 30 * time.Millisecond
+ w := newWatcher(lead, r)
+ defer w.Close()
+
+ first := time.Now().Add(50 * 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 TestUpdateInPastClearsDeadline(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(-1 * time.Hour))
+ if !errors.Is(err, ErrDeadlineInPast) {
+ t.Fatalf("want ErrDeadlineInPast, got %v", err)
+ }
+ if !w.Deadline().IsZero() {
+ t.Fatalf("in-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 TestUpdateWithinSkewAccepted(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(50*time.Millisecond, r)
+ defer w.Close()
+
+ // 5 seconds in the past is within the 30s Skew tolerance — accept it.
+ d := time.Now().Add(-5 * time.Second)
+ if err := w.Update(d); err != nil {
+ t.Fatalf("within-skew Update should succeed, got %v", err)
+ }
+ if !w.Deadline().Equal(d) {
+ t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
+ }
+}
+
+func TestCloseSilencesUpdates(t *testing.T) {
+ r := &fakeRecorder{}
+ w := newWatcher(50*time.Millisecond, r)
+ w.Close()
+
+ _ = w.Update(time.Now().Add(time.Hour))
+
+ time.Sleep(20 * time.Millisecond)
+ if got := r.snapshot(); len(got) != 0 {
+ t.Fatalf("expected no events after Close, got %+v", got)
+ }
+}
+
+// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
+// holding a live deadline must zero the recorder on Close so the next
+// engine's watcher (and the UI reading the shared server-scoped recorder)
+// doesn't start out showing the previous session's stale "expires in".
+func TestCloseClearsRecorderDeadline(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.IsZero() {
+ t.Fatalf("recorder deadline after Close = %v, want zero", got)
+ }
+}
+
+// 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/conn_mgr.go b/client/internal/conn_mgr.go
index 112559132..a82a4ca8b 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:
@@ -28,7 +38,7 @@ type ConnMgr struct {
peerStore *peerstore.Store
statusRecorder *peer.Status
iface lazyconn.WGIface
- enabledLocally bool
+ force lazyForce
rosenpassEnabled bool
lazyConnMgr *manager.Manager
@@ -43,28 +53,34 @@ func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerSto
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 +92,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,6 +105,7 @@ 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
}
@@ -98,6 +115,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er
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")
@@ -309,6 +327,25 @@ 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 == "" {
diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go
new file mode 100644
index 000000000..5e2c53e35
--- /dev/null
+++ b/client/internal/conn_mgr_test.go
@@ -0,0 +1,40 @@
+package internal
+
+import (
+ "os"
+ "testing"
+
+ "github.com/netbirdio/netbird/client/internal/lazyconn"
+)
+
+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)
+ }
+ })
+ }
+}
diff --git a/client/internal/connect.go b/client/internal/connect.go
index 7cd2bab22..c2fc2fd73 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -27,6 +27,7 @@ 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"
@@ -276,6 +277,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 +324,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 +413,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 +424,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 +464,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)
@@ -596,7 +619,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
BlockInbound: config.BlockInbound,
DisableIPv6: config.DisableIPv6,
- LazyConnectionEnabled: config.LazyConnectionEnabled,
+ LazyConnection: lazyconn.ParseState(config.LazyConnection),
MTU: selectMTU(config.MTU, peerConfig.Mtu),
LogPath: logPath,
@@ -670,7 +693,6 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
config.BlockLANAccess,
config.BlockInbound,
config.DisableIPv6,
- config.LazyConnectionEnabled,
config.EnableSSHRoot,
config.EnableSSHSFTP,
config.EnableSSHLocalPortForwarding,
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index a65d8bd05..3a7c0ebff 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -229,9 +229,16 @@ scutil_dns.txt (macOS only):
const (
clientLogFile = "client.log"
+ uiLogFile = "gui-client.log"
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"
)
@@ -249,6 +256,7 @@ type BundleGenerator struct {
statusRecorder *peer.Status
syncResponse *mgmProto.SyncResponse
logPath string
+ uiLogPath string
tempDir string
statePath string
cpuProfile []byte
@@ -276,6 +284,7 @@ type GeneratorDependencies struct {
StatusRecorder *peer.Status
SyncResponse *mgmProto.SyncResponse
LogPath string
+ UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one.
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
@@ -300,6 +309,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
statusRecorder: deps.StatusRecorder,
syncResponse: deps.SyncResponse,
logPath: deps.LogPath,
+ uiLogPath: deps.UILogPath,
tempDir: deps.TempDir,
statePath: deps.StatePath,
cpuProfile: deps.CPUProfile,
@@ -411,6 +421,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)
}
@@ -681,7 +695,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))
}
@@ -986,7 +1000,7 @@ func (g *BundleGenerator) addLogfile() error {
return fmt.Errorf("add client log file to zip: %w", err)
}
- g.addRotatedLogFiles(logDir)
+ g.addRotatedLogFiles(logDir, clientLogPrefix)
stdErrLogPath := filepath.Join(logDir, errorLogFile)
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
@@ -1006,6 +1020,25 @@ func (g *BundleGenerator) addLogfile() error {
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.uiLogPath, uiLogFile); err != nil {
+ return fmt.Errorf("add UI log file to zip: %w", err)
+ }
+
+ g.addRotatedLogFiles(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)
@@ -1078,14 +1111,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(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)
diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go
index f6473f979..3749b3e9b 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(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..8286f6852 100644
--- a/client/internal/debug/debug_test.go
+++ b/client/internal/debug/debug_test.go
@@ -885,7 +885,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
DNSRouteInterval: 5 * time.Second,
ClientCertPath: "/tmp/cert",
ClientCertKeyPath: "/tmp/key",
- LazyConnectionEnabled: true,
+ LazyConnection: "on",
MTU: 1280,
}
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/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/engine.go b/client/internal/engine.go
index 50b347bb3..f44400c52 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"
@@ -82,6 +83,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")
@@ -141,7 +148,9 @@ type EngineConfig struct {
BlockInbound bool
DisableIPv6 bool
- 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 +175,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.
@@ -258,11 +268,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 +335,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 +416,10 @@ func (e *Engine) stopLocked() {
e.srWatcher.Close()
}
+ if e.sessionWatcher != nil {
+ e.sessionWatcher.Close()
+ }
+
if e.updateManager != nil {
e.updateManager.SetDownloadOnly()
}
@@ -900,6 +941,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() {
@@ -915,11 +966,16 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return e.ctx.Err()
}
+ e.ApplySessionDeadline(update.GetSessionExpiresAt())
+
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
}
- if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
+ done := e.phase("netbird_config")
+ err := e.updateNetbirdConfig(update.GetNetbirdConfig())
+ done()
+ if err != nil {
return err
}
@@ -933,11 +989,16 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
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")
e.persistSyncResponse(update)
+ done()
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -978,6 +1039,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)
}
@@ -1047,6 +1110,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")
@@ -1071,11 +1142,22 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
}
e.checks = checks
- info, err := system.GetInfoWithChecks(e.ctx, checks, e.overlayAddresses()...)
- 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,
@@ -1087,19 +1169,12 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
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,
)
-
- if err := e.mgmClient.SyncMeta(info); err != nil {
- log.Errorf("could not sync meta: error %s", err)
- return err
- }
- return nil
}
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
@@ -1228,7 +1303,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)
},
}
@@ -1259,31 +1334,15 @@ func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
- info, err := system.GetInfoWithChecks(e.ctx, e.checks, e.overlayAddresses()...)
- 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
@@ -1376,13 +1435,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)
@@ -1391,29 +1453,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()))
@@ -1428,42 +1521,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 {
@@ -1943,7 +2037,6 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
- e.config.LazyConnectionEnabled,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
@@ -2136,7 +2229,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()
@@ -2159,9 +2265,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)
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_session_deadline_test.go b/client/internal/engine_session_deadline_test.go
new file mode 100644
index 000000000..6127e5bb0
--- /dev/null
+++ b/client/internal/engine_session_deadline_test.go
@@ -0,0 +1,78 @@
+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")
+ })
+}
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 1ac9ceff7..fbd47ed74 100644
--- a/client/internal/engine_test.go
+++ b/client/internal/engine_test.go
@@ -178,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
}
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/lazyconn/activity/listener_bind.go b/client/internal/lazyconn/activity/listener_bind.go
index 666c3bc28..72a0cfc76 100644
--- a/client/internal/lazyconn/activity/listener_bind.go
+++ b/client/internal/lazyconn/activity/listener_bind.go
@@ -124,6 +124,11 @@ func (d *BindListener) ReadPackets() {
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..3868e37e8 100644
--- a/client/internal/lazyconn/manager/manager.go
+++ b/client/internal/lazyconn/manager/manager.go
@@ -130,8 +130,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)
}
@@ -513,13 +513,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 +536,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{}) {
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/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..c0422e8b7
--- /dev/null
+++ b/client/internal/netflow/store/event_aggregation_test.go
@@ -0,0 +1,362 @@
+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) {
+ store := NewAggregatingMemoryStore()
+ 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(),
+ },
+ })
+
+ 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..a34e4be63 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,13 @@ type Memory struct {
events map[uuid.UUID]*types.Event
}
+type AggregatingMemory struct {
+ Memory
+ WindowStart time.Time
+ WindowEnd time.Time
+ rnd *v2.PCG
+}
+
func (m *Memory) StoreEvent(event *types.Event) {
m.mux.Lock()
defer m.mux.Unlock()
@@ -48,3 +60,95 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) {
delete(m.events, id)
}
}
+
+func NewAggregatingMemoryStore() *AggregatingMemory {
+ return &AggregatingMemory{WindowStart: time.Now(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
+}
+
+func (am *AggregatingMemory) ResetAggregationWindow() types.FlowEventAggregator {
+ am.mux.Lock()
+ defer am.mux.Unlock()
+
+ now := time.Now()
+ 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
+}
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..fb468696f 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"
@@ -136,6 +137,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.
@@ -172,6 +206,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 +271,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 +470,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 +595,8 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
wgConfigWorkaround()
+ conn.injectPendingFirstPacket(wgProxy, nil)
+
conn.rosenpassRemoteKey = rci.rosenpassPubKey
conn.currentConnPriority = conntype.Relay
conn.statusRelay.SetConnected()
@@ -752,15 +803,17 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) {
}
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.PrepareInitialHandshake() {
+ return
}
+
+ 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)
+ }()
}
func (conn *Conn) disableWgWatcherIfNeeded() {
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/status.go b/client/internal/peer/status.go
index e48ac333c..a987482fe 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,23 +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
- 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
+ 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
@@ -223,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
@@ -237,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,
@@ -401,6 +425,7 @@ func (d *Status) UpdatePeerState(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -426,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
}
@@ -451,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
}
@@ -500,6 +527,7 @@ func (d *Status) UpdatePeerICEState(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -536,6 +564,7 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -571,6 +600,7 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -609,6 +639,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -702,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 {
@@ -760,6 +792,41 @@ 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 that has already
+// slipped into the past reports as "none": once the session has expired it is
+// no longer a meaningful countdown, and the sessionwatch.Watcher does not
+// arm a timer at the deadline itself to clear it (only the two pre-expiry
+// warnings). Without this guard the UI would keep painting a stale
+// "expires in …" against a moment that has passed until the next login,
+// extend, or teardown rewrote the value.
+func (d *Status) GetSessionExpiresAt() time.Time {
+ d.mux.Lock()
+ defer d.mux.Unlock()
+ if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
+ return time.Time{}
+ }
+ return d.sessionExpiresAt
}
// AddLocalPeerStateRoute adds a route to the local peer state
@@ -828,11 +895,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
@@ -840,11 +915,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
@@ -852,6 +932,7 @@ func (d *Status) MarkManagementConnected() {
d.mux.Unlock()
d.notifier.updateServerStates(mgm, sig)
+ d.notifyStateChange()
}
// UpdateSignalAddress update the address of the signal server
@@ -885,6 +966,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
@@ -892,11 +977,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
@@ -904,6 +994,7 @@ func (d *Status) MarkSignalConnected() {
d.mux.Unlock()
d.notifier.updateServerStates(mgm, sig)
+ d.notifyStateChange()
}
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
@@ -1110,16 +1201,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
@@ -1261,6 +1355,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..4fc883d17 100644
--- a/client/internal/peer/wg_watcher.go
+++ b/client/internal/peer/wg_watcher.go
@@ -31,7 +31,9 @@ type WGWatcher struct {
stateDump *stateDump
enabled bool
- muEnabled sync.RWMutex
+ muEnabled sync.Mutex
+ // initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
+ initialHandshake time.Time
resetCh chan struct{}
}
@@ -46,38 +48,38 @@ 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)) {
+// PrepareInitialHandshake reserves the watcher and 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. Returns ok=false if the watcher is already
+// running, in which case EnableWgWatcher must not be called.
+func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
w.muEnabled.Lock()
if w.enabled {
w.muEnabled.Unlock()
- return
+ return false
}
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)
- }
+ handshake, _ := w.wgState()
+ w.initialHandshake = handshake
+ return true
+}
- w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, initialHandshake)
+// 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.
+func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
+ w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake)
w.muEnabled.Lock()
w.enabled = false
w.muEnabled.Unlock()
}
-// 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
-}
-
// Reset signals the watcher that the WireGuard peer has been reset and a new
// handshake is expected. This restarts the handshake timeout from scratch.
func (w *WGWatcher) Reset() {
@@ -101,13 +103,16 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
case <-timer.C:
handshake, ok := w.handshakeCheck(lastHandshake)
if !ok {
+ 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)
}
}
diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go
index 3ce91cd46..634d7974f 100644
--- a/client/internal/peer/wg_watcher_test.go
+++ b/client/internal/peer/wg_watcher_test.go
@@ -7,6 +7,7 @@ import (
"time"
log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/iface/configurer"
)
@@ -34,6 +35,9 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
+ ok := watcher.PrepareInitialHandshake()
+ require.True(t, ok, "watcher should not be enabled yet")
+
onDisconnected := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
mlog.Infof("onDisconnectedFn")
@@ -62,6 +66,9 @@ func TestWGWatcher_ReEnable(t *testing.T) {
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
ctx, cancel := context.WithCancel(context.Background())
+ ok := watcher.PrepareInitialHandshake()
+ require.True(t, ok, "watcher should not be enabled yet")
+
wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
@@ -76,6 +83,9 @@ func TestWGWatcher_ReEnable(t *testing.T) {
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
+ ok = watcher.PrepareInitialHandshake()
+ require.True(t, ok, "watcher should be re-enabled after the previous run stopped")
+
onDisconnected := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
onDisconnected <- struct{}{}
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 5a71a981e..ed2f21999 100644
--- a/client/internal/profilemanager/config.go
+++ b/client/internal/profilemanager/config.go
@@ -101,8 +101,6 @@ type ConfigInput struct {
DNSLabels domain.List
- LazyConnectionEnabled *bool
-
MTU *uint16
}
@@ -180,7 +178,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 +386,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
@@ -454,7 +454,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 +464,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 +474,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 +484,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 +494,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 +504,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 +587,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.DisableNotifications != nil && input.DisableNotifications != config.DisableNotifications {
+ if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) {
if *input.DisableNotifications {
log.Infof("disabling notifications")
} else {
@@ -632,12 +632,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 +722,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
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/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/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go
index 22f3355c8..b784cc274 100644
--- a/client/internal/routemanager/dnsinterceptor/handler.go
+++ b/client/internal/routemanager/dnsinterceptor/handler.go
@@ -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/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..16f8d48fa 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -442,6 +442,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 +587,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 +710,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 +727,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,6 +762,10 @@ 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
@@ -755,6 +775,9 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI
}
netID := haID.NetID()
+ if strings.HasSuffix(string(netID), route.V6ExitSuffix) {
+ continue
+ }
info.allIDs = append(info.allIDs, netID)
if m.routeSelector.HasUserSelectionForRoute(netID) {
@@ -791,45 +814,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/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/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/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go
index 6e7ab19cb..cb9af9ccb 100644
--- a/client/mdm/canonical_loaders.go
+++ b/client/mdm/canonical_loaders.go
@@ -15,6 +15,7 @@ var allKeys = []string{
KeyDisableUpdateSettings,
KeyDisableProfiles,
KeyDisableNetworks,
+ KeyDisableAdvancedView,
KeyDisableClientRoutes,
KeyDisableServerRoutes,
KeyBlockInbound,
@@ -27,6 +28,7 @@ var allKeys = []string{
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..b76c70a75 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"
)
@@ -23,6 +24,13 @@ const (
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"
@@ -41,6 +49,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 +75,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 +164,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 +178,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..6fbb09958 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"`
@@ -3030,6 +3066,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 +3156,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 +3168,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 +3181,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 +3200,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 +3212,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 +3225,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 +3238,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 +3250,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 +3263,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 +3284,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 +3296,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 +3309,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 +3336,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 +3348,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 +3361,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 +3382,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 +3394,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 +3407,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 +3434,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 +3446,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 +3459,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 +3478,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 +3490,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 +3503,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 +3521,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 +3533,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 +3546,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 +3563,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 +3575,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 +3588,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 +3650,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 +3662,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 +3675,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 +3753,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 +3765,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 +3778,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 +3819,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 +3831,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 +3844,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 +3869,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 +3881,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 +3894,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 +3912,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 +3924,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 +3937,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 +3997,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 +4009,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 +4022,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 +4034,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 +4046,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 +4059,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 +4081,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 +4093,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 +4106,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 +4135,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 +4147,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 +4160,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 +4216,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 +4228,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 +4241,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 +4497,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 +4509,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 +4522,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 +4537,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 +4549,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 +4562,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 +4590,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 +4602,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 +4615,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 +4638,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 +4650,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 +4663,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 +4697,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 +4709,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 +4722,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 +4744,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 +4756,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 +4769,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 +4797,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 +4809,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 +4822,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 +4841,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 +4853,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 +4866,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 +4885,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 +4897,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 +4910,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 +4931,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 +4943,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 +4956,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 +4988,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 +5000,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 +5013,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 +5027,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 +5039,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 +5052,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 +5086,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 +5098,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 +5111,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 +5136,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 +5148,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 +5161,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 +5244,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 +5256,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 +5269,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 +5277,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 +5301,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 +5314,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 +5338,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 +5359,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 +5371,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 +5384,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 +5402,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 +5414,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 +5427,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 +5440,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 +5452,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 +5465,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 +5493,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 +5505,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 +5518,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 +5545,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 +5557,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 +5570,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 +5612,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 +5624,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 +5637,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 +5670,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 +5682,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 +5695,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 +5760,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 +5772,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 +5785,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 +5817,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 +5829,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 +5842,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 +5866,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 +6187,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 +6199,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 +6212,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 +6224,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 +6236,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 +6249,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 +6261,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 +6273,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 +6286,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 +6298,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 +6310,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 +6323,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 +6334,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 +6346,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 +6359,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 +6372,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 +6384,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 +6397,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 +6430,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 +6442,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 +6455,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 +6526,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 +6538,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 +6551,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 +6592,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 +6604,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 +6617,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 +6662,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 +6674,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 +6687,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 +6741,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 +6753,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 +6766,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 +6787,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 +6799,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 +6812,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 +6830,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 +6842,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 +6855,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 +6866,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 +6878,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 +6891,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 +6902,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 +6914,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 +6927,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 +6940,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 +6952,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 +7070,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 +7083,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 +7191,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 +7205,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" +
@@ -6746,7 +7262,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 +7454,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 +7500,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 +7583,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 +7611,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 +7625,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 +7648,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 +7691,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 +7918,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 +7935,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..a0a9690f0
--- /dev/null
+++ b/client/proto/daemon.pb.gw.go
@@ -0,0 +1,2560 @@
+// 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_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_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_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
+}
+
+// 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_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_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_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
+ })
+
+ 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_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_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_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()...)
+ })
+ 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_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_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_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"}, ""))
+)
+
+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_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_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_StartCPUProfile_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_StopCPUProfile_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage
+ forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream
+)
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index c1e3fe513..8d5294eb7 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
@@ -518,6 +569,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 +829,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 +923,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..0b6ac4b53 100644
--- a/client/server/debug.go
+++ b/client/server/debug.go
@@ -53,7 +53,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 +67,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
StatusRecorder: s.statusRecorder,
SyncResponse: syncResponse,
LogPath: s.logFile,
+ UILogPath: s.uiLogPath,
CPUProfile: cpuProfileData,
CapturePath: capturePath,
RefreshStatus: refreshStatus,
@@ -124,9 +128,26 @@ 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).
+func (s *Server) RegisterUILog(_ context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ s.uiLogPath = req.GetPath()
+ 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/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/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..c38715256 100644
--- a/client/server/network.go
+++ b/client/server/network.go
@@ -172,6 +172,17 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
return nil, 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 := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil {
+ return nil, fmt.Errorf("deselect sibling exit nodes: %w", err)
+ }
+ }
+ }
}
routeManager.TriggerSelection(routeManager.GetClientRoutes())
@@ -249,3 +260,38 @@ func toNetIDs(routes []string) []route.NetID {
}
return netIDs
}
+
+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/server/network_exitnode_test.go b/client/server/network_exitnode_test.go
new file mode 100644
index 000000000..1c0ba0ecb
--- /dev/null
+++ b/client/server/network_exitnode_test.go
@@ -0,0 +1,26 @@
+package server
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/netbirdio/netbird/route"
+)
+
+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/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..363f716a9 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,19 @@ 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).
+ 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
mutex sync.Mutex
config *profilemanager.Config
@@ -87,7 +100,7 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
- lastProbe time.Time
+ probeThrottle *probeThrottle
persistSyncResponse bool
isSessionActive atomic.Bool
@@ -135,6 +148,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,6 +167,15 @@ 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)
@@ -214,7 +238,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 +259,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 +276,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 +318,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
}
@@ -425,7 +461,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 +499,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
@@ -577,8 +612,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 +626,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 +663,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 +676,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 +720,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 +760,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 +791,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
}
@@ -755,6 +871,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 +949,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)
}
@@ -929,6 +1064,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
}
@@ -945,23 +1084,37 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
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
}
@@ -1176,7 +1329,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 +1394,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 +1422,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 +1656,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 +1867,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 +1877,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 +1969,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 +2006,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 +2034,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
}
@@ -1741,9 +2066,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 +2182,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)
@@ -1989,3 +2378,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
index 225cf6494..8b6f78f04 100644
--- a/client/server/server_privileged_test.go
+++ b/client/server/server_privileged_test.go
@@ -6,6 +6,7 @@ import (
"context"
"net"
"os/user"
+ "path/filepath"
"testing"
"time"
@@ -59,9 +60,25 @@ var (
}
)
-// 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) {
+// 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 {
@@ -113,8 +130,8 @@ func TestConnectWithRetryRuns(t *testing.T) {
t.Setenv(retryMultiplierVar, "1")
s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
- if counter < 3 {
- t.Fatalf("expected counter > 2, got %d", counter)
+ if counter != 1 {
+ t.Fatalf("expected exactly 1 login attempt (PermissionDenied must stop the retry loop), got %d", counter)
}
}
diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go
index 9818f9fdf..9baf16136 100644
--- a/client/server/setconfig_mdm_test.go
+++ b/client/server/setconfig_mdm_test.go
@@ -181,6 +181,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..0e55257a9 100644
--- a/client/server/setconfig_test.go
+++ b/client/server/setconfig_test.go
@@ -69,43 +69,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 +138,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 +161,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 +188,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 +249,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 +265,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/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/status/status.go b/client/status/status.go
index 5b815aaa3..a53585c99 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
@@ -1025,3 +1049,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 27588859e..1838204b8 100644
--- a/client/system/info.go
+++ b/client/system/info.go
@@ -2,9 +2,11 @@ package system
import (
"context"
+ "errors"
"net/netip"
"slices"
"strings"
+ "time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/metadata"
@@ -72,8 +74,6 @@ type Info struct {
BlockInbound bool
DisableIPv6 bool
- LazyConnectionEnabled bool
-
EnableSSHRoot bool
EnableSSHSFTP bool
EnableSSHLocalPortForwarding bool
@@ -85,7 +85,7 @@ func (i *Info) SetFlags(
rosenpassEnabled, rosenpassPermissive bool,
serverSSHAllowed *bool,
disableClientRoutes, disableServerRoutes,
- disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool,
+ disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool,
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
disableSSHAuth *bool,
) {
@@ -103,8 +103,6 @@ func (i *Info) SetFlags(
i.BlockInbound = blockInbound
i.DisableIPv6 = disableIPv6
- i.LazyConnectionEnabled = lazyConnectionEnabled
-
if enableSSHRoot != nil {
i.EnableSSHRoot = *enableSSHRoot
}
@@ -174,7 +172,7 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs .
processCheckPaths = append(processCheckPaths, check.GetFiles()...)
}
- files, err := checkFileAndProcess(processCheckPaths)
+ files, err := checkFileAndProcess(ctx, processCheckPaths)
if err != nil {
return nil, err
}
@@ -187,3 +185,43 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, 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 dcda18e61..a7fa02197 100644
--- a/client/system/info_test.go
+++ b/client/system/info_test.go
@@ -4,6 +4,7 @@ import (
"context"
"net/netip"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/metadata"
@@ -35,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 {
diff --git a/client/system/process.go b/client/system/process.go
index 87e21eb9d..07f69a212 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"
)
-// 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..44a1c8ba0 100644
--- a/client/system/process_test.go
+++ b/client/system/process_test.go
@@ -1,6 +1,7 @@
package system
import (
+ "context"
"testing"
"github.com/shirou/gopsutil/v3/process"
@@ -9,7 +10,7 @@ import (
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/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/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 40fb4169d..000000000
--- a/client/ui/client_ui.go
+++ /dev/null
@@ -1,2027 +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:
- // Suppress the on-boot Quick Actions popup when the daemon
- // reports DisableAutoConnect=true — that flag carries both the
- // user's "Connect on Startup = off" preference AND any MDM-
- // enforced override (applyMDMPolicy writes the policy value
- // into the same Config field). See netbirdio/netbird#5744.
- if !s.disableAutoConnectFromDaemon() {
- s.showQuickActionsUI()
- }
- case args.showUpdate:
- s.showUpdateProgress(ctx, args.showUpdateVersion)
- }
-
- 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
-}
-
-// disableAutoConnectFromDaemon returns true when the daemon reports
-// the active profile has DisableAutoConnect=true. Used by the
-// --quick-actions startup path to suppress the on-boot popup when the
-// user (or an MDM admin) opted out of auto-connecting; both cases
-// converge on the same Config field because applyMDMPolicy writes the
-// policy value into it. Returns false on any RPC / lookup failure so a
-// daemon hiccup does not silently swallow the popup.
-func (s *serviceClient) disableAutoConnectFromDaemon() bool {
- activeProf, err := s.profileManager.GetActiveProfile()
- if err != nil {
- log.Warnf("disableAutoConnectFromDaemon: get active profile: %v", err)
- return false
- }
- currUser, err := user.Current()
- if err != nil {
- log.Warnf("disableAutoConnectFromDaemon: get current user: %v", err)
- return false
- }
- conn, err := s.getSrvClient(failFastTimeout)
- if err != nil {
- log.Warnf("disableAutoConnectFromDaemon: get daemon client: %v", err)
- return false
- }
- srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
- ProfileName: activeProf.ID.String(),
- Username: currUser.Username,
- })
- if err != nil {
- log.Warnf("disableAutoConnectFromDaemon: GetConfig RPC: %v", err)
- return false
- }
- return srvCfg.GetDisableAutoConnect()
-}
-
-// getSrvConfig from the service to show it in the settings window.
-func (s *serviceClient) getSrvConfig() {
- s.managementURL = profilemanager.DefaultManagementURL
-
- _, 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..c7b12e538
--- /dev/null
+++ b/client/ui/frontend/src/app.tsx
@@ -0,0 +1,61 @@
+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";
+
+// Must run first so even init-time logs reach the Go log pipeline.
+initLogForwarding();
+
+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..1b2a87da4
--- /dev/null
+++ b/client/ui/frontend/src/components/CopyToClipboard.tsx
@@ -0,0 +1,126 @@
+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;
+ variant?: CopyToClipboardVariant;
+ "aria-label"?: string;
+ tabIndex?: number;
+ onKeyDown?: (e: KeyboardEvent) => void;
+};
+
+export const CopyToClipboard = ({
+ children,
+ message,
+ size = 10,
+ iconAlignment = "right",
+ className,
+ iconClassName,
+ alwaysShowIcon = false,
+ variant = "default",
+ "aria-label": ariaLabel,
+ tabIndex = 0,
+ onKeyDown,
+}: CopyToClipboardProps) => {
+ const { t } = useTranslation();
+ const wrapperRef = useRef(null);
+ const [copied, setCopied] = useState(false);
+ const copyTimer = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (copyTimer.current) clearTimeout(copyTimer.current);
+ },
+ [],
+ );
+
+ const handleClick = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ const text = message ?? wrapperRef.current?.innerText ?? "";
+ if (!text) return;
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ if (copyTimer.current) clearTimeout(copyTimer.current);
+ copyTimer.current = setTimeout(() => setCopied(false), 500);
+ } catch (e) {
+ console.warn("copy to clipboard failed", e);
+ }
+ };
+
+ const resolvedLabel =
+ ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy"));
+
+ return (
+
+ );
+};
diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx
new file mode 100644
index 000000000..d43c37e1b
--- /dev/null
+++ b/client/ui/frontend/src/components/DropdownMenu.tsx
@@ -0,0 +1,233 @@
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { cva } from "class-variance-authority";
+import { Check, ChevronRight, Circle } from "lucide-react";
+import * as React from "react";
+import { cn } from "@/lib/cn";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const menuItemVariants = cva("", {
+ variants: {
+ variant: {
+ default:
+ "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
+ danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500",
+ },
+ },
+ defaultVariants: { variant: "default" },
+});
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ variant?: "default" | "danger";
+ }
+>(({ className, inset, children, variant, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ variant?: "default" | "danger";
+ href?: string;
+ target?: string;
+ rel?: string;
+ }
+>(({ className, inset, variant, onClick, href, target, rel, children, ...props }, ref) => (
+ {
+ if (href) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.(e);
+ }}
+ {...props}
+ >
+ {href ? (
+
+ {children}
+
+ ) : (
+ children
+ )}
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+};
diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx
new file mode 100644
index 000000000..7a30f8b33
--- /dev/null
+++ b/client/ui/frontend/src/components/LanguagePicker.tsx
@@ -0,0 +1,235 @@
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import * as Popover from "@radix-ui/react-popover";
+import * as ScrollArea from "@radix-ui/react-scroll-area";
+import { Command } from "cmdk";
+import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
+import { Preferences } from "@bindings/services";
+import { type LanguageCode, type Language } from "@bindings/i18n/models.js";
+import { HelpText } from "@/components/typography/HelpText";
+import { Label } from "@/components/typography/Label";
+import { useFocusVisible } from "@/hooks/useFocusVisible";
+import { loadLanguages } from "@/lib/i18n";
+import { cn } from "@/lib/cn";
+import { errorDialog, formatErrorMessage } from "@/lib/errors";
+
+// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
+
+const labelFor = (lang: Language): string =>
+ lang.englishName && lang.englishName !== lang.displayName
+ ? `${lang.displayName} (${lang.englishName})`
+ : lang.displayName;
+
+export function LanguagePicker() {
+ const { t, i18n } = useTranslation();
+ const [languages, setLanguages] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const isFocusVisible = useFocusVisible();
+
+ useEffect(() => {
+ let cancelled = false;
+ loadLanguages()
+ .then((list) => {
+ if (!cancelled) setLanguages(list);
+ })
+ .catch((err: unknown) => console.error("load languages failed", err));
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const sorted = useMemo(
+ () => [...languages].sort((a, b) => a.displayName.localeCompare(b.displayName)),
+ [languages],
+ );
+
+ const current = useMemo(
+ () =>
+ languages.find((l) => l.code === i18n.language) ??
+ languages.find((l) => l.code === "en"),
+ [languages, i18n.language],
+ );
+
+ const handleTriggerKeyDown = (e: React.KeyboardEvent) => {
+ if (open) return;
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ e.preventDefault();
+ setOpen(true);
+ }
+ };
+
+ const select = async (code: string) => {
+ setOpen(false);
+ if (busy || code === i18n.language) return;
+ setBusy(true);
+ try {
+ await Preferences.SetLanguage(code as LanguageCode);
+ } catch (e) {
+ await errorDialog({
+ Title: t("settings.error.saveTitle"),
+ Message: formatErrorMessage(e),
+ });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ {t("settings.general.language.help")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("settings.general.language.empty")}
+
+
+
+ {sorted.map((lang) => {
+ const checked = lang.code === i18n.language;
+ return (
+ void select(lang.code)}
+ className={cn(
+ "my-0.5 flex cursor-default items-center gap-2 rounded-md px-2 py-2 outline-none",
+ "text-xs font-semibold text-nb-gray-200",
+ "data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
+ )}
+ >
+
+ {labelFor(lang)}
+
+
+ {checked && (
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/ui/frontend/src/components/ManagementServerSwitch.tsx b/client/ui/frontend/src/components/ManagementServerSwitch.tsx
new file mode 100644
index 000000000..0083a767a
--- /dev/null
+++ b/client/ui/frontend/src/components/ManagementServerSwitch.tsx
@@ -0,0 +1,38 @@
+import { useTranslation } from "react-i18next";
+import netbirdLogo from "@/assets/logos/netbird.svg";
+import { SwitchItem } from "@/components/switches/SwitchItem";
+import { SwitchItemGroup } from "@/components/switches/SwitchItemGroup";
+import { ManagementMode } from "@/hooks/useManagementUrl.ts";
+
+type Props = {
+ value: ManagementMode;
+ onChange: (mode: ManagementMode) => void;
+ fullWidth?: boolean;
+};
+
+export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: Props) => {
+ const { t, i18n } = useTranslation();
+ const itemClass = fullWidth ? "flex-1" : undefined;
+ return (
+ onChange(v as ManagementMode)}
+ aria-label={t("settings.general.management.label")}
+ className={fullWidth ? "w-full" : undefined}
+ >
+
+
+ {t("settings.general.management.cloud")}
+
+
+ {t("settings.general.management.selfHosted")}
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx
new file mode 100644
index 000000000..e904d2de5
--- /dev/null
+++ b/client/ui/frontend/src/components/SquareIcon.tsx
@@ -0,0 +1,37 @@
+import { type ComponentType } from "react";
+import { type LucideProps } from "lucide-react";
+import { cn } from "@/lib/cn";
+
+export type SquareIconVariant = "default" | "info" | "warning" | "danger";
+
+const variantClass: Record = {
+ default: "text-white",
+ info: "text-sky-400",
+ warning: "text-netbird",
+ danger: "text-red-500",
+};
+
+type SquareIconProps = {
+ icon: ComponentType;
+ iconSize?: number;
+ variant?: SquareIconVariant;
+ className?: string;
+};
+
+export const SquareIcon = ({
+ icon: Icon,
+ iconSize = 18,
+ variant = "default",
+ className,
+}: SquareIconProps) => (
+
+
+
+);
diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx
new file mode 100644
index 000000000..2c77ba139
--- /dev/null
+++ b/client/ui/frontend/src/components/Tooltip.tsx
@@ -0,0 +1,98 @@
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import * as RTooltip from "@radix-ui/react-tooltip";
+import { cn } from "@/lib/cn";
+
+type Props = {
+ content: ReactNode;
+ children: ReactNode;
+ side?: RTooltip.TooltipContentProps["side"];
+ align?: RTooltip.TooltipContentProps["align"];
+ delayDuration?: number;
+ sideOffset?: number;
+ alignOffset?: number;
+ interactive?: boolean;
+ keepOpenOnClick?: boolean;
+ contentClassName?: string;
+ closeDelay?: number;
+};
+
+export const Tooltip = ({
+ content,
+ children,
+ side = "bottom",
+ align = "center",
+ delayDuration = 200,
+ sideOffset = 6,
+ alignOffset = 0,
+ interactive = false,
+ keepOpenOnClick = true,
+ contentClassName,
+ closeDelay = 0,
+}: Props) => {
+ const [open, setOpen] = useState(false);
+ const hoveringRef = useRef(false);
+ const closeTimer = useRef | null>(null);
+
+ const cancelClose = () => {
+ if (closeTimer.current) {
+ clearTimeout(closeTimer.current);
+ closeTimer.current = null;
+ }
+ };
+ const scheduleClose = () => {
+ cancelClose();
+ if (closeDelay <= 0) {
+ setOpen(false);
+ return;
+ }
+ closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
+ };
+ useEffect(() => () => cancelClose(), []);
+
+ const handleOpenChange = (next: boolean) => {
+ if (!next && keepOpenOnClick && hoveringRef.current) return;
+ if (next) cancelClose();
+ setOpen(next);
+ };
+
+ return (
+
+
+ {
+ hoveringRef.current = true;
+ cancelClose();
+ }}
+ onPointerLeave={() => {
+ hoveringRef.current = false;
+ scheduleClose();
+ }}
+ >
+ {children}
+
+
+ e.preventDefault()}
+ className={cn(
+ "z-50 select-none text-xs text-nb-gray-100 shadow-lg",
+ "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
+ "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
+ !interactive && "pointer-events-none",
+ contentClassName ??
+ "rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
+ )}
+ >
+ {content}
+
+
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/TruncatedText.tsx b/client/ui/frontend/src/components/TruncatedText.tsx
new file mode 100644
index 000000000..5b2d2160c
--- /dev/null
+++ b/client/ui/frontend/src/components/TruncatedText.tsx
@@ -0,0 +1,32 @@
+import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
+import { Tooltip } from "@/components/Tooltip";
+
+type Props = {
+ text: string;
+ className?: string;
+ tooltipContent?: ReactNode;
+ delayDuration?: number;
+};
+
+export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => {
+ const ref = useRef(null);
+ const [overflowing, setOverflowing] = useState(false);
+
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ setOverflowing(el.scrollWidth > el.clientWidth);
+ }, [text]);
+
+ const span = (
+
+ {text}
+
+ );
+ if (!overflowing) return span;
+ return (
+
+ {span}
+
+ );
+};
diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx
new file mode 100644
index 000000000..1aedf82a6
--- /dev/null
+++ b/client/ui/frontend/src/components/VerticalTabs.tsx
@@ -0,0 +1,98 @@
+import { type ComponentType, type ReactNode, forwardRef } from "react";
+import * as Tabs from "@radix-ui/react-tabs";
+import { type LucideProps } from "lucide-react";
+import { cn } from "@/lib/cn";
+import { useFocusVisible } from "@/hooks/useFocusVisible";
+
+const Root = forwardRef>(
+ function VerticalTabsRoot({ className, ...props }, ref) {
+ return (
+
+ );
+ },
+);
+
+const List = forwardRef(function VerticalTabsList(
+ { className, ...props },
+ ref,
+) {
+ return (
+
+ );
+});
+
+type TriggerProps = Tabs.TabsTriggerProps & {
+ icon: ComponentType;
+ title: string;
+ iconSize?: number;
+ adornment?: ReactNode;
+};
+
+const Trigger = forwardRef(function VerticalTabsTrigger(
+ { icon: Icon, title, iconSize = 16, adornment, className, ...props },
+ ref,
+) {
+ const isFocusVisible = useFocusVisible();
+ return (
+
+
+
+ {title}
+
+ {adornment && (
+
+ {adornment}
+
+ )}
+
+ );
+});
+
+const Content = forwardRef(function VerticalTabsContent(
+ { className, ...props },
+ ref,
+) {
+ return (
+
+ );
+});
+
+export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });
diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx
new file mode 100644
index 000000000..6b151c17b
--- /dev/null
+++ b/client/ui/frontend/src/components/buttons/Button.tsx
@@ -0,0 +1,195 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { Check, Copy, Loader2 } from "lucide-react";
+import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react";
+
+import { cn } from "@/lib/cn";
+
+type ButtonVariants = VariantProps;
+
+interface ButtonProps extends ButtonHTMLAttributes, ButtonVariants {
+ disabled?: boolean;
+ stopPropagation?: boolean;
+ copy?: string;
+ loading?: boolean;
+}
+
+const buttonVariants = cva(
+ [
+ "relative",
+ "cursor-default select-none whitespace-nowrap text-sm font-medium shadow-sm focus:z-10 focus:outline-none focus:ring-2",
+ "inline-flex items-center justify-center gap-2 transition-colors focus:ring-offset-1",
+ "disabled:cursor-not-allowed disabled:opacity-40 dark:ring-offset-neutral-950/50 disabled:dark:text-nb-gray-300",
+ ],
+ {
+ variants: {
+ variant: {
+ default: [
+ "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
+ "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50",
+ ],
+ primary: [
+ "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900",
+ "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50",
+ ],
+ secondary: [
+ "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
+ "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
+ "dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white",
+ ],
+ secondaryLighter: [
+ "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
+ "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
+ "dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white",
+ ],
+ subtle: [
+ "border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60",
+ "dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40",
+ "dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950",
+ ],
+ input: [
+ "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
+ "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
+ "dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80",
+ ],
+ dropdown: [
+ "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
+ "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
+ "dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50",
+ ],
+ dotted: [
+ "border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
+ "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
+ "dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white",
+ ],
+ tertiary: [
+ "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
+ "dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
+ ],
+ white: [
+ "border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
+ "disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300",
+ ],
+ outline: [
+ "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
+ "dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50",
+ ],
+ "danger-outline": [
+ "dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20",
+ ],
+ "danger-text": [
+ "rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600",
+ ],
+ "default-outline": [
+ "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
+ "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
+ "data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white",
+ ],
+ ghost: [
+ "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
+ "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
+ ],
+ danger: [
+ "dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20",
+ ],
+ },
+ size: {
+ xs: "px-3.5 py-2.5 text-xs",
+ xs2: "px-4 py-[1.1rem] text-[0.78rem] leading-[0]",
+ sm: "px-4 py-[9px] text-sm",
+ md: "px-4 py-[9px]",
+ lg: "px-4 py-[9px] text-lg",
+ },
+ rounded: {
+ true: "rounded-md",
+ false: "",
+ },
+ border: {
+ 0: "border",
+ 1: "border border-transparent",
+ 2: "border border-b-0 border-t-0",
+ },
+ },
+ },
+);
+
+export const Button = forwardRef(function Button(
+ {
+ variant = "default",
+ rounded = true,
+ border = 1,
+ size = "md",
+ stopPropagation = true,
+ type = "button",
+ children,
+ className,
+ onClick,
+ disabled,
+ copy,
+ loading = false,
+ ...props
+ },
+ ref,
+) {
+ const [copied, setCopied] = useState(false);
+ const copyTimer = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (copyTimer.current) clearTimeout(copyTimer.current);
+ },
+ [],
+ );
+ const iconSize = size === "xs" ? 12 : 14;
+ return (
+
+ );
+});
+
+export default Button;
diff --git a/client/ui/frontend/src/components/buttons/IconButton.tsx b/client/ui/frontend/src/components/buttons/IconButton.tsx
new file mode 100644
index 000000000..3d36bc111
--- /dev/null
+++ b/client/ui/frontend/src/components/buttons/IconButton.tsx
@@ -0,0 +1,36 @@
+import { type ButtonHTMLAttributes, type ComponentType, forwardRef } from "react";
+import { type LucideProps } from "lucide-react";
+import { useFocusVisible } from "@/hooks/useFocusVisible";
+import { cn } from "@/lib/cn";
+
+type Props = ButtonHTMLAttributes & {
+ icon: ComponentType;
+ iconSize?: number;
+ iconClassName?: string;
+};
+
+export const IconButton = forwardRef(function IconButton(
+ { icon: Icon, iconSize = 17, iconClassName, className, type = "button", disabled, ...props },
+ ref,
+) {
+ const isFocusVisible = useFocusVisible();
+ return (
+
+ );
+});
diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx
new file mode 100644
index 000000000..caf98c2c8
--- /dev/null
+++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx
@@ -0,0 +1,35 @@
+import { type ReactNode, forwardRef } from "react";
+import { cn } from "@/lib/cn.ts";
+import { isMacOS } from "@/lib/platform.ts";
+
+type ConfirmDialogProps = {
+ children: ReactNode;
+ "aria-label"?: string;
+ "aria-labelledby"?: string;
+};
+
+export const ConfirmDialog = forwardRef(function ConfirmDialog(
+ { children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy },
+ ref,
+) {
+ return (
+
+ );
+});
diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx
new file mode 100644
index 000000000..241a69ec9
--- /dev/null
+++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx
@@ -0,0 +1,84 @@
+import { type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import * as Dialog from "@/components/dialog/Dialog";
+import { Button } from "@/components/buttons/Button";
+import { DialogHeading } from "@/components/dialog/DialogHeading";
+import { DialogDescription } from "@/components/dialog/DialogDescription";
+import { DialogActions } from "@/components/dialog/DialogActions";
+
+type ConfirmModalProps = {
+ open: boolean;
+ title: ReactNode;
+ description: ReactNode;
+ confirmLabel: string;
+ cancelLabel?: string;
+ danger?: boolean;
+ busy?: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+};
+
+export const ConfirmModal = ({
+ open,
+ title,
+ description,
+ confirmLabel,
+ cancelLabel,
+ danger = false,
+ busy = false,
+ onConfirm,
+ onCancel,
+}: ConfirmModalProps) => {
+ const { t } = useTranslation();
+ const resolvedCancel = cancelLabel ?? t("common.cancel");
+
+ const srTitle = typeof title === "string" ? title : undefined;
+ const srDescription = typeof description === "string" ? description : undefined;
+
+ return (
+ {
+ if (!next && !busy) onCancel();
+ }}
+ >
+ e.preventDefault()}
+ >
+
+
+ {title}
+
+ {description}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx
new file mode 100644
index 000000000..fa8007d9f
--- /dev/null
+++ b/client/ui/frontend/src/components/dialog/Dialog.tsx
@@ -0,0 +1,159 @@
+import {
+ forwardRef,
+ type ComponentPropsWithoutRef,
+ type ElementRef,
+ type HTMLAttributes,
+} from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
+import { X } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/cn";
+
+export const Root = DialogPrimitive.Root;
+
+type OverlayProps = ComponentPropsWithoutRef & {
+ exitAnimation?: boolean;
+};
+
+const Overlay = forwardRef, OverlayProps>(
+ function DialogOverlay({ className, exitAnimation = false, ...props }, ref) {
+ return (
+
+ );
+ },
+);
+
+type ContentProps = ComponentPropsWithoutRef & {
+ showClose?: boolean;
+ maxWidthClass?: string;
+ exitAnimation?: boolean;
+ srTitle?: string;
+ srDescription?: string;
+};
+
+export const Content = forwardRef, ContentProps>(
+ function DialogContent(
+ {
+ className,
+ children,
+ showClose = true,
+ maxWidthClass = "max-w-md",
+ exitAnimation = false,
+ srTitle,
+ srDescription,
+ ...props
+ },
+ ref,
+ ) {
+ const { t } = useTranslation();
+ return (
+
+
+ e.stopPropagation()}
+ {...props}
+ >
+
+
+ {srTitle ?? t("common.netbird")}
+
+
+ {srDescription && (
+
+
+ {srDescription}
+
+
+ )}
+ {children}
+ {showClose && (
+
+
+
+ )}
+
+
+
+ );
+ },
+);
+
+export const Title = forwardRef<
+ ElementRef,
+ ComponentPropsWithoutRef
+>(function DialogTitle({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+export const Description = forwardRef<
+ ElementRef,
+ ComponentPropsWithoutRef
+>(function DialogDescription({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+type FooterProps = HTMLAttributes & {
+ separator?: boolean;
+};
+
+export const Footer = ({ className, separator = true, ...props }: FooterProps) => (
+
+
*]:w-full sm:[&>*]:w-auto",
+ "px-8 pt-6",
+ className,
+ )}
+ {...props}
+ />
+
+);
diff --git a/client/ui/frontend/src/components/dialog/DialogActions.tsx b/client/ui/frontend/src/components/dialog/DialogActions.tsx
new file mode 100644
index 000000000..3aa3a1fe5
--- /dev/null
+++ b/client/ui/frontend/src/components/dialog/DialogActions.tsx
@@ -0,0 +1,13 @@
+import { type ReactNode } from "react";
+import { cn } from "@/lib/cn";
+
+type DialogActionsProps = {
+ children: ReactNode;
+ className?: string;
+};
+
+export const DialogActions = ({ children, className }: DialogActionsProps) => (
+
+ {children}
+
+);
diff --git a/client/ui/frontend/src/components/dialog/DialogDescription.tsx b/client/ui/frontend/src/components/dialog/DialogDescription.tsx
new file mode 100644
index 000000000..12c358043
--- /dev/null
+++ b/client/ui/frontend/src/components/dialog/DialogDescription.tsx
@@ -0,0 +1,26 @@
+import { type ReactNode } from "react";
+import { cn } from "@/lib/cn";
+
+type DialogAlign = "left" | "center" | "right";
+
+const alignClass: Record
= {
+ left: "text-left",
+ center: "text-center",
+ right: "text-right",
+};
+
+type DialogDescriptionProps = {
+ children: ReactNode;
+ className?: string;
+ align?: DialogAlign;
+};
+
+export const DialogDescription = ({
+ children,
+ className,
+ align = "center",
+}: DialogDescriptionProps) => (
+
+ {children}
+
+);
diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx
new file mode 100644
index 000000000..b9dda72a9
--- /dev/null
+++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx
@@ -0,0 +1,35 @@
+import { type ReactNode } from "react";
+import { cn } from "@/lib/cn";
+
+type DialogAlign = "left" | "center" | "right";
+
+const alignClass: Record = {
+ left: "text-left",
+ center: "text-center",
+ right: "text-right",
+};
+
+type DialogHeadingProps = {
+ children: ReactNode;
+ className?: string;
+ align?: DialogAlign;
+ id?: string;
+};
+
+export const DialogHeading = ({
+ children,
+ className,
+ align = "center",
+ id,
+}: DialogHeadingProps) => (
+
+ {children}
+
+);
diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx
new file mode 100644
index 000000000..4ee8c2740
--- /dev/null
+++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx
@@ -0,0 +1,50 @@
+import { useTranslation } from "react-i18next";
+import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
+import { Browser } from "@wailsio/runtime";
+import { Button } from "@/components/buttons/Button";
+import { useStatus } from "@/contexts/StatusContext.tsx";
+
+const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
+
+function openUrl(url: string) {
+ Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
+}
+
+export const DaemonOutdatedOverlay = () => {
+ const { t } = useTranslation();
+ const { isDaemonOutdated } = useStatus();
+
+ if (!isDaemonOutdated) return null;
+
+ return (
+
+
+
+
+
+
+ {t("daemon.outdated.title")}
+
+
{t("daemon.outdated.description")}
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx
new file mode 100644
index 000000000..89c121e21
--- /dev/null
+++ b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx
@@ -0,0 +1,52 @@
+import { useTranslation } from "react-i18next";
+import { AlertCircleIcon, BookText } from "lucide-react";
+import { Browser } from "@wailsio/runtime";
+import { Button } from "@/components/buttons/Button";
+import { useStatus } from "@/contexts/StatusContext.tsx";
+
+const DOCS_URL = "https://docs.netbird.io/how-to/installation";
+
+function openUrl(url: string) {
+ Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
+}
+
+export const DaemonUnavailableOverlay = () => {
+ const { t } = useTranslation();
+ const { isDaemonUnavailable } = useStatus();
+
+ if (!isDaemonUnavailable) return null;
+
+ return (
+
+
+
+
+
+
+ {t("daemon.unavailable.title")}
+
+
+ {t("daemon.unavailable.description")}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/empty-state/EmptyState.tsx b/client/ui/frontend/src/components/empty-state/EmptyState.tsx
new file mode 100644
index 000000000..7d6890a98
--- /dev/null
+++ b/client/ui/frontend/src/components/empty-state/EmptyState.tsx
@@ -0,0 +1,31 @@
+import { type ComponentType } from "react";
+import { type LucideProps } from "lucide-react";
+import { cn } from "@/lib/cn";
+import { SquareIcon } from "@/components/SquareIcon";
+import { isMacOS } from "@/lib/platform";
+
+// Knob to shift the centered main-window content up/down together.
+export const contentVerticalOffset = (): string => (isMacOS() ? "0.6rem" : "-1.4rem");
+export const contentTop = (base: string) => `calc(${base} + ${contentVerticalOffset()})`;
+
+type Props = {
+ icon: ComponentType;
+ title: string;
+ description?: string;
+ className?: string;
+};
+
+export const EmptyState = ({ icon, title, description, className }: Props) => {
+ return (
+
+
+
+
{title}
+ {description &&
{description}
}
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/empty-state/NoResults.tsx b/client/ui/frontend/src/components/empty-state/NoResults.tsx
new file mode 100644
index 000000000..cf0995b37
--- /dev/null
+++ b/client/ui/frontend/src/components/empty-state/NoResults.tsx
@@ -0,0 +1,22 @@
+import { type ComponentType } from "react";
+import { FunnelXIcon, type LucideProps } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { EmptyState } from "./EmptyState";
+
+type Props = {
+ icon?: ComponentType;
+ title?: string;
+ description?: string;
+};
+
+export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) => {
+ const { t } = useTranslation();
+ return (
+
+ );
+};
diff --git a/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx
new file mode 100644
index 000000000..2bcf70376
--- /dev/null
+++ b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx
@@ -0,0 +1,16 @@
+import { GlobeOffIcon } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { EmptyState } from "./EmptyState";
+
+export const NotConnectedState = () => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx
new file mode 100644
index 000000000..2dad80d7a
--- /dev/null
+++ b/client/ui/frontend/src/components/inputs/Input.tsx
@@ -0,0 +1,374 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
+import {
+ forwardRef,
+ type InputHTMLAttributes,
+ type ReactNode,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/cn";
+import { Label } from "@/components/typography/Label";
+
+type InputVariants = VariantProps;
+
+export interface InputProps extends InputHTMLAttributes, InputVariants {
+ label?: string;
+ customPrefix?: ReactNode;
+ customSuffix?: ReactNode;
+ maxWidthClass?: string;
+ icon?: ReactNode;
+ error?: string;
+ warning?: string;
+ prefixClassName?: string;
+ showPasswordToggle?: boolean;
+ copy?: boolean;
+}
+
+const inputVariants = cva("", {
+ variants: {
+ variant: {
+ default: [
+ "border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
+ "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
+ ],
+ darker: [
+ "border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70",
+ "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
+ ],
+ error: [
+ "border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
+ "ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10",
+ ],
+ warning: [
+ "border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
+ "ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10",
+ ],
+ },
+ prefixSuffixVariant: {
+ default: [
+ "border-neutral-200 text-nb-gray-300 dark:border-nb-gray-700 dark:bg-nb-gray-900",
+ ],
+ error: ["border-red-500 text-nb-gray-300 text-red-500 dark:bg-nb-gray-900"],
+ },
+ },
+});
+
+function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number {
+ const stepAttr = el.step === "" ? 1 : Number(el.step);
+ const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
+ const min = el.min === "" ? -Infinity : Number(el.min);
+ const max = el.max === "" ? Infinity : Number(el.max);
+ const current = el.value === "" ? 0 : Number(el.value);
+ let next = (Number.isFinite(current) ? current : 0) + delta * step;
+ if (next < min) next = min;
+ if (next > max) next = max;
+ return next;
+}
+
+function buildInputClassName(
+ opts: Readonly<{
+ variant: InputVariants["variant"];
+ hasCustomPrefix: boolean;
+ hasSuffix: boolean;
+ hasIcon: boolean;
+ readOnly?: boolean;
+ showStepper: boolean;
+ className?: string;
+ }>,
+): string {
+ return cn(
+ inputVariants({ variant: opts.variant }),
+ "flex h-[40px] w-full select-text rounded-md bg-white px-3 py-2 text-sm",
+ "file:border-0 file:bg-transparent file:text-sm file:font-medium",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
+ "disabled:cursor-not-allowed disabled:opacity-40",
+ opts.hasCustomPrefix && "!rounded-l-none !border-l-0",
+ opts.hasSuffix && "!pr-9",
+ opts.hasIcon && "!pl-10",
+ "border",
+ opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350",
+ opts.showStepper &&
+ "!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
+ opts.className,
+ );
+}
+
+function InputAffix({
+ content,
+ error,
+ disabled,
+ className,
+}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) {
+ return (
+
+ {content}
+
+ );
+}
+
+function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) {
+ return (
+
+ {icon}
+
+ );
+}
+
+function InputSuffixSlot({
+ suffix,
+ disabled,
+}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) {
+ return (
+
+ {suffix}
+
+ );
+}
+
+function NumberStepper({
+ error,
+ disabled,
+ onStep,
+}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ );
+}
+
+function FieldMessage({
+ id,
+ error,
+ warning,
+}: Readonly<{ id?: string; error?: string; warning?: string }>) {
+ if (!error && !warning) return null;
+ return (
+
+ {error ?? warning}
+
+ );
+}
+
+export const Input = forwardRef(function Input(
+ {
+ className,
+ type,
+ label,
+ customSuffix,
+ customPrefix,
+ icon,
+ maxWidthClass = "",
+ error,
+ warning,
+ variant = "default",
+ prefixClassName,
+ showPasswordToggle = false,
+ copy = false,
+ id,
+ ...props
+ },
+ ref,
+) {
+ const { t } = useTranslation();
+ const [showPassword, setShowPassword] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const isPasswordType = type === "password";
+ const inputType = isPasswordType && showPassword ? "text" : type;
+ const isNumber = type === "number";
+
+ const reactId = useId();
+ const fallbackId = `input-${reactId}`;
+ const inputId = id ?? (label ? fallbackId : undefined);
+ const messageId = error || warning ? `${inputId ?? fallbackId}-message` : undefined;
+
+ const copyTimer = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (copyTimer.current) clearTimeout(copyTimer.current);
+ },
+ [],
+ );
+
+ const internalRef = useRef(null);
+ const setRefs = (el: HTMLInputElement | null) => {
+ internalRef.current = el;
+ if (typeof ref === "function") ref(el);
+ else if (ref) ref.current = el;
+ };
+
+ const stepBy = (delta: 1 | -1) => {
+ const el = internalRef.current;
+ if (!el || el.disabled || el.readOnly) return;
+ const setter = Object.getOwnPropertyDescriptor(
+ globalThis.HTMLInputElement.prototype,
+ "value",
+ )?.set;
+ const next = computeNextStepValue(el, delta);
+ setter?.call(el, String(next));
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ };
+
+ const passwordToggle =
+ isPasswordType && showPasswordToggle ? (
+
+ ) : null;
+
+ const onCopy = async () => {
+ const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value);
+ if (!text) return;
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ if (copyTimer.current) clearTimeout(copyTimer.current);
+ copyTimer.current = setTimeout(() => setCopied(false), 1500);
+ } catch (e) {
+ console.warn("copy to clipboard failed", e);
+ }
+ };
+
+ const copyToggle = copy ? (
+
+ ) : null;
+
+ const suffix = passwordToggle || copyToggle || customSuffix;
+ const showStepper = isNumber;
+ const warningVariant = warning ? "warning" : variant;
+ const resolvedVariant = error ? "error" : warningVariant;
+
+ const inputClassName = buildInputClassName({
+ variant: resolvedVariant,
+ hasCustomPrefix: !!customPrefix,
+ hasSuffix: !!suffix,
+ hasIcon: !!icon,
+ readOnly: props.readOnly,
+ showStepper,
+ className,
+ });
+
+ return (
+
+ {label &&
}
+
+ {customPrefix && (
+
+ )}
+
+ {icon &&
}
+
+
+
+
+ {suffix && }
+
+
+ {showStepper && (
+
+ )}
+
+
+
+ );
+});
+
+export default Input;
diff --git a/client/ui/frontend/src/components/inputs/SearchInput.tsx b/client/ui/frontend/src/components/inputs/SearchInput.tsx
new file mode 100644
index 000000000..5f46e8fba
--- /dev/null
+++ b/client/ui/frontend/src/components/inputs/SearchInput.tsx
@@ -0,0 +1,59 @@
+import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { SearchIcon } from "lucide-react";
+import { cn } from "@/lib/cn";
+
+type Props = InputHTMLAttributes & {
+ iconSize?: number;
+ shortcut?: ReactNode;
+};
+
+export const SearchInput = forwardRef(function SearchInput(
+ { iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props },
+ ref,
+) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {shortcut && (
+
+ {shortcut}
+
+ )}
+
+ );
+});
diff --git a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx
new file mode 100644
index 000000000..45e3e333a
--- /dev/null
+++ b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx
@@ -0,0 +1,102 @@
+import React from "react";
+import { HelpText } from "@/components/typography/HelpText";
+import { Label } from "@/components/typography/Label";
+import { ToggleSwitch } from "@/components/switches/ToggleSwitch";
+import { cn } from "@/lib/cn";
+
+interface Props {
+ value: boolean;
+ onChange: (value: boolean) => void;
+ helpText?: React.ReactNode;
+ label?: React.ReactNode;
+ children?: React.ReactNode;
+ disabled?: boolean;
+ loading?: boolean;
+ dataCy?: string;
+ className?: string;
+ labelClassName?: string;
+ textWrapperClassName?: string;
+}
+
+export default function FancyToggleSwitch({
+ value,
+ onChange,
+ helpText,
+ label,
+ children,
+ disabled = false,
+ loading = false,
+ dataCy,
+ className,
+ labelClassName,
+ textWrapperClassName = "max-w-lg",
+}: Readonly) {
+ const switchId = React.useId();
+ const descriptionId = React.useId();
+
+ if (loading) {
+ const shimmer =
+ "text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
+ return (
+
+
+
+
+
+
+ {helpText}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {helpText}
+
+
+
+
+
+
+ {children && value ?
{children}
: null}
+
+ );
+}
diff --git a/client/ui/frontend/src/components/switches/SwitchItem.tsx b/client/ui/frontend/src/components/switches/SwitchItem.tsx
new file mode 100644
index 000000000..e23c73fe3
--- /dev/null
+++ b/client/ui/frontend/src/components/switches/SwitchItem.tsx
@@ -0,0 +1,42 @@
+import * as RadioGroup from "@radix-ui/react-radio-group";
+import { motion } from "framer-motion";
+import { type ReactNode } from "react";
+import { cn } from "@/lib/cn";
+import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup";
+
+type Props = {
+ value: string;
+ children: ReactNode;
+ className?: string;
+};
+
+export const SwitchItem = ({ value, children, className }: Props) => {
+ const { value: activeValue, layoutId } = useSwitchItemGroup();
+ const active = activeValue === value;
+
+ return (
+
+ {active && (
+
+ )}
+
+ {children}
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx
new file mode 100644
index 000000000..b4361d530
--- /dev/null
+++ b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx
@@ -0,0 +1,60 @@
+import * as RadioGroup from "@radix-ui/react-radio-group";
+import { createContext, type ReactNode, useContext, useId, useMemo } from "react";
+import { cn } from "@/lib/cn";
+
+type SwitchItemGroupContextValue = {
+ value: string;
+ layoutId: string;
+};
+
+const SwitchItemGroupContext = createContext(null);
+
+export const useSwitchItemGroup = () => {
+ const ctx = useContext(SwitchItemGroupContext);
+ if (!ctx) {
+ throw new Error("SwitchItem must be used inside a SwitchItemGroup");
+ }
+ return ctx;
+};
+
+type Props = {
+ value: string;
+ onChange: (value: string) => void;
+ children: ReactNode;
+ className?: string;
+ disabled?: boolean;
+ "aria-label"?: string;
+ "aria-labelledby"?: string;
+};
+
+export const SwitchItemGroup = ({
+ value,
+ onChange,
+ children,
+ className,
+ disabled = false,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledBy,
+}: Props) => {
+ const layoutId = useId();
+ const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/client/ui/frontend/src/components/switches/ToggleSwitch.tsx b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx
new file mode 100644
index 000000000..2d9f597e6
--- /dev/null
+++ b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+import { cn } from "@/lib/cn";
+
+type SwitchVariants = VariantProps;
+
+const switchVariants = cva("", {
+ variants: {
+ size: {
+ default: "h-[24px] w-[44px]",
+ small: "h-[18px] w-[36px]",
+ large: "h-[36px] w-[66px]",
+ },
+ variant: {
+ default: [
+ "dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
+ "dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
+ "data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
+ "data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300",
+ ],
+ "red-green": [
+ "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
+ "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
+ "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
+ "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300",
+ ],
+ red: [
+ "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
+ "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
+ "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200",
+ "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300",
+ ],
+ },
+ "thumb-size": {
+ default:
+ "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
+ small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0",
+ large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]",
+ },
+ },
+});
+
+const ToggleSwitch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ SwitchVariants & { dataCy?: string }
+>(({ className, size = "default", variant = "default", dataCy, disabled, ...props }, ref) => (
+ {
+ e.stopPropagation();
+ props.onClick?.(e);
+ }}
+ ref={ref}
+ >
+
+
+));
+ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
+
+export { ToggleSwitch };
diff --git a/client/ui/frontend/src/components/typography/HelpText.tsx b/client/ui/frontend/src/components/typography/HelpText.tsx
new file mode 100644
index 000000000..8c52ff714
--- /dev/null
+++ b/client/ui/frontend/src/components/typography/HelpText.tsx
@@ -0,0 +1,24 @@
+import { type ReactNode } from "react";
+import { cn } from "@/lib/cn";
+
+type Props = {
+ children?: ReactNode;
+ margin?: boolean;
+ className?: string;
+ disabled?: boolean;
+};
+
+export const HelpText = ({ children, margin = true, className, disabled = false }: Props) => (
+
+ {children}
+
+);
+
+export default HelpText;
diff --git a/client/ui/frontend/src/components/typography/Label.tsx b/client/ui/frontend/src/components/typography/Label.tsx
new file mode 100644
index 000000000..a8e1a446f
--- /dev/null
+++ b/client/ui/frontend/src/components/typography/Label.tsx
@@ -0,0 +1,42 @@
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { cva, type VariantProps } from "class-variance-authority";
+import { type ComponentPropsWithoutRef, forwardRef, type Ref } from "react";
+import { cn } from "@/lib/cn";
+
+const labelVariants = cva(
+ "mb-1.5 inline-block flex items-center gap-2 text-sm font-medium leading-none tracking-wider peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-nb-gray-100",
+);
+
+type LabelProps = ComponentPropsWithoutRef &
+ VariantProps & {
+ as?: "label" | "div";
+ disabled?: boolean;
+ };
+
+export const Label = forwardRef(function Label(
+ { className, as = "label", disabled = false, children, ...props },
+ ref,
+) {
+ const classes = cn(
+ labelVariants(),
+ className,
+ "select-none transition-all duration-300",
+ disabled && "pointer-events-none opacity-30",
+ );
+
+ if (as === "div") {
+ return (
+ } className={classes}>
+ {children}
+
+ );
+ }
+
+ return (
+ } className={classes} {...props}>
+ {children}
+
+ );
+});
+
+export default Label;
diff --git a/client/ui/frontend/src/contexts/ClientVersionContext.tsx b/client/ui/frontend/src/contexts/ClientVersionContext.tsx
new file mode 100644
index 000000000..c0699a9ee
--- /dev/null
+++ b/client/ui/frontend/src/contexts/ClientVersionContext.tsx
@@ -0,0 +1,114 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Events } from "@wailsio/runtime";
+
+import { Update as UpdateSvc, WindowManager } from "@bindings/services";
+import type { State as UpdateState } from "@bindings/updater/models.js";
+import i18next from "@/lib/i18n";
+import { errorDialog, formatErrorMessage } from "@/lib/errors";
+
+const isDaemonUnavailable = (e: unknown): boolean => {
+ const msg = e instanceof Error ? e.message : String(e);
+ return msg.includes("code = Unavailable");
+};
+
+type ClientVersionContextValue = {
+ updateAvailable: boolean;
+ updateVersion: string | null;
+ enforced: boolean;
+ installing: boolean;
+ triggerUpdate: () => void;
+ updating: boolean;
+};
+
+const EVENT_UPDATE_STATE = "netbird:update:state";
+
+const emptyState: UpdateState = {
+ available: false,
+ version: "",
+ enforced: false,
+ installing: false,
+};
+
+const ClientVersionContext = createContext(null);
+
+export const useClientVersion = () => {
+ const ctx = useContext(ClientVersionContext);
+ if (!ctx) {
+ throw new Error("useClientVersion must be used inside ClientVersionProvider");
+ }
+ return ctx;
+};
+
+export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
+ const [state, setState] = useState(emptyState);
+ const [updating, setUpdating] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ UpdateSvc.GetState()
+ .then((s) => {
+ if (cancelled || !s) return;
+ setState(s);
+ })
+ .catch((e) => {
+ if (cancelled || isDaemonUnavailable(e)) return;
+ void errorDialog({
+ Title: i18next.t("update.error.loadStateTitle"),
+ Message: formatErrorMessage(e),
+ });
+ });
+ const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => {
+ if (ev?.data) setState(ev.data);
+ });
+ return () => {
+ cancelled = true;
+ off?.();
+ };
+ }, []);
+
+ const prevInstallingRef = useRef(false);
+ useEffect(() => {
+ if (state.installing && !prevInstallingRef.current) {
+ WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
+ }
+ prevInstallingRef.current = state.installing;
+ }, [state.installing, state.version]);
+
+ const triggerUpdate = useCallback(() => {
+ setUpdating(true);
+ WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
+ UpdateSvc.Trigger()
+ .catch(async (e) => {
+ if (isDaemonUnavailable(e)) return;
+ WindowManager.CloseInstallProgress().catch(console.error);
+ await errorDialog({
+ Title: i18next.t("update.error.triggerTitle"),
+ Message: formatErrorMessage(e),
+ });
+ })
+ .finally(() => setUpdating(false));
+ }, [state.version]);
+
+ const value = useMemo(
+ () => ({
+ updateAvailable: state.available,
+ updateVersion: state.version || null,
+ enforced: state.enforced,
+ installing: state.installing,
+ triggerUpdate,
+ updating,
+ }),
+ [state, triggerUpdate, updating],
+ );
+
+ return {children};
+};
diff --git a/client/ui/frontend/src/contexts/DebugBundleContext.tsx b/client/ui/frontend/src/contexts/DebugBundleContext.tsx
new file mode 100644
index 000000000..a0a131fbf
--- /dev/null
+++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx
@@ -0,0 +1,314 @@
+import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
+import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
+import type { DebugBundleResult } from "@bindings/services/models.js";
+import i18next from "@/lib/i18n";
+import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
+import { startConnection } from "@/lib/connection.ts";
+
+const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
+const TRACE_LOG_FILE_COUNT = 5;
+const PLAIN_LOG_FILE_COUNT = 1;
+const TRACE_LOG_LEVEL = "trace";
+const DEFAULT_LOG_LEVEL = "info";
+
+export type DebugStage =
+ | { kind: "idle" }
+ | { kind: "preparing-trace" }
+ | { kind: "reconnecting" }
+ | { kind: "capturing"; remainingSec: number; totalSec: number }
+ | { kind: "restoring-level" }
+ | { kind: "bundling" }
+ | { kind: "uploading" }
+ | { kind: "cancelling" }
+ | { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean };
+
+const sleep = (ms: number, signal: AbortSignal) =>
+ new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new DOMException("aborted", "AbortError"));
+ return;
+ }
+ const onAbort = () => {
+ clearTimeout(id);
+ reject(new DOMException("aborted", "AbortError"));
+ };
+ const id = setTimeout(() => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+ signal.addEventListener("abort", onAbort);
+ });
+
+const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
+
+const throwIfAborted = (signal: AbortSignal) => {
+ if (signal.aborted) throw new DOMException("aborted", "AbortError");
+};
+
+const setLogLevelBestEffort = async (level: string) => {
+ try {
+ await DebugSvc.SetLogLevel({ level });
+ } catch (e) {
+ console.warn("[DebugBundle] best-effort set log level failed", e);
+ }
+};
+
+const stopCaptureBestEffort = async () => {
+ try {
+ await DebugSvc.StopBundleCapture();
+ } catch (e) {
+ console.warn("[DebugBundle] best-effort stop packet capture failed", e);
+ }
+};
+
+type LevelState = { original: string; raised: boolean };
+type CaptureState = { started: boolean };
+
+type BundleOptions = {
+ trace: boolean;
+ capture: boolean;
+ capturePackets: boolean;
+ hasWindow: boolean;
+ totalSec: number;
+ uploadUrl: string;
+ anonymize: boolean;
+ systemInfo: boolean;
+};
+
+const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
+ try {
+ // Mirror the CLI's safety margin: window + 30s, server caps at 10m.
+ await DebugSvc.StartBundleCapture(totalSec + 30);
+ pcap.started = true;
+ } catch (e) {
+ console.warn("[DebugBundle] start packet capture failed", e);
+ }
+};
+
+const cleanupBestEffort = async (pcap: CaptureState, level: LevelState, restoreLevel: boolean) => {
+ if (pcap.started) {
+ await stopCaptureBestEffort();
+ pcap.started = false;
+ }
+ if (restoreLevel && level.raised) {
+ await setLogLevelBestEffort(level.original);
+ }
+};
+
+const raiseToTrace = async (
+ signal: AbortSignal,
+ level: LevelState,
+ setStage: (s: DebugStage) => void,
+) => {
+ setStage({ kind: "preparing-trace" });
+ try {
+ const cur = await DebugSvc.GetLogLevel();
+ if (cur?.level) level.original = cur.level;
+ } catch (e) {
+ console.warn("[DebugBundle] read current log level failed", e);
+ }
+ throwIfAborted(signal);
+ await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL });
+ level.raised = true;
+};
+
+const cycleConnection = async (signal: AbortSignal, setStage: (s: DebugStage) => void) => {
+ throwIfAborted(signal);
+ setStage({ kind: "reconnecting" });
+ try {
+ await ConnectionSvc.Down();
+ } catch (e) {
+ console.warn("[DebugBundle] disconnect before capture failed", e);
+ }
+ throwIfAborted(signal);
+ await startConnection(undefined, signal);
+};
+
+const restoreLogLevel = async (level: LevelState, setStage: (s: DebugStage) => void) => {
+ setStage({ kind: "restoring-level" });
+ try {
+ await DebugSvc.SetLogLevel({ level: level.original });
+ level.raised = false;
+ } catch (e) {
+ console.warn("[DebugBundle] restore log level failed", e);
+ }
+};
+
+const waitCaptureWindow = async (
+ signal: AbortSignal,
+ setStage: (s: DebugStage) => void,
+ totalSec: number,
+) => {
+ for (let remaining = totalSec; remaining > 0; remaining--) {
+ setStage({ kind: "capturing", remainingSec: remaining, totalSec });
+ await sleep(1000, signal);
+ }
+};
+
+const runBundleFlow = async (
+ signal: AbortSignal,
+ opts: BundleOptions,
+ level: LevelState,
+ pcap: CaptureState,
+ setStage: (s: DebugStage) => void,
+ setLastBundlePath: (p: string) => void,
+) => {
+ if (opts.trace) {
+ await raiseToTrace(signal, level, setStage);
+ }
+ throwIfAborted(signal);
+
+ if (opts.capture) {
+ await cycleConnection(signal, setStage);
+ }
+ throwIfAborted(signal);
+
+ if (opts.hasWindow && opts.capturePackets) {
+ await startCaptureBestEffort(opts.totalSec, pcap);
+ }
+ throwIfAborted(signal);
+
+ if (opts.hasWindow) {
+ await waitCaptureWindow(signal, setStage, opts.totalSec);
+ }
+
+ if (pcap.started) {
+ await stopCaptureBestEffort();
+ pcap.started = false;
+ }
+
+ if (level.raised) {
+ await restoreLogLevel(level, setStage);
+ }
+
+ throwIfAborted(signal);
+ setStage({ kind: "bundling" });
+ const logFileCount = opts.trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT;
+
+ if (opts.uploadUrl) setStage({ kind: "uploading" });
+ const result = await DebugSvc.Bundle({
+ anonymize: opts.anonymize,
+ systemInfo: opts.systemInfo,
+ uploadUrl: opts.uploadUrl,
+ logFileCount,
+ });
+ throwIfAborted(signal);
+ if (result.path) setLastBundlePath(result.path);
+ setStage({ kind: "done", result, uploadAttempted: Boolean(opts.uploadUrl) });
+};
+
+const useDebugBundle = () => {
+ const [anonymize, setAnonymize] = useState(false);
+ const [systemInfo, setSystemInfo] = useState(true);
+ const [upload, setUpload] = useState(true);
+ const [trace, setTrace] = useState(true);
+ const [capture, setCapture] = useState(false);
+ const [traceMinutes, setTraceMinutes] = useState(1);
+ const [capturePackets, setCapturePackets] = useState(true);
+ const [stage, setStage] = useState({ kind: "idle" });
+ const [lastBundlePath, setLastBundlePath] = useState("");
+ const abortRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ abortRef.current?.abort();
+ };
+ }, []);
+
+ const isRunning = stage.kind !== "idle" && stage.kind !== "done";
+
+ const reset = () => setStage({ kind: "idle" });
+
+ const cancel = () => {
+ if (!abortRef.current || abortRef.current.signal.aborted) return;
+ abortRef.current.abort();
+ setStage({ kind: "cancelling" });
+ };
+
+ const run = async () => {
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+ const signal = ctrl.signal;
+
+ const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
+ const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false };
+ const pcap: CaptureState = { started: false };
+ const opts: BundleOptions = {
+ trace,
+ capture,
+ capturePackets,
+ hasWindow: capture && totalSec > 0,
+ totalSec,
+ uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
+ anonymize,
+ systemInfo,
+ };
+
+ try {
+ await runBundleFlow(signal, opts, level, pcap, setStage, setLastBundlePath);
+ } catch (e) {
+ if (isAbort(e)) {
+ setStage({ kind: "cancelling" });
+ await cleanupBestEffort(pcap, level, true);
+ setStage({ kind: "idle" });
+ return;
+ }
+ await cleanupBestEffort(pcap, level, false);
+ setStage({ kind: "idle" });
+ await errorDialog({
+ Title: i18next.t("settings.error.debugBundleTitle"),
+ Message: formatErrorMessage(e),
+ });
+ } finally {
+ if (abortRef.current === ctrl) abortRef.current = null;
+ }
+ };
+
+ const openBundleDir = () => {
+ if (!lastBundlePath) return;
+ DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) =>
+ console.error("[DebugBundleContext] reveal failed", err),
+ );
+ };
+
+ return {
+ anonymize,
+ setAnonymize,
+ systemInfo,
+ setSystemInfo,
+ upload,
+ setUpload,
+ trace,
+ setTrace,
+ capture,
+ setCapture,
+ traceMinutes,
+ setTraceMinutes,
+ capturePackets,
+ setCapturePackets,
+ stage,
+ isRunning,
+ lastBundlePath,
+ run,
+ cancel,
+ reset,
+ openBundleDir,
+ };
+};
+
+export type DebugBundleContextValue = ReturnType;
+
+const DebugBundleContext = createContext(null);
+
+export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
+ const value = useDebugBundle();
+ return {children};
+};
+
+export const useDebugBundleContext = () => {
+ const ctx = useContext(DebugBundleContext);
+ if (!ctx) {
+ throw new Error("useDebugBundleContext must be used inside DebugBundleProvider");
+ }
+ return ctx;
+};
diff --git a/client/ui/frontend/src/contexts/DialogContext.tsx b/client/ui/frontend/src/contexts/DialogContext.tsx
new file mode 100644
index 000000000..8a52e0dd0
--- /dev/null
+++ b/client/ui/frontend/src/contexts/DialogContext.tsx
@@ -0,0 +1,68 @@
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { ConfirmModal } from "@/components/dialog/ConfirmModal";
+
+export type ConfirmOptions = {
+ title: ReactNode;
+ description: ReactNode;
+ confirmLabel: string;
+ cancelLabel?: string;
+ danger?: boolean;
+};
+
+type DialogContextValue = {
+ confirm: (options: ConfirmOptions) => Promise;
+};
+
+const DialogContext = createContext(null);
+
+export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
+ const [open, setOpen] = useState(false);
+ const [options, setOptions] = useState(null);
+ const resolverRef = useRef<((result: boolean) => void) | null>(null);
+
+ const confirm = useCallback((opts: ConfirmOptions) => {
+ setOptions(opts);
+ setOpen(true);
+ return new Promise((resolve) => {
+ resolverRef.current = resolve;
+ });
+ }, []);
+
+ const settle = (result: boolean) => {
+ resolverRef.current?.(result);
+ resolverRef.current = null;
+ setOpen(false);
+ };
+
+ const value = useMemo(() => ({ confirm }), [confirm]);
+
+ return (
+
+ {children}
+ settle(true)}
+ onCancel={() => settle(false)}
+ />
+
+ );
+}
+
+export const useConfirm = () => {
+ const ctx = useContext(DialogContext);
+ if (!ctx) throw new Error("useConfirm must be used within a DialogProvider");
+ return ctx.confirm;
+};
diff --git a/client/ui/frontend/src/contexts/NavSectionContext.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx
new file mode 100644
index 000000000..c08a3c824
--- /dev/null
+++ b/client/ui/frontend/src/contexts/NavSectionContext.tsx
@@ -0,0 +1,24 @@
+import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
+
+export type NavSection = "peers" | "networks";
+
+type NavSectionContextValue = {
+ section: NavSection;
+ setSection: (s: NavSection) => void;
+};
+
+const NavSectionContext = createContext(null);
+
+export const useNavSection = (): NavSectionContextValue => {
+ const ctx = useContext(NavSectionContext);
+ if (!ctx) {
+ throw new Error("useNavSection must be used inside NavSectionProvider");
+ }
+ return ctx;
+};
+
+export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
+ const [section, setSection] = useState("peers");
+ const value = useMemo(() => ({ section, setSection }), [section]);
+ return {children};
+};
diff --git a/client/ui/frontend/src/contexts/NetworksContext.tsx b/client/ui/frontend/src/contexts/NetworksContext.tsx
new file mode 100644
index 000000000..ef7231700
--- /dev/null
+++ b/client/ui/frontend/src/contexts/NetworksContext.tsx
@@ -0,0 +1,222 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Networks as NetworksSvc } from "@bindings/services";
+import type { Network } from "@bindings/services/models.js";
+import { useStatus } from "@/contexts/StatusContext";
+
+// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node.
+// The daemon may merge a v4+v6 pair into a single comma-joined range string.
+export const isExitNode = (range: string): boolean =>
+ range.split(",").some((part) => {
+ const trimmed = part.trim();
+ return trimmed === "0.0.0.0/0" || trimmed === "::/0";
+ });
+
+type NetworksContextValue = {
+ routes: Network[];
+ networkRoutes: Network[];
+ exitNodes: Network[];
+ activeExitNode: Network | null;
+ refresh: () => Promise;
+ toggleNetwork: (id: string, selected: boolean) => Promise;
+ toggleExitNode: (id: string, selected: boolean) => Promise;
+ setNetworksSelected: (ids: string[], selected: boolean) => Promise;
+};
+
+const NetworksContext = createContext(null);
+
+export const useNetworks = () => {
+ const ctx = useContext(NetworksContext);
+ if (!ctx) {
+ throw new Error("useNetworks must be used inside NetworksProvider");
+ }
+ return ctx;
+};
+
+export const NetworksProvider = ({ children }: { children: ReactNode }) => {
+ const { status } = useStatus();
+ const [routes, setRoutes] = useState([]);
+ const [pending, setPending] = useState