Compare commits
25 Commits
main
...
nmap/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c7dfd7f1d | ||
|
|
a59d979a21 | ||
|
|
63dd4df3f3 | ||
|
|
396ac3619c | ||
|
|
b1347ad1ff | ||
|
|
db76f33b71 | ||
|
|
eab0826b4e | ||
|
|
7048b87931 | ||
|
|
596952265d | ||
|
|
21cfec93d4 | ||
|
|
98818e3095 | ||
|
|
5d5c2d9f95 | ||
|
|
13e41e432c | ||
|
|
efa6a3f502 | ||
|
|
5fbcdeceac | ||
|
|
3a1bbeba90 | ||
|
|
728057ef15 | ||
|
|
582cd70086 | ||
|
|
9bbbafaf69 | ||
|
|
672b057aa0 | ||
|
|
b9a0186200 | ||
|
|
9083bdb977 | ||
|
|
b194af48b8 | ||
|
|
4543780ef0 | ||
|
|
2de0283971 |
@@ -1,18 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -6,6 +6,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
|||||||
iptables=1.8.9-2 \
|
iptables=1.8.9-2 \
|
||||||
libgl1-mesa-dev=22.3.6-1+deb12u1 \
|
libgl1-mesa-dev=22.3.6-1+deb12u1 \
|
||||||
xorg-dev=1:7.7+23 \
|
xorg-dev=1:7.7+23 \
|
||||||
|
libayatana-appindicator3-dev=0.5.92-1 \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& go install -v golang.org/x/tools/gopls@latest
|
&& go install -v golang.org/x/tools/gopls@latest
|
||||||
|
|||||||
98
.github/workflows/frontend-ui.yml
vendored
@@ -1,98 +0,0 @@
|
|||||||
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
|
|
||||||
10
.github/workflows/golang-test-darwin.yml
vendored
@@ -45,15 +45,7 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
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)
|
||||||
# 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
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||||
|
|||||||
17
.github/workflows/golang-test-linux.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
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
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||||
|
|
||||||
- name: Install 32-bit libpcap
|
- name: Install 32-bit libpcap
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
@@ -145,7 +145,7 @@ jobs:
|
|||||||
${{ runner.os }}-gotest-cache-
|
${{ runner.os }}-gotest-cache-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
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
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||||
|
|
||||||
- name: Install 32-bit libpcap
|
- name: Install 32-bit libpcap
|
||||||
if: matrix.arch == '386'
|
if: matrix.arch == '386'
|
||||||
@@ -158,15 +158,7 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
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 ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged)
|
||||||
# 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
|
- name: Upload coverage reports to Codecov
|
||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
@@ -176,6 +168,7 @@ jobs:
|
|||||||
slug: netbirdio/netbird
|
slug: netbirdio/netbird
|
||||||
flags: unit,client
|
flags: unit,client
|
||||||
|
|
||||||
|
|
||||||
test_client_on_docker:
|
test_client_on_docker:
|
||||||
name: "Client (Docker) / Unit"
|
name: "Client (Docker) / Unit"
|
||||||
needs: [build-cache]
|
needs: [build-cache]
|
||||||
@@ -236,7 +229,7 @@ jobs:
|
|||||||
sh -c ' \
|
sh -c ' \
|
||||||
apk update; apk add --no-cache \
|
apk update; apk add --no-cache \
|
||||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
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 -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)
|
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)
|
||||||
'
|
'
|
||||||
|
|
||||||
test_relay:
|
test_relay:
|
||||||
|
|||||||
9
.github/workflows/golang-test-windows.yml
vendored
@@ -65,15 +65,8 @@ 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 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
|
- 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
|
- 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: |
|
run: |
|
||||||
$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' }
|
$packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
|
||||||
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
$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"
|
$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
|
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
||||||
|
|||||||
25
.github/workflows/golangci-lint.yml
vendored
@@ -22,15 +22,7 @@ jobs:
|
|||||||
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals,flate,recordin,unparseable
|
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
|
skip: go.mod,go.sum,**/proxy/web/**
|
||||||
# (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:
|
golangci:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -45,7 +37,7 @@ jobs:
|
|||||||
display_name: Linux
|
display_name: Linux
|
||||||
name: ${{ matrix.display_name }}
|
name: ${{ matrix.display_name }}
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
@@ -62,16 +54,7 @@ jobs:
|
|||||||
cache: false
|
cache: false
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
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
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-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
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||||
with:
|
with:
|
||||||
@@ -79,4 +62,4 @@ jobs:
|
|||||||
skip-cache: true
|
skip-cache: true
|
||||||
skip-save-cache: true
|
skip-save-cache: true
|
||||||
cache-invalidation-interval: 0
|
cache-invalidation-interval: 0
|
||||||
args: --timeout=12m
|
args: --timeout=20m
|
||||||
|
|||||||
86
.github/workflows/release.yml
vendored
@@ -9,7 +9,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SIGN_PIPE_VER: "v0.1.8"
|
SIGN_PIPE_VER: "v0.1.6"
|
||||||
GORELEASER_VER: "v2.16.0"
|
GORELEASER_VER: "v2.16.0"
|
||||||
PRODUCT_NAME: "NetBird"
|
PRODUCT_NAME: "NetBird"
|
||||||
COPYRIGHT: "NetBird GmbH"
|
COPYRIGHT: "NetBird GmbH"
|
||||||
@@ -216,9 +216,9 @@ jobs:
|
|||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||||
- name: Generate windows syso amd64
|
- name: Generate windows syso amd64
|
||||||
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
|
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
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
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
|
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
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
@@ -293,11 +293,8 @@ jobs:
|
|||||||
${{ steps.goreleaser.outputs.artifacts }}
|
${{ steps.goreleaser.outputs.artifacts }}
|
||||||
JSON
|
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 < <(
|
mapfile -t src_images < <(
|
||||||
jq -r '.[] | select(.type == "Docker Image") | .name | select(startswith("ghcr.io/") and endswith("-amd64"))' /tmp/goreleaser-artifacts.json
|
jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name | select(startswith("ghcr.io/"))' /tmp/goreleaser-artifacts.json
|
||||||
)
|
)
|
||||||
|
|
||||||
for src in "${src_images[@]}"; do
|
for src in "${src_images[@]}"; do
|
||||||
@@ -397,18 +394,8 @@ jobs:
|
|||||||
- name: check git status
|
- name: check git status
|
||||||
run: git --no-pager diff --exit-code
|
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
|
- name: Install dependencies
|
||||||
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
|
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
- name: Decode GPG signing key
|
- name: Decode GPG signing key
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
@@ -427,16 +414,10 @@ jobs:
|
|||||||
echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
|
echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
|
||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
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
|
- name: Generate windows syso amd64
|
||||||
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
|
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
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
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
|
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
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
@@ -505,20 +486,6 @@ jobs:
|
|||||||
run: go mod tidy
|
run: go mod tidy
|
||||||
- name: check git status
|
- name: check git status
|
||||||
run: git --no-pager diff --exit-code
|
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
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
@@ -606,6 +573,23 @@ jobs:
|
|||||||
- name: Move wintun.dll into dist
|
- name: Move wintun.dll into dist
|
||||||
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
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
|
- name: Download EnVar plugin for NSIS
|
||||||
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
@@ -628,28 +612,6 @@ jobs:
|
|||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
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
|
- name: Build NSIS installer
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
|
|||||||
2
.github/workflows/wasm-build-validation.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
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
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
- name: Install golangci-lint
|
- name: Install golangci-lint
|
||||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||||
with:
|
with:
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
.claude
|
|
||||||
.idea
|
.idea
|
||||||
.run
|
.run
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
@@ -114,16 +114,6 @@ linters:
|
|||||||
- linters:
|
- linters:
|
||||||
- staticcheck
|
- staticcheck
|
||||||
text: "QF1012"
|
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:
|
paths:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
|
|||||||
@@ -212,7 +212,6 @@ nfpms:
|
|||||||
description: Netbird client.
|
description: Netbird client.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
license: BSD-3-Clause
|
license: BSD-3-Clause
|
||||||
vendor: NetBird
|
|
||||||
id: netbird_deb
|
id: netbird_deb
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
builds:
|
builds:
|
||||||
@@ -227,7 +226,6 @@ nfpms:
|
|||||||
description: Netbird client.
|
description: Netbird client.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
license: BSD-3-Clause
|
license: BSD-3-Clause
|
||||||
vendor: NetBird
|
|
||||||
id: netbird_rpm
|
id: netbird_rpm
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
builds:
|
builds:
|
||||||
|
|||||||
@@ -2,15 +2,6 @@ version: 2
|
|||||||
env:
|
env:
|
||||||
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
||||||
project_name: netbird-ui
|
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:
|
builds:
|
||||||
- id: netbird-ui
|
- id: netbird-ui
|
||||||
dir: client/ui
|
dir: client/ui
|
||||||
@@ -71,8 +62,6 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
license: BSD-3-Clause
|
|
||||||
vendor: NetBird
|
|
||||||
id: netbird_ui_deb
|
id: netbird_ui_deb
|
||||||
package_name: netbird-ui
|
package_name: netbird-ui
|
||||||
builds:
|
builds:
|
||||||
@@ -82,9 +71,9 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/linux/netbird.desktop
|
- src: client/ui/build/netbird.desktop
|
||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/assets/netbird.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
@@ -92,8 +81,6 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
license: BSD-3-Clause
|
|
||||||
vendor: NetBird
|
|
||||||
id: netbird_ui_rpm
|
id: netbird_ui_rpm
|
||||||
package_name: netbird-ui
|
package_name: netbird-ui
|
||||||
builds:
|
builds:
|
||||||
@@ -103,13 +90,12 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/linux/netbird.desktop
|
- src: client/ui/build/netbird.desktop
|
||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/assets/netbird.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
|
|
||||||
rpm:
|
rpm:
|
||||||
signature:
|
signature:
|
||||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
project_name: netbird-ui
|
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:
|
builds:
|
||||||
- id: netbird-ui-darwin
|
- id: netbird-ui-darwin
|
||||||
dir: client/ui
|
dir: client/ui
|
||||||
@@ -29,6 +20,8 @@ builds:
|
|||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
- -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 }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
tags:
|
||||||
|
- load_wgnt_from_rsrc
|
||||||
|
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
- id: netbird-ui-darwin
|
- id: netbird-ui-darwin
|
||||||
|
|||||||
@@ -79,21 +79,13 @@ dependencies are installed. Here is a short guide on how that can be done.
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
#### Go 1.25
|
#### Go 1.21
|
||||||
|
|
||||||
Follow the installation guide from https://go.dev/
|
Follow the installation guide from https://go.dev/
|
||||||
|
|
||||||
#### UI client - Wails v3 + React
|
#### UI client - Fyne toolkit
|
||||||
|
|
||||||
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:
|
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
|
||||||
|
|
||||||
- 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
|
#### 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.
|
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.
|
||||||
@@ -222,39 +214,6 @@ To start NetBird the client in the foreground:
|
|||||||
sudo ./client up --log-level debug --log-file console
|
sudo ./client up --log-level debug --log-file console
|
||||||
```
|
```
|
||||||
> On Windows use a powershell with administrator privileges
|
> 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
|
#### Signal service
|
||||||
|
|
||||||
To start NetBird's signal, execute:
|
To start NetBird's signal, execute:
|
||||||
@@ -292,10 +251,10 @@ Create dist directory
|
|||||||
mkdir -p dist/netbird_windows_amd64
|
mkdir -p dist/netbird_windows_amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
UI client (built with Wails v3 — see the [UI client](#ui-client) section above)
|
UI client
|
||||||
```shell
|
```shell
|
||||||
(cd client/ui && CGO_ENABLED=1 task windows:build)
|
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 client/ui/bin/netbird-ui.exe ./dist/netbird_windows_amd64/
|
mv netbird-ui.exe ./dist/netbird_windows_amd64/
|
||||||
```
|
```
|
||||||
|
|
||||||
Client
|
Client
|
||||||
@@ -332,6 +291,8 @@ go test -exec sudo ./...
|
|||||||
```
|
```
|
||||||
> On Windows use a powershell with administrator privileges
|
> 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
|
## Checklist before submitting a PR
|
||||||
As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
|
As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
|
||||||
- Keep functions as simple as possible, with a single purpose
|
- Keep functions as simple as possible, with a single purpose
|
||||||
|
|||||||
@@ -22,19 +22,11 @@ import (
|
|||||||
"github.com/netbirdio/netbird/util"
|
"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() {
|
func init() {
|
||||||
loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
|
loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
|
||||||
loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc)
|
loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc)
|
||||||
loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
|
loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
|
||||||
loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location")
|
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{
|
var loginCmd = &cobra.Command{
|
||||||
@@ -69,16 +61,6 @@ var loginCmd = &cobra.Command{
|
|||||||
return err
|
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
|
// workaround to run without service
|
||||||
if util.FindFirstLogPath(logFiles) == "" {
|
if util.FindFirstLogPath(logFiles) == "" {
|
||||||
if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil {
|
if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil {
|
||||||
@@ -170,65 +152,6 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
|||||||
return nil
|
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) {
|
func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) (*profilemanager.Profile, error) {
|
||||||
// switch profile if provided
|
// switch profile if provided
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -116,11 +115,6 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
// manager only knows the active profile ID, not its display name.
|
// manager only knows the active profile ID, not its display name.
|
||||||
profName := getActiveProfileName(ctx)
|
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{
|
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||||
Anonymize: anonymizeFlag,
|
Anonymize: anonymizeFlag,
|
||||||
DaemonVersion: resp.GetDaemonVersion(),
|
DaemonVersion: resp.GetDaemonVersion(),
|
||||||
@@ -131,7 +125,6 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
IPsFilter: ipsFilterMap,
|
IPsFilter: ipsFilterMap,
|
||||||
ConnectionTypeFilter: connectionTypeFilter,
|
ConnectionTypeFilter: connectionTypeFilter,
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
SessionExpiresAt: sessionExpiresAt,
|
|
||||||
})
|
})
|
||||||
var statusOutputString string
|
var statusOutputString string
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ func (c *Client) Status() (peer.FullStatus, error) {
|
|||||||
if connect != nil {
|
if connect != nil {
|
||||||
engine := connect.Engine()
|
engine := connect.Engine()
|
||||||
if engine != nil {
|
if engine != nil {
|
||||||
_ = engine.RunHealthProbes(context.Background(), false)
|
_ = engine.RunHealthProbes(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,15 +17,12 @@ import (
|
|||||||
|
|
||||||
type KernelConfigurer struct {
|
type KernelConfigurer struct {
|
||||||
deviceName string
|
deviceName string
|
||||||
statsCache *statsCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKernelConfigurer(deviceName string) *KernelConfigurer {
|
func NewKernelConfigurer(deviceName string) *KernelConfigurer {
|
||||||
c := &KernelConfigurer{
|
return &KernelConfigurer{
|
||||||
deviceName: deviceName,
|
deviceName: deviceName,
|
||||||
}
|
}
|
||||||
c.statsCache = newStatsCache(statsCacheTTL, c.fetchStats)
|
|
||||||
return c
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error {
|
func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error {
|
||||||
@@ -249,6 +246,12 @@ 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)
|
return wg.ConfigureDevice(c.deviceName, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,14 +300,6 @@ func (c *KernelConfigurer) FullStats() (*Stats, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *KernelConfigurer) GetStats() (map[string]WGStats, 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)
|
stats := make(map[string]WGStats)
|
||||||
wg, err := wgctrl.New()
|
wg, err := wgctrl.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -331,3 +326,7 @@ func (c *KernelConfigurer) fetchStats() (map[string]WGStats, error) {
|
|||||||
}
|
}
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *KernelConfigurer) LastActivities() map[string]monotime.Time {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -40,7 +40,6 @@ type WGUSPConfigurer struct {
|
|||||||
device *device.Device
|
device *device.Device
|
||||||
deviceName string
|
deviceName string
|
||||||
activityRecorder *bind.ActivityRecorder
|
activityRecorder *bind.ActivityRecorder
|
||||||
statsCache *statsCache
|
|
||||||
|
|
||||||
uapiListener net.Listener
|
uapiListener net.Listener
|
||||||
}
|
}
|
||||||
@@ -51,19 +50,16 @@ func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder
|
|||||||
deviceName: deviceName,
|
deviceName: deviceName,
|
||||||
activityRecorder: activityRecorder,
|
activityRecorder: activityRecorder,
|
||||||
}
|
}
|
||||||
wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats)
|
|
||||||
wgCfg.startUAPI()
|
wgCfg.startUAPI()
|
||||||
return wgCfg
|
return wgCfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer {
|
func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer {
|
||||||
wgCfg := &WGUSPConfigurer{
|
return &WGUSPConfigurer{
|
||||||
device: device,
|
device: device,
|
||||||
deviceName: deviceName,
|
deviceName: deviceName,
|
||||||
activityRecorder: activityRecorder,
|
activityRecorder: activityRecorder,
|
||||||
}
|
}
|
||||||
wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats)
|
|
||||||
return wgCfg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error {
|
func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error {
|
||||||
@@ -352,10 +348,6 @@ func (t *WGUSPConfigurer) Close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *WGUSPConfigurer) GetStats() (map[string]WGStats, error) {
|
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()
|
ipc, err := t.device.IpcGet()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ipc get: %w", err)
|
return nil, fmt.Errorf("ipc get: %w", err)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
!define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls."
|
!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 INSTALLER_NAME "netbird-installer.exe"
|
||||||
!define MAIN_APP_EXE "Netbird"
|
!define MAIN_APP_EXE "Netbird"
|
||||||
!define ICON "ui\\build\\windows\\icon.ico"
|
!define ICON "ui\\assets\\netbird.ico"
|
||||||
!define BANNER "ui\\build\\banner.bmp"
|
!define BANNER "ui\\build\\banner.bmp"
|
||||||
!define LICENSE_DATA "..\\LICENSE"
|
!define LICENSE_DATA "..\\LICENSE"
|
||||||
|
|
||||||
@@ -79,6 +79,8 @@ ShowInstDetails Show
|
|||||||
|
|
||||||
!insertmacro MUI_PAGE_DIRECTORY
|
!insertmacro MUI_PAGE_DIRECTORY
|
||||||
|
|
||||||
|
Page custom AutostartPage AutostartPageLeave
|
||||||
|
|
||||||
!insertmacro MUI_PAGE_INSTFILES
|
!insertmacro MUI_PAGE_INSTFILES
|
||||||
|
|
||||||
!insertmacro MUI_PAGE_FINISH
|
!insertmacro MUI_PAGE_FINISH
|
||||||
@@ -95,12 +97,40 @@ UninstPage custom un.DeleteDataPage un.DeleteDataPageLeave
|
|||||||
|
|
||||||
!insertmacro MUI_LANGUAGE "English"
|
!insertmacro MUI_LANGUAGE "English"
|
||||||
|
|
||||||
|
; Variables for autostart option
|
||||||
|
Var AutostartCheckbox
|
||||||
|
Var AutostartEnabled
|
||||||
|
|
||||||
; Variables for uninstall data deletion option
|
; Variables for uninstall data deletion option
|
||||||
Var DeleteDataCheckbox
|
Var DeleteDataCheckbox
|
||||||
Var DeleteDataEnabled
|
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 to create the uninstall data deletion page
|
||||||
Function un.DeleteDataPage
|
Function un.DeleteDataPage
|
||||||
!insertmacro MUI_HEADER_TEXT "Uninstall Options" "Choose whether to delete ${APP_NAME} data."
|
!insertmacro MUI_HEADER_TEXT "Uninstall Options" "Choose whether to delete ${APP_NAME} data."
|
||||||
@@ -171,6 +201,8 @@ Pop $0
|
|||||||
|
|
||||||
Function .onInit
|
Function .onInit
|
||||||
StrCpy $INSTDIR "${INSTALL_DIR}"
|
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
|
; 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.
|
; in the 32-bit view. Fall back to it so upgrades still find them.
|
||||||
@@ -228,12 +260,17 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
|
|||||||
|
|
||||||
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
||||||
|
|
||||||
; Autostart is owned by the UI's per-user setting (HKCU\...\Run via Wails),
|
; Create autostart registry entry based on checkbox
|
||||||
; not the installer. Drop the machine-wide entry older installers wrote so the
|
DetailPrint "Autostart enabled: $AutostartEnabled"
|
||||||
; toggle is the single source of truth. HKCU is left untouched -- it may hold
|
${If} $AutostartEnabled == "1"
|
||||||
; the user's own toggle state, which must survive upgrades.
|
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
||||||
DetailPrint "Removing installer-managed autostart registry entry if present..."
|
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
${Else}
|
||||||
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||||
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
DetailPrint "Autostart not enabled by user"
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
EnVar::SetHKLM
|
EnVar::SetHKLM
|
||||||
EnVar::AddValueEx "path" "$INSTDIR"
|
EnVar::AddValueEx "path" "$INSTDIR"
|
||||||
@@ -243,43 +280,6 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
|||||||
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
||||||
SectionEnd
|
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
|
Section -Post
|
||||||
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
|
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
|
||||||
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
|
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
|
||||||
@@ -299,14 +299,11 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
|
|||||||
DetailPrint "Terminating Netbird UI process..."
|
DetailPrint "Terminating Netbird UI process..."
|
||||||
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
||||||
|
|
||||||
; Remove autostart registry entries
|
; Remove autostart registry entry
|
||||||
DetailPrint "Removing autostart registry entries if they exist..."
|
DetailPrint "Removing autostart registry entry if exists..."
|
||||||
; Legacy machine-wide entry written by older installers.
|
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
; Per-user entry the UI toggle writes via Wails (value name is the lowercase
|
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||||
; app-name slug). Uninstall removes the app, so drop it too.
|
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "netbird"
|
|
||||||
|
|
||||||
; Handle data deletion based on checkbox
|
; Handle data deletion based on checkbox
|
||||||
DetailPrint "Checking if user requested data deletion..."
|
DetailPrint "Checking if user requested data deletion..."
|
||||||
@@ -329,9 +326,9 @@ DetailPrint "Deleting application files..."
|
|||||||
Delete "$INSTDIR\${UI_APP_EXE}"
|
Delete "$INSTDIR\${UI_APP_EXE}"
|
||||||
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
||||||
Delete "$INSTDIR\wintun.dll"
|
Delete "$INSTDIR\wintun.dll"
|
||||||
# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove
|
!if ${ARCH} == "amd64"
|
||||||
# any leftover copy on uninstall so old upgrades don't leave it behind.
|
|
||||||
Delete "$INSTDIR\opengl32.dll"
|
Delete "$INSTDIR\opengl32.dll"
|
||||||
|
!endif
|
||||||
DetailPrint "Removing application directory..."
|
DetailPrint "Removing application directory..."
|
||||||
RmDir /r "$INSTDIR"
|
RmDir /r "$INSTDIR"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,25 +21,6 @@ import (
|
|||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
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 <setup-key>` 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
|
// Auth manages authentication operations with the management server
|
||||||
// It maintains a long-lived connection and automatically handles reconnection with backoff
|
// It maintains a long-lived connection and automatically handles reconnection with backoff
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
@@ -204,15 +184,6 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
|||||||
log.Debugf("peer registration required")
|
log.Debugf("peer registration required")
|
||||||
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
|
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
|
||||||
if err != nil {
|
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)
|
isAuthError = isPermissionDenied(err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -502,16 +473,3 @@ func isLoginNeeded(err error) bool {
|
|||||||
func isRegistrationNeeded(err error) bool {
|
func isRegistrationNeeded(err error) bool {
|
||||||
return isPermissionDenied(err)
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
// 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,519 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
@@ -277,15 +277,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
||||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
||||||
if err != nil {
|
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))
|
return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err))
|
||||||
}
|
}
|
||||||
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
|
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
|
||||||
@@ -424,10 +415,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
return wrapErr(err)
|
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())
|
log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress())
|
||||||
state.Set(StatusConnected)
|
state.Set(StatusConnected)
|
||||||
|
|
||||||
@@ -464,10 +451,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.statusRecorder.ClientStart()
|
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))
|
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
||||||
|
|||||||
@@ -229,16 +229,9 @@ scutil_dns.txt (macOS only):
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
clientLogFile = "client.log"
|
clientLogFile = "client.log"
|
||||||
uiLogFile = "gui-client.log"
|
|
||||||
errorLogFile = "netbird.err"
|
errorLogFile = "netbird.err"
|
||||||
stdoutLogFile = "netbird.out"
|
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"
|
darwinErrorLogPath = "/var/log/netbird.out.log"
|
||||||
darwinStdoutLogPath = "/var/log/netbird.err.log"
|
darwinStdoutLogPath = "/var/log/netbird.err.log"
|
||||||
)
|
)
|
||||||
@@ -256,7 +249,6 @@ type BundleGenerator struct {
|
|||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
syncResponse *mgmProto.SyncResponse
|
syncResponse *mgmProto.SyncResponse
|
||||||
logPath string
|
logPath string
|
||||||
uiLogPath string
|
|
||||||
tempDir string
|
tempDir string
|
||||||
statePath string
|
statePath string
|
||||||
cpuProfile []byte
|
cpuProfile []byte
|
||||||
@@ -284,7 +276,6 @@ type GeneratorDependencies struct {
|
|||||||
StatusRecorder *peer.Status
|
StatusRecorder *peer.Status
|
||||||
SyncResponse *mgmProto.SyncResponse
|
SyncResponse *mgmProto.SyncResponse
|
||||||
LogPath string
|
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.
|
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.
|
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
|
||||||
CPUProfile []byte
|
CPUProfile []byte
|
||||||
@@ -309,7 +300,6 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
|||||||
statusRecorder: deps.StatusRecorder,
|
statusRecorder: deps.StatusRecorder,
|
||||||
syncResponse: deps.SyncResponse,
|
syncResponse: deps.SyncResponse,
|
||||||
logPath: deps.LogPath,
|
logPath: deps.LogPath,
|
||||||
uiLogPath: deps.UILogPath,
|
|
||||||
tempDir: deps.TempDir,
|
tempDir: deps.TempDir,
|
||||||
statePath: deps.StatePath,
|
statePath: deps.StatePath,
|
||||||
cpuProfile: deps.CPUProfile,
|
cpuProfile: deps.CPUProfile,
|
||||||
@@ -421,10 +411,6 @@ func (g *BundleGenerator) createArchive() error {
|
|||||||
log.Errorf("failed to add logs to debug bundle: %v", err)
|
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 {
|
if err := g.addUpdateLogs(); err != nil {
|
||||||
log.Errorf("failed to add updater logs: %v", err)
|
log.Errorf("failed to add updater logs: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1000,7 +986,7 @@ func (g *BundleGenerator) addLogfile() error {
|
|||||||
return fmt.Errorf("add client log file to zip: %w", err)
|
return fmt.Errorf("add client log file to zip: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
g.addRotatedLogFiles(logDir, clientLogPrefix)
|
g.addRotatedLogFiles(logDir)
|
||||||
|
|
||||||
stdErrLogPath := filepath.Join(logDir, errorLogFile)
|
stdErrLogPath := filepath.Join(logDir, errorLogFile)
|
||||||
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
|
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
|
||||||
@@ -1020,25 +1006,6 @@ func (g *BundleGenerator) addLogfile() error {
|
|||||||
return nil
|
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
|
// addSingleLogfile adds a single log file to the archive
|
||||||
func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
|
func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
|
||||||
logFile, err := os.Open(logPath)
|
logFile, err := os.Open(logPath)
|
||||||
@@ -1111,16 +1078,14 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount.
|
// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount
|
||||||
// prefix is the base log name without extension (e.g. "client", "gui-client");
|
func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
||||||
// the glob matches both files rotated by us and by logrotate on linux.
|
|
||||||
func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) {
|
|
||||||
if g.logFileCount == 0 {
|
if g.logFileCount == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// This pattern matches both logs rotated by us and logrotate on linux
|
// This regex will match both logs rotated by us and logrotate on linux
|
||||||
pattern := filepath.Join(logDir, prefix+"*.log.*")
|
pattern := filepath.Join(logDir, "client*.log.*")
|
||||||
files, err := filepath.Glob(pattern)
|
files, err := filepath.Glob(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("failed to glob rotated logs: %v", err)
|
log.Warnf("failed to glob rotated logs: %v", err)
|
||||||
|
|||||||
@@ -40,25 +40,6 @@ func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
|
|||||||
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
|
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
|
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
|
||||||
// logFileCount rotated files are bundled, ordered by mtime.
|
// logFileCount rotated files are bundled, ordered by mtime.
|
||||||
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
|
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
|
||||||
@@ -86,10 +67,6 @@ func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
|
|||||||
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
|
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
|
||||||
// zip writer and returns the set of entry names that ended up in the archive.
|
// 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{} {
|
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()
|
t.Helper()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -97,7 +74,7 @@ func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount
|
|||||||
archive: zip.NewWriter(&buf),
|
archive: zip.NewWriter(&buf),
|
||||||
logFileCount: logFileCount,
|
logFileCount: logFileCount,
|
||||||
}
|
}
|
||||||
g.addRotatedLogFiles(dir, prefix)
|
g.addRotatedLogFiles(dir)
|
||||||
require.NoError(t, g.archive.Close())
|
require.NoError(t, g.archive.Close())
|
||||||
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ import (
|
|||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
|
nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
types "github.com/netbirdio/netbird/shared/management/types"
|
||||||
"github.com/netbirdio/netbird/shared/netiputil"
|
"github.com/netbirdio/netbird/shared/netiputil"
|
||||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||||
@@ -220,6 +222,13 @@ type Engine struct {
|
|||||||
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
|
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
|
||||||
networkSerial uint64
|
networkSerial uint64
|
||||||
|
|
||||||
|
// latestComponents is the most-recent NetworkMapComponents decoded from
|
||||||
|
// a NetworkMapEnvelope (capability=3 peers only). Held alongside the
|
||||||
|
// NetworkMap that Calculate() produced from it so future incremental
|
||||||
|
// updates have a base to apply changes against. nil for legacy-format
|
||||||
|
// peers. Guarded by syncMsgMux.
|
||||||
|
latestComponents *types.NetworkMapComponents
|
||||||
|
|
||||||
networkMonitor *networkmonitor.NetworkMonitor
|
networkMonitor *networkmonitor.NetworkMonitor
|
||||||
|
|
||||||
sshServer sshServer
|
sshServer sshServer
|
||||||
@@ -274,20 +283,6 @@ type Engine struct {
|
|||||||
jobExecutorWG sync.WaitGroup
|
jobExecutorWG sync.WaitGroup
|
||||||
|
|
||||||
exposeManager *expose.Manager
|
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
|
// Peer is an instance of the Connection Peer
|
||||||
@@ -339,17 +334,6 @@ func NewEngine(
|
|||||||
updateManager: services.UpdateManager,
|
updateManager: services.UpdateManager,
|
||||||
syncStoreDir: config.StateDir,
|
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())
|
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
||||||
return engine
|
return engine
|
||||||
@@ -416,10 +400,6 @@ func (e *Engine) stopLocked() {
|
|||||||
e.srWatcher.Close()
|
e.srWatcher.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
if e.sessionWatcher != nil {
|
|
||||||
e.sessionWatcher.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.updateManager != nil {
|
if e.updateManager != nil {
|
||||||
e.updateManager.SetDownloadOnly()
|
e.updateManager.SetDownloadOnly()
|
||||||
}
|
}
|
||||||
@@ -961,10 +941,12 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
return e.ctx.Err()
|
return e.ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
e.ApplySessionDeadline(update.GetSessionExpiresAt())
|
// Envelope sync responses carry PeerConfig at the top level; legacy
|
||||||
|
// NetworkMap syncs carry it under NetworkMap.PeerConfig.
|
||||||
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
if pc := update.GetPeerConfig(); pc != nil {
|
||||||
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
e.handleAutoUpdateVersion(pc.GetAutoUpdate())
|
||||||
|
} else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil {
|
||||||
|
e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate())
|
||||||
}
|
}
|
||||||
|
|
||||||
done := e.phase("netbird_config")
|
done := e.phase("netbird_config")
|
||||||
@@ -974,12 +956,42 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode the network map from either the components envelope or the
|
||||||
|
// legacy proto.NetworkMap before the posture-check gating below, so the
|
||||||
|
// "is there a network map" decision covers both wire shapes.
|
||||||
|
var (
|
||||||
|
nm *mgmProto.NetworkMap
|
||||||
|
components *types.NetworkMapComponents
|
||||||
|
)
|
||||||
|
if envelope := update.GetNetworkMapEnvelope(); envelope != nil {
|
||||||
|
// Components-format peer: decode the envelope back to typed
|
||||||
|
// components, run Calculate() locally, and convert to the wire
|
||||||
|
// NetworkMap shape the rest of the engine consumes. Components are
|
||||||
|
// retained so future incremental updates can apply deltas instead
|
||||||
|
// of doing a full reconstruction.
|
||||||
|
localKey := e.config.WgPrivateKey.PublicKey().String()
|
||||||
|
dnsName := ""
|
||||||
|
if pc := update.GetPeerConfig(); pc != nil {
|
||||||
|
// PeerConfig.Fqdn = "<dns_label>.<dns_domain>" — extract the
|
||||||
|
// shared domain by stripping the peer's own label prefix. Falls
|
||||||
|
// back to empty if the FQDN doesn't have the expected shape.
|
||||||
|
dnsName = extractDNSDomainFromFQDN(pc.GetFqdn())
|
||||||
|
}
|
||||||
|
result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode network map envelope: %w", err)
|
||||||
|
}
|
||||||
|
nm = result.NetworkMap
|
||||||
|
components = result.Components
|
||||||
|
} else {
|
||||||
|
nm = update.GetNetworkMap()
|
||||||
|
}
|
||||||
|
|
||||||
// Posture checks are bound to the network map presence:
|
// Posture checks are bound to the network map presence:
|
||||||
// NetworkMap != nil, checks present -> apply the received checks
|
// NetworkMap != nil, checks present -> apply the received checks
|
||||||
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
|
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
|
||||||
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
|
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
|
||||||
// leave the previously applied checks untouched
|
// leave the previously applied checks untouched
|
||||||
nm := update.GetNetworkMap()
|
|
||||||
if nm == nil {
|
if nm == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -992,6 +1004,14 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
done = e.phase("persist")
|
done = e.phase("persist")
|
||||||
|
// Only retain the components view when the server sent the envelope
|
||||||
|
// path. A legacy proto.NetworkMap means components == nil; writing it
|
||||||
|
// here would clobber a previously-cached snapshot, breaking the
|
||||||
|
// incremental-delta base on a future envelope sync.
|
||||||
|
if components != nil {
|
||||||
|
e.latestComponents = components
|
||||||
|
}
|
||||||
|
|
||||||
e.persistSyncResponse(update)
|
e.persistSyncResponse(update)
|
||||||
done()
|
done()
|
||||||
|
|
||||||
@@ -1005,6 +1025,19 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractDNSDomainFromFQDN returns the trailing dotted domain part of the
|
||||||
|
// receiving peer's FQDN — the same value the management server fills as
|
||||||
|
// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" →
|
||||||
|
// "netbird.cloud". An empty string is returned for unrecognized formats.
|
||||||
|
func extractDNSDomainFromFQDN(fqdn string) string {
|
||||||
|
for i := 0; i < len(fqdn); i++ {
|
||||||
|
if fqdn[i] == '.' && i+1 < len(fqdn) {
|
||||||
|
return fqdn[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// updateNetbirdConfig applies the management-provided NetBird configuration:
|
// updateNetbirdConfig applies the management-provided NetBird configuration:
|
||||||
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
|
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
|
||||||
// which is the case for sync updates carrying only a network map.
|
// which is the case for sync updates carrying only a network map.
|
||||||
@@ -1298,7 +1331,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
|||||||
ClientMetrics: e.clientMetrics,
|
ClientMetrics: e.clientMetrics,
|
||||||
DaemonVersion: version.NetbirdVersion(),
|
DaemonVersion: version.NetbirdVersion(),
|
||||||
RefreshStatus: func() {
|
RefreshStatus: func() {
|
||||||
e.RunHealthProbes(e.ctx, true)
|
e.RunHealthProbes(true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2224,20 +2257,7 @@ func (e *Engine) getRosenpassAddr() string {
|
|||||||
|
|
||||||
// RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services
|
// RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services
|
||||||
// and updates the status recorder with the latest states.
|
// 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()
|
e.syncMsgMux.Lock()
|
||||||
|
|
||||||
signalHealthy := e.signal.IsHealthy()
|
signalHealthy := e.signal.IsHealthy()
|
||||||
@@ -2260,9 +2280,9 @@ func (e *Engine) RunHealthProbes(ctx context.Context, waitForResult bool) bool {
|
|||||||
if runtime.GOOS != "js" {
|
if runtime.GOOS != "js" {
|
||||||
var results []relay.ProbeResult
|
var results []relay.ProbeResult
|
||||||
if waitForResult {
|
if waitForResult {
|
||||||
results = e.probeStunTurn.ProbeAllWaitResult(ctx, stuns, turns)
|
results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns)
|
||||||
} else {
|
} else {
|
||||||
results = e.probeStunTurn.ProbeAll(ctx, stuns, turns)
|
results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns)
|
||||||
}
|
}
|
||||||
e.statusRecorder.UpdateRelayStates(results)
|
e.statusRecorder.UpdateRelayStates(results)
|
||||||
|
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
//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).
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ type Logger struct {
|
|||||||
wgIfaceNetV6 netip.Prefix
|
wgIfaceNetV6 netip.Prefix
|
||||||
dnsCollection atomic.Bool
|
dnsCollection atomic.Bool
|
||||||
exitNodeCollection atomic.Bool
|
exitNodeCollection atomic.Bool
|
||||||
Store types.AggregatingStore
|
Store types.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix) *Logger {
|
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,
|
statusRecorder: statusRecorder,
|
||||||
wgIfaceNet: wgIfaceIPNet,
|
wgIfaceNet: wgIfaceIPNet,
|
||||||
wgIfaceNetV6: wgIfaceIPNetV6,
|
wgIfaceNetV6: wgIfaceIPNetV6,
|
||||||
Store: store.NewAggregatingMemoryStore(),
|
Store: store.NewMemoryStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +125,6 @@ func (l *Logger) stop() {
|
|||||||
l.mux.Unlock()
|
l.mux.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) ResetAggregationWindow() types.FlowEventAggregator {
|
|
||||||
return l.Store.ResetAggregationWindow()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) GetEvents() []*types.Event {
|
func (l *Logger) GetEvents() []*types.Event {
|
||||||
return l.Store.GetEvents()
|
return l.Store.GetEvents()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow/conntrack"
|
"github.com/netbirdio/netbird/client/internal/netflow/conntrack"
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow/logger"
|
"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"
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/flow/client"
|
"github.com/netbirdio/netbird/flow/client"
|
||||||
@@ -25,16 +23,14 @@ import (
|
|||||||
|
|
||||||
// Manager handles netflow tracking and logging
|
// Manager handles netflow tracking and logging
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mux sync.Mutex
|
mux sync.Mutex
|
||||||
shutdownWg sync.WaitGroup
|
shutdownWg sync.WaitGroup
|
||||||
logger nftypes.FlowLogger
|
logger nftypes.FlowLogger
|
||||||
flowConfig *nftypes.FlowConfig
|
flowConfig *nftypes.FlowConfig
|
||||||
conntrack nftypes.ConnTracker
|
conntrack nftypes.ConnTracker
|
||||||
receiverClient *client.GRPCClient
|
receiverClient *client.GRPCClient
|
||||||
eventsWithoutAcks nftypes.Store
|
publicKey []byte
|
||||||
publicKey []byte
|
cancel context.CancelFunc
|
||||||
cancel context.CancelFunc
|
|
||||||
retryInterval time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new netflow manager
|
// NewManager creates a new netflow manager
|
||||||
@@ -52,11 +48,9 @@ func NewManager(iface nftypes.IFaceMapper, publicKey []byte, statusRecorder *pee
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &Manager{
|
return &Manager{
|
||||||
logger: flowLogger,
|
logger: flowLogger,
|
||||||
conntrack: ct,
|
conntrack: ct,
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
retryInterval: time.Second,
|
|
||||||
eventsWithoutAcks: store.NewMemoryStore(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +66,6 @@ func (m *Manager) needsNewClient(previous *nftypes.FlowConfig) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// enableFlow starts components for flow tracking
|
// enableFlow starts components for flow tracking
|
||||||
// must be called under m.mux lock
|
|
||||||
func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error {
|
func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error {
|
||||||
// first make sender ready so events don't pile up
|
// first make sender ready so events don't pile up
|
||||||
if m.needsNewClient(previous) {
|
if m.needsNewClient(previous) {
|
||||||
@@ -92,7 +85,6 @@ func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// must be called under m.mux lock
|
|
||||||
func (m *Manager) resetClient() error {
|
func (m *Manager) resetClient() error {
|
||||||
if m.receiverClient != nil {
|
if m.receiverClient != nil {
|
||||||
if err := m.receiverClient.Close(); err != nil {
|
if err := m.receiverClient.Close(); err != nil {
|
||||||
@@ -115,19 +107,14 @@ func (m *Manager) resetClient() error {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
m.cancel = cancel
|
m.cancel = cancel
|
||||||
|
|
||||||
m.shutdownWg.Add(3)
|
m.shutdownWg.Add(2)
|
||||||
flowConfigInterval := m.flowConfig.Interval
|
|
||||||
go func() {
|
go func() {
|
||||||
defer m.shutdownWg.Done()
|
defer m.shutdownWg.Done()
|
||||||
m.receiveACKs(ctx, flowClient, flowConfigInterval)
|
m.receiveACKs(ctx, flowClient)
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer m.shutdownWg.Done()
|
defer m.shutdownWg.Done()
|
||||||
m.startSender(ctx, flowConfigInterval)
|
m.startSender(ctx)
|
||||||
}()
|
|
||||||
go func() {
|
|
||||||
defer m.shutdownWg.Done()
|
|
||||||
m.startRetries(ctx, flowConfigInterval)
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -211,8 +198,8 @@ func (m *Manager) GetLogger() nftypes.FlowLogger {
|
|||||||
return m.logger
|
return m.logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) startSender(ctx context.Context, flowConfigInterval time.Duration) {
|
func (m *Manager) startSender(ctx context.Context) {
|
||||||
ticker := time.NewTicker(flowConfigInterval)
|
ticker := time.NewTicker(m.flowConfig.Interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -220,29 +207,27 @@ func (m *Manager) startSender(ctx context.Context, flowConfigInterval time.Durat
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
collectedEvents := m.logger.ResetAggregationWindow()
|
events := m.logger.GetEvents()
|
||||||
events := collectedEvents.GetAggregatedEvents()
|
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
m.eventsWithoutAcks.StoreEvent(event)
|
|
||||||
if err := m.send(event); err != nil {
|
if err := m.send(event); err != nil {
|
||||||
log.Errorf("failed to send flow event to server: %v", err)
|
log.Errorf("failed to send flow event to server: %v", err)
|
||||||
} else {
|
continue
|
||||||
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, flowConfigInterval time.Duration) {
|
func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) {
|
||||||
err := client.Receive(ctx, flowConfigInterval, func(ack *proto.FlowEventAck) error {
|
err := client.Receive(ctx, m.flowConfig.Interval, func(ack *proto.FlowEventAck) error {
|
||||||
id, err := uuid.FromBytes(ack.EventId)
|
id, err := uuid.FromBytes(ack.EventId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("failed to convert ack event id to uuid: %v", err)
|
log.Warnf("failed to convert ack event id to uuid: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Tracef("received flow event ack: %s", id)
|
log.Tracef("received flow event ack: %s", id)
|
||||||
m.eventsWithoutAcks.DeleteEvents([]uuid.UUID{id})
|
m.logger.DeleteEvents([]uuid.UUID{id})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -251,51 +236,6 @@ func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient, fl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func (m *Manager) send(event *nftypes.Event) error {
|
||||||
m.mux.Lock()
|
m.mux.Lock()
|
||||||
client := m.receiverClient
|
client := m.receiverClient
|
||||||
@@ -310,11 +250,9 @@ func (m *Manager) send(event *nftypes.Event) error {
|
|||||||
|
|
||||||
func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent {
|
func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent {
|
||||||
protoEvent := &proto.FlowEvent{
|
protoEvent := &proto.FlowEvent{
|
||||||
EventId: event.ID[:],
|
EventId: event.ID[:],
|
||||||
Timestamp: timestamppb.New(event.Timestamp),
|
Timestamp: timestamppb.New(event.Timestamp),
|
||||||
PublicKey: publicKey,
|
PublicKey: publicKey,
|
||||||
WindowStart: timestamppb.New(event.WindowStart),
|
|
||||||
WindowEnd: timestamppb.New(event.WindowEnd),
|
|
||||||
FlowFields: &proto.FlowFields{
|
FlowFields: &proto.FlowFields{
|
||||||
FlowId: event.FlowID[:],
|
FlowId: event.FlowID[:],
|
||||||
RuleId: event.RuleID,
|
RuleId: event.RuleID,
|
||||||
@@ -329,9 +267,6 @@ func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent {
|
|||||||
TxBytes: event.TxBytes,
|
TxBytes: event.TxBytes,
|
||||||
SourceResourceId: event.SourceResourceID,
|
SourceResourceId: event.SourceResourceID,
|
||||||
DestResourceId: event.DestResourceID,
|
DestResourceId: event.DestResourceID,
|
||||||
NumOfStarts: event.NumOfStarts,
|
|
||||||
NumOfEnds: event.NumOfEnds,
|
|
||||||
NumOfDrops: event.NumOfDrops,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
|
||||||
"math/rand"
|
|
||||||
v2 "math/rand/v2"
|
|
||||||
"net/netip"
|
|
||||||
"slices"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow/types"
|
"github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,13 +19,6 @@ type Memory struct {
|
|||||||
events map[uuid.UUID]*types.Event
|
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) {
|
func (m *Memory) StoreEvent(event *types.Event) {
|
||||||
m.mux.Lock()
|
m.mux.Lock()
|
||||||
defer m.mux.Unlock()
|
defer m.mux.Unlock()
|
||||||
@@ -60,95 +48,3 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) {
|
|||||||
delete(m.events, id)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package types
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -70,10 +69,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
WindowStart time.Time
|
|
||||||
WindowEnd time.Time
|
|
||||||
EventFields
|
EventFields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,17 +92,6 @@ type EventFields struct {
|
|||||||
TxPackets uint64
|
TxPackets uint64
|
||||||
RxBytes uint64
|
RxBytes uint64
|
||||||
TxBytes 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 {
|
type FlowConfig struct {
|
||||||
@@ -128,15 +114,13 @@ type FlowManager interface {
|
|||||||
GetLogger() FlowLogger
|
GetLogger() FlowLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlowEventAggregator interface {
|
|
||||||
ResetAggregationWindow() FlowEventAggregator
|
|
||||||
GetAggregatedEvents() []*Event
|
|
||||||
}
|
|
||||||
|
|
||||||
type FlowLogger interface {
|
type FlowLogger interface {
|
||||||
ResetAggregationWindow() FlowEventAggregator
|
|
||||||
// StoreEvent stores a flow event
|
// StoreEvent stores a flow event
|
||||||
StoreEvent(flowEvent EventFields)
|
StoreEvent(flowEvent EventFields)
|
||||||
|
// GetEvents returns all stored events
|
||||||
|
GetEvents() []*Event
|
||||||
|
// DeleteEvents deletes events from the store
|
||||||
|
DeleteEvents([]uuid.UUID)
|
||||||
// Close closes the logger
|
// Close closes the logger
|
||||||
Close()
|
Close()
|
||||||
// Enable enables the flow logger receiver
|
// Enable enables the flow logger receiver
|
||||||
@@ -156,11 +140,6 @@ type Store interface {
|
|||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
type AggregatingStore interface {
|
|
||||||
FlowEventAggregator
|
|
||||||
Store
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConnTracker defines the interface for connection tracking functionality
|
// ConnTracker defines the interface for connection tracking functionality
|
||||||
type ConnTracker interface {
|
type ConnTracker interface {
|
||||||
// Start begins tracking connections by listening for conntrack events.
|
// Start begins tracking connections by listening for conntrack events.
|
||||||
|
|||||||
@@ -85,11 +85,7 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
|
|||||||
defer g.srWatcher.RemoveListener(srReconnectedChan)
|
defer g.srWatcher.RemoveListener(srReconnectedChan)
|
||||||
|
|
||||||
ticker := g.initialTicker(ctx)
|
ticker := g.initialTicker(ctx)
|
||||||
defer func() {
|
defer ticker.Stop()
|
||||||
// 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
|
tickerChannel := ticker.C
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -192,30 +191,23 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
// every private-service request) don't contend against each other.
|
// every private-service request) don't contend against each other.
|
||||||
// Pure read methods take RLock; anything that mutates state takes Lock.
|
// Pure read methods take RLock; anything that mutates state takes Lock.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
mux sync.RWMutex
|
mux sync.RWMutex
|
||||||
muxRelays sync.RWMutex
|
muxRelays sync.RWMutex
|
||||||
peers map[string]State
|
peers map[string]State
|
||||||
ipToKey map[string]string
|
ipToKey map[string]string
|
||||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||||
signalState bool
|
signalState bool
|
||||||
signalError error
|
signalError error
|
||||||
managementState bool
|
managementState bool
|
||||||
managementError error
|
managementError error
|
||||||
relayStates []relay.ProbeResult
|
relayStates []relay.ProbeResult
|
||||||
localPeer LocalPeerState
|
localPeer LocalPeerState
|
||||||
offlinePeers []State
|
offlinePeers []State
|
||||||
mgmAddress string
|
mgmAddress string
|
||||||
signalAddress string
|
signalAddress string
|
||||||
notifier *notifier
|
notifier *notifier
|
||||||
rosenpassEnabled bool
|
rosenpassEnabled bool
|
||||||
rosenpassPermissive 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
|
nsGroupStates []NSGroupState
|
||||||
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
||||||
lazyConnectionEnabled bool
|
lazyConnectionEnabled bool
|
||||||
@@ -231,21 +223,6 @@ type Status struct {
|
|||||||
eventStreams map[string]chan *proto.SystemEvent
|
eventStreams map[string]chan *proto.SystemEvent
|
||||||
eventQueue *EventQueue
|
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
|
ingressGwMgr *ingressgw.Manager
|
||||||
|
|
||||||
routeIDLookup routeIDLookup
|
routeIDLookup routeIDLookup
|
||||||
@@ -260,7 +237,6 @@ func NewRecorder(mgmAddress string) *Status {
|
|||||||
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
||||||
eventStreams: make(map[string]chan *proto.SystemEvent),
|
eventStreams: make(map[string]chan *proto.SystemEvent),
|
||||||
eventQueue: NewEventQueue(eventQueueSize),
|
eventQueue: NewEventQueue(eventQueueSize),
|
||||||
stateChangeStreams: make(map[string]chan struct{}),
|
|
||||||
offlinePeers: make([]State, 0),
|
offlinePeers: make([]State, 0),
|
||||||
notifier: newNotifier(),
|
notifier: newNotifier(),
|
||||||
mgmAddress: mgmAddress,
|
mgmAddress: mgmAddress,
|
||||||
@@ -425,7 +401,6 @@ func (d *Status) UpdatePeerState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +426,6 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R
|
|||||||
|
|
||||||
// todo: consider to make sense of this notification or not
|
// todo: consider to make sense of this notification or not
|
||||||
d.notifier.peerListChanged(numPeers)
|
d.notifier.peerListChanged(numPeers)
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +451,6 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error {
|
|||||||
|
|
||||||
// todo: consider to make sense of this notification or not
|
// todo: consider to make sense of this notification or not
|
||||||
d.notifier.peerListChanged(numPeers)
|
d.notifier.peerListChanged(numPeers)
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +500,6 @@ func (d *Status) UpdatePeerICEState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,7 +536,6 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +571,6 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,7 +609,6 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +702,6 @@ func (d *Status) FinishPeerListModifications() {
|
|||||||
for _, rd := range dispatches {
|
for _, rd := range dispatches {
|
||||||
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
|
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
|
func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
|
||||||
@@ -792,41 +760,6 @@ func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.localAddressChanged(fqdn, ip)
|
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
|
// AddLocalPeerStateRoute adds a route to the local peer state
|
||||||
@@ -895,19 +828,11 @@ func (d *Status) CleanLocalPeerState() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.localAddressChanged(fqdn, ip)
|
d.notifier.localAddressChanged(fqdn, ip)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkManagementDisconnected sets ManagementState to disconnected
|
// MarkManagementDisconnected sets ManagementState to disconnected
|
||||||
func (d *Status) MarkManagementDisconnected(err error) {
|
func (d *Status) MarkManagementDisconnected(err error) {
|
||||||
d.mux.Lock()
|
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.managementState = false
|
||||||
d.managementError = err
|
d.managementError = err
|
||||||
mgm := d.managementState
|
mgm := d.managementState
|
||||||
@@ -915,16 +840,11 @@ func (d *Status) MarkManagementDisconnected(err error) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkManagementConnected sets ManagementState to connected
|
// MarkManagementConnected sets ManagementState to connected
|
||||||
func (d *Status) MarkManagementConnected() {
|
func (d *Status) MarkManagementConnected() {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
if d.managementState && d.managementError == nil {
|
|
||||||
d.mux.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.managementState = true
|
d.managementState = true
|
||||||
d.managementError = nil
|
d.managementError = nil
|
||||||
mgm := d.managementState
|
mgm := d.managementState
|
||||||
@@ -932,7 +852,6 @@ func (d *Status) MarkManagementConnected() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSignalAddress update the address of the signal server
|
// UpdateSignalAddress update the address of the signal server
|
||||||
@@ -966,10 +885,6 @@ func (d *Status) UpdateLazyConnection(enabled bool) {
|
|||||||
// MarkSignalDisconnected sets SignalState to disconnected
|
// MarkSignalDisconnected sets SignalState to disconnected
|
||||||
func (d *Status) MarkSignalDisconnected(err error) {
|
func (d *Status) MarkSignalDisconnected(err error) {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
if !d.signalState && errors.Is(d.signalError, err) {
|
|
||||||
d.mux.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.signalState = false
|
d.signalState = false
|
||||||
d.signalError = err
|
d.signalError = err
|
||||||
mgm := d.managementState
|
mgm := d.managementState
|
||||||
@@ -977,16 +892,11 @@ func (d *Status) MarkSignalDisconnected(err error) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkSignalConnected sets SignalState to connected
|
// MarkSignalConnected sets SignalState to connected
|
||||||
func (d *Status) MarkSignalConnected() {
|
func (d *Status) MarkSignalConnected() {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
if d.signalState && d.signalError == nil {
|
|
||||||
d.mux.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.signalState = true
|
d.signalState = true
|
||||||
d.signalError = nil
|
d.signalError = nil
|
||||||
mgm := d.managementState
|
mgm := d.managementState
|
||||||
@@ -994,7 +904,6 @@ func (d *Status) MarkSignalConnected() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
|
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
|
||||||
@@ -1201,19 +1110,16 @@ func (d *Status) GetFullStatus() FullStatus {
|
|||||||
// ClientStart will notify all listeners about the new service state
|
// ClientStart will notify all listeners about the new service state
|
||||||
func (d *Status) ClientStart() {
|
func (d *Status) ClientStart() {
|
||||||
d.notifier.clientStart()
|
d.notifier.clientStart()
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientStop will notify all listeners about the new service state
|
// ClientStop will notify all listeners about the new service state
|
||||||
func (d *Status) ClientStop() {
|
func (d *Status) ClientStop() {
|
||||||
d.notifier.clientStop()
|
d.notifier.clientStop()
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientTeardown will notify all listeners about the service is under teardown
|
// ClientTeardown will notify all listeners about the service is under teardown
|
||||||
func (d *Status) ClientTeardown() {
|
func (d *Status) ClientTeardown() {
|
||||||
d.notifier.clientTearDown()
|
d.notifier.clientTearDown()
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConnectionListener set a listener to the notifier
|
// SetConnectionListener set a listener to the notifier
|
||||||
@@ -1355,79 +1261,6 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent {
|
|||||||
return d.eventQueue.GetAll()
|
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) {
|
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.Unlock()
|
||||||
|
|||||||
@@ -314,39 +314,3 @@ func TestGetFullStatus(t *testing.T) {
|
|||||||
assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal")
|
assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal")
|
||||||
assert.ElementsMatch(t, []State{peerState1, peerState2}, fullStatus.Peers, "peers states should match")
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
@@ -72,22 +71,3 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
|||||||
|
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -442,11 +442,6 @@ func (m *DefaultManager) UpdateRoutes(
|
|||||||
|
|
||||||
m.updateClientNetworks(updateSerial, filteredClientRoutes)
|
m.updateClientNetworks(updateSerial, filteredClientRoutes)
|
||||||
m.notifier.OnNewRoutes(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
|
m.clientRoutes = clientRoutes
|
||||||
|
|
||||||
@@ -587,10 +582,6 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) {
|
|||||||
if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil {
|
if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil {
|
||||||
log.Errorf("failed to update state: %v", err)
|
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
|
// stopObsoleteClients stops the client network watcher for the networks that are not in the new list
|
||||||
@@ -710,13 +701,7 @@ func resolveURLsToIPs(urls []string) []net.IP {
|
|||||||
return ips
|
return ips
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateRouteSelectorFromManagement reconciles exit-node selection on every
|
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
|
||||||
// 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) {
|
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
|
||||||
m.mirrorV6ExitPairSelections(clientRoutes)
|
m.mirrorV6ExitPairSelections(clientRoutes)
|
||||||
|
|
||||||
@@ -727,14 +712,13 @@ func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HA
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
info := m.collectExitNodeInfo(clientRoutes)
|
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
|
||||||
if len(info.allIDs) == 0 {
|
if len(exitNodeInfo.allIDs) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
preferred := pickPreferredExitNode(info)
|
m.updateExitNodeSelections(exitNodeInfo)
|
||||||
m.enforceSingleExitNode(preferred, info.allIDs)
|
m.logExitNodeUpdate(exitNodeInfo)
|
||||||
m.logExitNodeUpdate(info, preferred)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection
|
// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection
|
||||||
@@ -762,10 +746,6 @@ type exitNodeInfo struct {
|
|||||||
userDeselected []route.NetID
|
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 {
|
func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeInfo {
|
||||||
var info exitNodeInfo
|
var info exitNodeInfo
|
||||||
|
|
||||||
@@ -775,9 +755,6 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI
|
|||||||
}
|
}
|
||||||
|
|
||||||
netID := haID.NetID()
|
netID := haID.NetID()
|
||||||
if strings.HasSuffix(string(netID), route.V6ExitSuffix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info.allIDs = append(info.allIDs, netID)
|
info.allIDs = append(info.allIDs, netID)
|
||||||
|
|
||||||
if m.routeSelector.HasUserSelectionForRoute(netID) {
|
if m.routeSelector.HasUserSelectionForRoute(netID) {
|
||||||
@@ -814,52 +791,45 @@ func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickPreferredExitNode chooses the single exit node to keep selected. In order:
|
func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) {
|
||||||
// - a persisted user selection wins (deterministic if several survive from
|
routesToDeselect := m.getRoutesToDeselect(info.allIDs)
|
||||||
// legacy state, so the set self-heals down to one);
|
m.deselectExitNodes(routesToDeselect)
|
||||||
// - otherwise activate only what management marks for auto-apply
|
m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs)
|
||||||
// (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 ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// enforceSingleExitNode makes preferred the only selected exit node: every other
|
func (m *DefaultManager) getRoutesToDeselect(allIDs []route.NetID) []route.NetID {
|
||||||
// available exit node is deselected and preferred (if any) is selected, without
|
var routesToDeselect []route.NetID
|
||||||
// disturbing non-exit route selections. The whole reconciliation runs under a
|
for _, netID := range allIDs {
|
||||||
// single RouteSelector lock (SetExclusiveExitNode) so a concurrent deselect-all
|
if !m.routeSelector.HasUserSelectionForRoute(netID) {
|
||||||
// cannot interleave and get undone; a global deselect-all is left untouched so
|
routesToDeselect = append(routesToDeselect, netID)
|
||||||
// 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 best
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,38 +115,7 @@ func (rs *RouteSelector) DeselectAllRoutes() {
|
|||||||
clear(rs.selectedRoutes)
|
clear(rs.selectedRoutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetExclusiveExitNode atomically makes preferred the only selected exit node
|
// IsDeselectAll reports whether the user has explicitly deselected all routes.
|
||||||
// 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 {
|
func (rs *RouteSelector) IsDeselectAll() bool {
|
||||||
rs.mu.RLock()
|
rs.mu.RLock()
|
||||||
defer rs.mu.RUnlock()
|
defer rs.mu.RUnlock()
|
||||||
|
|||||||
@@ -859,31 +859,3 @@ 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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,34 +33,17 @@ func CtxGetState(ctx context.Context) *contextState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type contextState struct {
|
type contextState struct {
|
||||||
err error
|
err error
|
||||||
status StatusType
|
status StatusType
|
||||||
mutex sync.Mutex
|
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) {
|
func (c *contextState) Set(update StatusType) {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
c.status = update
|
c.status = update
|
||||||
c.err = nil
|
c.err = nil
|
||||||
cb := c.onChange
|
|
||||||
c.mutex.Unlock()
|
|
||||||
|
|
||||||
if cb != nil {
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *contextState) Status() (StatusType, error) {
|
func (c *contextState) Status() (StatusType, error) {
|
||||||
@@ -74,17 +57,6 @@ func (c *contextState) Status() (StatusType, error) {
|
|||||||
return c.status, nil
|
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 {
|
func (c *contextState) Wrap(err error) error {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ var allKeys = []string{
|
|||||||
KeyDisableUpdateSettings,
|
KeyDisableUpdateSettings,
|
||||||
KeyDisableProfiles,
|
KeyDisableProfiles,
|
||||||
KeyDisableNetworks,
|
KeyDisableNetworks,
|
||||||
KeyDisableAdvancedView,
|
|
||||||
KeyDisableClientRoutes,
|
KeyDisableClientRoutes,
|
||||||
KeyDisableServerRoutes,
|
KeyDisableServerRoutes,
|
||||||
KeyBlockInbound,
|
KeyBlockInbound,
|
||||||
|
|||||||
@@ -24,13 +24,6 @@ const (
|
|||||||
KeyDisableUpdateSettings = "disableUpdateSettings"
|
KeyDisableUpdateSettings = "disableUpdateSettings"
|
||||||
KeyDisableProfiles = "disableProfiles"
|
KeyDisableProfiles = "disableProfiles"
|
||||||
KeyDisableNetworks = "disableNetworks"
|
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"
|
KeyDisableClientRoutes = "disableClientRoutes"
|
||||||
KeyDisableServerRoutes = "disableServerRoutes"
|
KeyDisableServerRoutes = "disableServerRoutes"
|
||||||
KeyBlockInbound = "blockInbound"
|
KeyBlockInbound = "blockInbound"
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
|
|
||||||
<MajorUpgrade AllowSameVersionUpgrades='yes' DowngradeErrorMessage="A newer version of [ProductName] is already installed. Setup will now exit."/>
|
<MajorUpgrade AllowSameVersionUpgrades='yes' DowngradeErrorMessage="A newer version of [ProductName] is already installed. Setup will now exit."/>
|
||||||
|
|
||||||
|
<!-- Autostart: enabled by default, disable with AUTOSTART=0 on the msiexec command line -->
|
||||||
|
<Property Id="AUTOSTART" Value="1" />
|
||||||
|
|
||||||
<StandardDirectory Id="ProgramFiles64Folder">
|
<StandardDirectory Id="ProgramFiles64Folder">
|
||||||
<Directory Id="NetbirdInstallDir" Name="Netbird">
|
<Directory Id="NetbirdInstallDir" Name="Netbird">
|
||||||
<Component Id="NetbirdFiles" Guid="db3165de-cc6e-4922-8396-9d892950e23e" Bitness="always64">
|
<Component Id="NetbirdFiles" Guid="db3165de-cc6e-4922-8396-9d892950e23e" Bitness="always64">
|
||||||
@@ -29,6 +32,9 @@
|
|||||||
</File>
|
</File>
|
||||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\wintun.dll" />
|
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\wintun.dll" />
|
||||||
<File Id="NetbirdToastIcon" Name="netbird.png" Source=".\client\ui\assets\netbird.png" />
|
<File Id="NetbirdToastIcon" Name="netbird.png" Source=".\client\ui\assets\netbird.png" />
|
||||||
|
<?if $(var.ArchSuffix) = "amd64" ?>
|
||||||
|
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\opengl32.dll" />
|
||||||
|
<?endif ?>
|
||||||
|
|
||||||
<ServiceInstall
|
<ServiceInstall
|
||||||
Id="NetBirdService"
|
Id="NetBirdService"
|
||||||
@@ -56,60 +62,33 @@
|
|||||||
<Component Id="NetbirdAumidRegistry" Guid="*">
|
<Component Id="NetbirdAumidRegistry" Guid="*">
|
||||||
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
||||||
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
||||||
<!-- Pre-seed the CLSID the Wails notifications service reads on
|
|
||||||
first startup (notifications_windows.go:getGUID looks for
|
|
||||||
the CustomActivator value under this key). Without this
|
|
||||||
the service generates a fresh per-install UUID, which
|
|
||||||
diverges from the ToastActivatorCLSID set on the Start
|
|
||||||
Menu / Desktop shortcuts above and the COM activator
|
|
||||||
never fires when a toast is clicked. -->
|
|
||||||
<RegistryValue Name="CustomActivator" Type="string" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
|
|
||||||
</RegistryKey>
|
</RegistryKey>
|
||||||
</Component>
|
</Component>
|
||||||
</StandardDirectory>
|
</StandardDirectory>
|
||||||
|
|
||||||
|
<StandardDirectory Id="CommonAppDataFolder">
|
||||||
|
<Directory Id="NetbirdAutoStartDir" Name="Netbird">
|
||||||
|
<Component Id="NetbirdAutoStart" Guid="b199eaca-b0dd-4032-af19-679cfad48eb3" Bitness="always64" Condition='AUTOSTART = "1"'>
|
||||||
|
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\Run"
|
||||||
|
Name="Netbird" Value=""[NetbirdInstallDir]netbird-ui.exe""
|
||||||
|
Type="string" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</Directory>
|
||||||
|
</StandardDirectory>
|
||||||
|
|
||||||
<ComponentGroup Id="NetbirdFilesComponent">
|
<ComponentGroup Id="NetbirdFilesComponent">
|
||||||
<ComponentRef Id="NetbirdFiles" />
|
<ComponentRef Id="NetbirdFiles" />
|
||||||
<ComponentRef Id="NetbirdAumidRegistry" />
|
<ComponentRef Id="NetbirdAumidRegistry" />
|
||||||
|
<ComponentRef Id="NetbirdAutoStart" />
|
||||||
</ComponentGroup>
|
</ComponentGroup>
|
||||||
|
|
||||||
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
||||||
<util:CloseApplication Id="CloseNetBirdUI" CloseMessage="no" Target="netbird-ui.exe" RebootPrompt="no" TerminateProcess="0" />
|
<util:CloseApplication Id="CloseNetBirdUI" CloseMessage="no" Target="netbird-ui.exe" RebootPrompt="no" TerminateProcess="0" />
|
||||||
|
|
||||||
<!-- WebView2 evergreen runtime detection.
|
|
||||||
Probe both the per-machine and per-user EdgeUpdate keys; if either
|
|
||||||
reports a non-empty `pv` value the runtime is already installed
|
|
||||||
and we skip the bootstrapper. -->
|
|
||||||
<Property Id="WEBVIEW2_VERSION_HKLM">
|
|
||||||
<RegistrySearch Id="WV2HKLM" Root="HKLM"
|
|
||||||
Key="SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
|
||||||
Name="pv" Type="raw" Bitness="always64" />
|
|
||||||
</Property>
|
|
||||||
<Property Id="WEBVIEW2_VERSION_HKCU">
|
|
||||||
<RegistrySearch Id="WV2HKCU" Root="HKCU"
|
|
||||||
Key="Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
|
||||||
Name="pv" Type="raw" />
|
|
||||||
</Property>
|
|
||||||
|
|
||||||
<!-- Embed the bootstrapper payload. Path is relative to the WiX
|
|
||||||
working directory; sign-pipelines stages it next to client/
|
|
||||||
via `wails3 generate webview2bootstrapper`. -->
|
|
||||||
<Binary Id="WebView2Bootstrapper" SourceFile=".\client\MicrosoftEdgeWebview2Setup.exe" />
|
|
||||||
|
|
||||||
<CustomAction Id="InstallWebView2"
|
|
||||||
BinaryRef="WebView2Bootstrapper"
|
|
||||||
ExeCommand="/silent /install"
|
|
||||||
Execute="deferred"
|
|
||||||
Impersonate="no"
|
|
||||||
Return="check" />
|
|
||||||
|
|
||||||
<InstallExecuteSequence>
|
|
||||||
<Custom Action="InstallWebView2" Before="InstallFinalize"
|
|
||||||
Condition="NOT WEBVIEW2_VERSION_HKLM AND NOT WEBVIEW2_VERSION_HKCU AND NOT REMOVE" />
|
|
||||||
</InstallExecuteSequence>
|
|
||||||
|
|
||||||
<!-- Icons -->
|
<!-- Icons -->
|
||||||
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\build\windows\icon.ico" />
|
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\assets\netbird.ico" />
|
||||||
<Property Id="ARPPRODUCTICON" Value="NetbirdIcon" />
|
<Property Id="ARPPRODUCTICON" Value="NetbirdIcon" />
|
||||||
|
|
||||||
</Package>
|
</Package>
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ service DaemonService {
|
|||||||
// Status of the service.
|
// Status of the service.
|
||||||
rpc Status(StatusRequest) returns (StatusResponse) {}
|
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.
|
// Down stops engine work in the daemon.
|
||||||
rpc Down(DownRequest) returns (DownResponse) {}
|
rpc Down(DownRequest) returns (DownResponse) {}
|
||||||
|
|
||||||
@@ -85,11 +79,6 @@ service DaemonService {
|
|||||||
|
|
||||||
rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {}
|
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 SwitchProfile(SwitchProfileRequest) returns (SwitchProfileResponse) {}
|
||||||
|
|
||||||
rpc SetConfig(SetConfigRequest) returns (SetConfigResponse) {}
|
rpc SetConfig(SetConfigRequest) returns (SetConfigResponse) {}
|
||||||
@@ -122,25 +111,6 @@ service DaemonService {
|
|||||||
// WaitJWTToken waits for JWT authentication completion
|
// WaitJWTToken waits for JWT authentication completion
|
||||||
rpc WaitJWTToken(WaitJWTTokenRequest) returns (WaitJWTTokenResponse) {}
|
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
|
// StartCPUProfile starts CPU profiling in the daemon
|
||||||
rpc StartCPUProfile(StartCPUProfileRequest) returns (StartCPUProfileResponse) {}
|
rpc StartCPUProfile(StartCPUProfileRequest) returns (StartCPUProfileResponse) {}
|
||||||
|
|
||||||
@@ -151,11 +121,6 @@ service DaemonService {
|
|||||||
|
|
||||||
// ExposeService exposes a local port via the NetBird reverse proxy
|
// ExposeService exposes a local port via the NetBird reverse proxy
|
||||||
rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {}
|
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) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -264,12 +229,6 @@ message UpRequest {
|
|||||||
optional string profileName = 1;
|
optional string profileName = 1;
|
||||||
optional string username = 2;
|
optional string username = 2;
|
||||||
reserved 3;
|
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 {}
|
message UpResponse {}
|
||||||
@@ -287,10 +246,6 @@ message StatusResponse{
|
|||||||
FullStatus fullStatus = 2;
|
FullStatus fullStatus = 2;
|
||||||
// NetBird daemon version
|
// NetBird daemon version
|
||||||
string daemonVersion = 3;
|
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 {}
|
message DownRequest {}
|
||||||
@@ -466,12 +421,6 @@ message FullStatus {
|
|||||||
|
|
||||||
bool lazyConnectionEnabled = 9;
|
bool lazyConnectionEnabled = 9;
|
||||||
SSHServerState sshServerState = 10;
|
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
|
// Networks
|
||||||
@@ -569,13 +518,6 @@ message SetLogLevelRequest {
|
|||||||
message SetLogLevelResponse {
|
message SetLogLevelResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
message RegisterUILogRequest {
|
|
||||||
string path = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message RegisterUILogResponse {
|
|
||||||
}
|
|
||||||
|
|
||||||
// State represents a daemon state entry
|
// State represents a daemon state entry
|
||||||
message State {
|
message State {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
@@ -829,22 +771,12 @@ message LogoutRequest {
|
|||||||
|
|
||||||
message LogoutResponse {}
|
message LogoutResponse {}
|
||||||
|
|
||||||
message WailsUIReadyRequest {}
|
|
||||||
|
|
||||||
message WailsUIReadyResponse {}
|
|
||||||
|
|
||||||
message GetFeaturesRequest{}
|
message GetFeaturesRequest{}
|
||||||
|
|
||||||
message GetFeaturesResponse{
|
message GetFeaturesResponse{
|
||||||
bool disable_profiles = 1;
|
bool disable_profiles = 1;
|
||||||
bool disable_update_settings = 2;
|
bool disable_update_settings = 2;
|
||||||
bool disable_networks = 3;
|
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
|
// MDMManagedFieldsViolation is attached as a gRPC error detail on a
|
||||||
@@ -923,55 +855,6 @@ message WaitJWTTokenResponse {
|
|||||||
int64 expiresIn = 3;
|
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
|
// StartCPUProfileRequest for starting CPU profiling
|
||||||
message StartCPUProfileRequest {}
|
message StartCPUProfileRequest {}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const (
|
|||||||
DaemonService_WaitSSOLogin_FullMethodName = "/daemon.DaemonService/WaitSSOLogin"
|
DaemonService_WaitSSOLogin_FullMethodName = "/daemon.DaemonService/WaitSSOLogin"
|
||||||
DaemonService_Up_FullMethodName = "/daemon.DaemonService/Up"
|
DaemonService_Up_FullMethodName = "/daemon.DaemonService/Up"
|
||||||
DaemonService_Status_FullMethodName = "/daemon.DaemonService/Status"
|
DaemonService_Status_FullMethodName = "/daemon.DaemonService/Status"
|
||||||
DaemonService_SubscribeStatus_FullMethodName = "/daemon.DaemonService/SubscribeStatus"
|
|
||||||
DaemonService_Down_FullMethodName = "/daemon.DaemonService/Down"
|
DaemonService_Down_FullMethodName = "/daemon.DaemonService/Down"
|
||||||
DaemonService_GetConfig_FullMethodName = "/daemon.DaemonService/GetConfig"
|
DaemonService_GetConfig_FullMethodName = "/daemon.DaemonService/GetConfig"
|
||||||
DaemonService_ListNetworks_FullMethodName = "/daemon.DaemonService/ListNetworks"
|
DaemonService_ListNetworks_FullMethodName = "/daemon.DaemonService/ListNetworks"
|
||||||
@@ -43,7 +42,6 @@ const (
|
|||||||
DaemonService_StopBundleCapture_FullMethodName = "/daemon.DaemonService/StopBundleCapture"
|
DaemonService_StopBundleCapture_FullMethodName = "/daemon.DaemonService/StopBundleCapture"
|
||||||
DaemonService_SubscribeEvents_FullMethodName = "/daemon.DaemonService/SubscribeEvents"
|
DaemonService_SubscribeEvents_FullMethodName = "/daemon.DaemonService/SubscribeEvents"
|
||||||
DaemonService_GetEvents_FullMethodName = "/daemon.DaemonService/GetEvents"
|
DaemonService_GetEvents_FullMethodName = "/daemon.DaemonService/GetEvents"
|
||||||
DaemonService_RegisterUILog_FullMethodName = "/daemon.DaemonService/RegisterUILog"
|
|
||||||
DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile"
|
DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile"
|
||||||
DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig"
|
DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig"
|
||||||
DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile"
|
DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile"
|
||||||
@@ -57,14 +55,10 @@ const (
|
|||||||
DaemonService_GetPeerSSHHostKey_FullMethodName = "/daemon.DaemonService/GetPeerSSHHostKey"
|
DaemonService_GetPeerSSHHostKey_FullMethodName = "/daemon.DaemonService/GetPeerSSHHostKey"
|
||||||
DaemonService_RequestJWTAuth_FullMethodName = "/daemon.DaemonService/RequestJWTAuth"
|
DaemonService_RequestJWTAuth_FullMethodName = "/daemon.DaemonService/RequestJWTAuth"
|
||||||
DaemonService_WaitJWTToken_FullMethodName = "/daemon.DaemonService/WaitJWTToken"
|
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_StartCPUProfile_FullMethodName = "/daemon.DaemonService/StartCPUProfile"
|
||||||
DaemonService_StopCPUProfile_FullMethodName = "/daemon.DaemonService/StopCPUProfile"
|
DaemonService_StopCPUProfile_FullMethodName = "/daemon.DaemonService/StopCPUProfile"
|
||||||
DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult"
|
DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult"
|
||||||
DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService"
|
DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService"
|
||||||
DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DaemonServiceClient is the client API for DaemonService service.
|
// DaemonServiceClient is the client API for DaemonService service.
|
||||||
@@ -80,11 +74,6 @@ type DaemonServiceClient interface {
|
|||||||
Up(ctx context.Context, in *UpRequest, opts ...grpc.CallOption) (*UpResponse, error)
|
Up(ctx context.Context, in *UpRequest, opts ...grpc.CallOption) (*UpResponse, error)
|
||||||
// Status of the service.
|
// Status of the service.
|
||||||
Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error)
|
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 stops engine work in the daemon.
|
||||||
Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error)
|
Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error)
|
||||||
// GetConfig of the daemon.
|
// GetConfig of the daemon.
|
||||||
@@ -121,10 +110,6 @@ type DaemonServiceClient interface {
|
|||||||
StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error)
|
StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error)
|
||||||
SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], 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)
|
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)
|
SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error)
|
||||||
SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error)
|
SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error)
|
||||||
AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error)
|
AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error)
|
||||||
@@ -144,22 +129,6 @@ type DaemonServiceClient interface {
|
|||||||
RequestJWTAuth(ctx context.Context, in *RequestJWTAuthRequest, opts ...grpc.CallOption) (*RequestJWTAuthResponse, error)
|
RequestJWTAuth(ctx context.Context, in *RequestJWTAuthRequest, opts ...grpc.CallOption) (*RequestJWTAuthResponse, error)
|
||||||
// WaitJWTToken waits for JWT authentication completion
|
// WaitJWTToken waits for JWT authentication completion
|
||||||
WaitJWTToken(ctx context.Context, in *WaitJWTTokenRequest, opts ...grpc.CallOption) (*WaitJWTTokenResponse, error)
|
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 starts CPU profiling in the daemon
|
||||||
StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error)
|
StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error)
|
||||||
// StopCPUProfile stops CPU profiling in the daemon
|
// StopCPUProfile stops CPU profiling in the daemon
|
||||||
@@ -167,10 +136,6 @@ type DaemonServiceClient interface {
|
|||||||
GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error)
|
GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error)
|
||||||
// ExposeService exposes a local port via the NetBird reverse proxy
|
// ExposeService exposes a local port via the NetBird reverse proxy
|
||||||
ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error)
|
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 {
|
type daemonServiceClient struct {
|
||||||
@@ -221,25 +186,6 @@ func (c *daemonServiceClient) Status(ctx context.Context, in *StatusRequest, opt
|
|||||||
return out, nil
|
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) {
|
func (c *daemonServiceClient) Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(DownResponse)
|
out := new(DownResponse)
|
||||||
@@ -382,7 +328,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) {
|
func (c *daemonServiceClient) StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CapturePacket], error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_StartCapture_FullMethodName, cOpts...)
|
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_StartCapture_FullMethodName, cOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -421,7 +367,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) {
|
func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], DaemonService_SubscribeEvents_FullMethodName, cOpts...)
|
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_SubscribeEvents_FullMethodName, cOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -448,16 +394,6 @@ func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsReques
|
|||||||
return out, nil
|
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) {
|
func (c *daemonServiceClient) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(SwitchProfileResponse)
|
out := new(SwitchProfileResponse)
|
||||||
@@ -588,36 +524,6 @@ func (c *daemonServiceClient) WaitJWTToken(ctx context.Context, in *WaitJWTToken
|
|||||||
return out, nil
|
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) {
|
func (c *daemonServiceClient) StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(StartCPUProfileResponse)
|
out := new(StartCPUProfileResponse)
|
||||||
@@ -650,7 +556,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) {
|
func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[3], DaemonService_ExposeService_FullMethodName, cOpts...)
|
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], DaemonService_ExposeService_FullMethodName, cOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -667,16 +573,6 @@ 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.
|
// 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]
|
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.
|
// DaemonServiceServer is the server API for DaemonService service.
|
||||||
// All implementations must embed UnimplementedDaemonServiceServer
|
// All implementations must embed UnimplementedDaemonServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -690,11 +586,6 @@ type DaemonServiceServer interface {
|
|||||||
Up(context.Context, *UpRequest) (*UpResponse, error)
|
Up(context.Context, *UpRequest) (*UpResponse, error)
|
||||||
// Status of the service.
|
// Status of the service.
|
||||||
Status(context.Context, *StatusRequest) (*StatusResponse, error)
|
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 stops engine work in the daemon.
|
||||||
Down(context.Context, *DownRequest) (*DownResponse, error)
|
Down(context.Context, *DownRequest) (*DownResponse, error)
|
||||||
// GetConfig of the daemon.
|
// GetConfig of the daemon.
|
||||||
@@ -731,10 +622,6 @@ type DaemonServiceServer interface {
|
|||||||
StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error)
|
StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error)
|
||||||
SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error
|
SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error
|
||||||
GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, 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)
|
SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error)
|
||||||
SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error)
|
SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error)
|
||||||
AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error)
|
AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error)
|
||||||
@@ -754,22 +641,6 @@ type DaemonServiceServer interface {
|
|||||||
RequestJWTAuth(context.Context, *RequestJWTAuthRequest) (*RequestJWTAuthResponse, error)
|
RequestJWTAuth(context.Context, *RequestJWTAuthRequest) (*RequestJWTAuthResponse, error)
|
||||||
// WaitJWTToken waits for JWT authentication completion
|
// WaitJWTToken waits for JWT authentication completion
|
||||||
WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error)
|
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 starts CPU profiling in the daemon
|
||||||
StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error)
|
StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error)
|
||||||
// StopCPUProfile stops CPU profiling in the daemon
|
// StopCPUProfile stops CPU profiling in the daemon
|
||||||
@@ -777,10 +648,6 @@ type DaemonServiceServer interface {
|
|||||||
GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error)
|
GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error)
|
||||||
// ExposeService exposes a local port via the NetBird reverse proxy
|
// ExposeService exposes a local port via the NetBird reverse proxy
|
||||||
ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error
|
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()
|
mustEmbedUnimplementedDaemonServiceServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,9 +670,6 @@ func (UnimplementedDaemonServiceServer) Up(context.Context, *UpRequest) (*UpResp
|
|||||||
func (UnimplementedDaemonServiceServer) Status(context.Context, *StatusRequest) (*StatusResponse, error) {
|
func (UnimplementedDaemonServiceServer) Status(context.Context, *StatusRequest) (*StatusResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method Status not implemented")
|
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) {
|
func (UnimplementedDaemonServiceServer) Down(context.Context, *DownRequest) (*DownResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method Down not implemented")
|
return nil, status.Error(codes.Unimplemented, "method Down not implemented")
|
||||||
}
|
}
|
||||||
@@ -863,9 +727,6 @@ func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, grpc.
|
|||||||
func (UnimplementedDaemonServiceServer) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) {
|
func (UnimplementedDaemonServiceServer) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method GetEvents not implemented")
|
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) {
|
func (UnimplementedDaemonServiceServer) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method SwitchProfile not implemented")
|
return nil, status.Error(codes.Unimplemented, "method SwitchProfile not implemented")
|
||||||
}
|
}
|
||||||
@@ -905,15 +766,6 @@ func (UnimplementedDaemonServiceServer) RequestJWTAuth(context.Context, *Request
|
|||||||
func (UnimplementedDaemonServiceServer) WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) {
|
func (UnimplementedDaemonServiceServer) WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method WaitJWTToken not implemented")
|
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) {
|
func (UnimplementedDaemonServiceServer) StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method StartCPUProfile not implemented")
|
return nil, status.Error(codes.Unimplemented, "method StartCPUProfile not implemented")
|
||||||
}
|
}
|
||||||
@@ -926,9 +778,6 @@ func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *Ins
|
|||||||
func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error {
|
func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error {
|
||||||
return status.Error(codes.Unimplemented, "method ExposeService not implemented")
|
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) mustEmbedUnimplementedDaemonServiceServer() {}
|
||||||
func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {}
|
func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -1022,17 +871,6 @@ func _DaemonService_Status_Handler(srv interface{}, ctx context.Context, dec fun
|
|||||||
return interceptor(ctx, in, info, handler)
|
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) {
|
func _DaemonService_Down_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(DownRequest)
|
in := new(DownRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -1361,24 +1199,6 @@ func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec
|
|||||||
return interceptor(ctx, in, info, handler)
|
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) {
|
func _DaemonService_SwitchProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(SwitchProfileRequest)
|
in := new(SwitchProfileRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -1613,60 +1433,6 @@ func _DaemonService_WaitJWTToken_Handler(srv interface{}, ctx context.Context, d
|
|||||||
return interceptor(ctx, in, info, handler)
|
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) {
|
func _DaemonService_StartCPUProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(StartCPUProfileRequest)
|
in := new(StartCPUProfileRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -1732,24 +1498,6 @@ 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.
|
// 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]
|
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.
|
// DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -1841,10 +1589,6 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "GetEvents",
|
MethodName: "GetEvents",
|
||||||
Handler: _DaemonService_GetEvents_Handler,
|
Handler: _DaemonService_GetEvents_Handler,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
MethodName: "RegisterUILog",
|
|
||||||
Handler: _DaemonService_RegisterUILog_Handler,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
MethodName: "SwitchProfile",
|
MethodName: "SwitchProfile",
|
||||||
Handler: _DaemonService_SwitchProfile_Handler,
|
Handler: _DaemonService_SwitchProfile_Handler,
|
||||||
@@ -1897,18 +1641,6 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "WaitJWTToken",
|
MethodName: "WaitJWTToken",
|
||||||
Handler: _DaemonService_WaitJWTToken_Handler,
|
Handler: _DaemonService_WaitJWTToken_Handler,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
MethodName: "RequestExtendAuthSession",
|
|
||||||
Handler: _DaemonService_RequestExtendAuthSession_Handler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MethodName: "WaitExtendAuthSession",
|
|
||||||
Handler: _DaemonService_WaitExtendAuthSession_Handler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MethodName: "DismissSessionWarning",
|
|
||||||
Handler: _DaemonService_DismissSessionWarning_Handler,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
MethodName: "StartCPUProfile",
|
MethodName: "StartCPUProfile",
|
||||||
Handler: _DaemonService_StartCPUProfile_Handler,
|
Handler: _DaemonService_StartCPUProfile_Handler,
|
||||||
@@ -1921,17 +1653,8 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "GetInstallerResult",
|
MethodName: "GetInstallerResult",
|
||||||
Handler: _DaemonService_GetInstallerResult_Handler,
|
Handler: _DaemonService_GetInstallerResult_Handler,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
MethodName: "WailsUIReady",
|
|
||||||
Handler: _DaemonService_WailsUIReady_Handler,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{
|
Streams: []grpc.StreamDesc{
|
||||||
{
|
|
||||||
StreamName: "SubscribeStatus",
|
|
||||||
Handler: _DaemonService_SubscribeStatus_Handler,
|
|
||||||
ServerStreams: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
StreamName: "StartCapture",
|
StreamName: "StartCapture",
|
||||||
Handler: _DaemonService_StartCapture_Handler,
|
Handler: _DaemonService_StartCapture_Handler,
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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"
|
|
||||||
)
|
|
||||||
@@ -53,10 +53,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
|||||||
if engine != nil {
|
if engine != nil {
|
||||||
refreshStatus = func() {
|
refreshStatus = func() {
|
||||||
log.Debug("refreshing system health status for debug bundle")
|
log.Debug("refreshing system health status for debug bundle")
|
||||||
// Background ctx: the bundle wants a full, fresh probe regardless
|
engine.RunHealthProbes(true)
|
||||||
// of the DebugBundle RPC client's lifetime. The engine's own ctx
|
|
||||||
// still aborts it on shutdown.
|
|
||||||
engine.RunHealthProbes(context.Background(), true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +64,6 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
|||||||
StatusRecorder: s.statusRecorder,
|
StatusRecorder: s.statusRecorder,
|
||||||
SyncResponse: syncResponse,
|
SyncResponse: syncResponse,
|
||||||
LogPath: s.logFile,
|
LogPath: s.logFile,
|
||||||
UILogPath: s.uiLogPath,
|
|
||||||
CPUProfile: cpuProfileData,
|
CPUProfile: cpuProfileData,
|
||||||
CapturePath: capturePath,
|
CapturePath: capturePath,
|
||||||
RefreshStatus: refreshStatus,
|
RefreshStatus: refreshStatus,
|
||||||
@@ -128,26 +124,9 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (
|
|||||||
|
|
||||||
log.Infof("Log level set to %s", level.String())
|
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
|
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.
|
// SetSyncResponsePersistence sets the sync response persistence for the server.
|
||||||
func (s *Server) SetSyncResponsePersistence(_ context.Context, req *proto.SetSyncResponsePersistenceRequest) (*proto.SetSyncResponsePersistenceResponse, error) {
|
func (s *Server) SetSyncResponsePersistence(_ context.Context, req *proto.SetSyncResponsePersistenceRequest) (*proto.SetSyncResponsePersistenceResponse, error) {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/google/uuid"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
)
|
)
|
||||||
@@ -18,15 +16,6 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
|
|||||||
log.Debug("client subscribed to events")
|
log.Debug("client subscribed to events")
|
||||||
s.startUpdateManagerForGUI()
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case event := <-subscription.Events():
|
case event := <-subscription.Events():
|
||||||
@@ -39,24 +28,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -100,10 +99,7 @@ func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
|
|||||||
proto.SystemEvent_SYSTEM,
|
proto.SystemEvent_SYSTEM,
|
||||||
"MDM policy applied",
|
"MDM policy applied",
|
||||||
"NetBird configuration was updated by your IT policy.",
|
"NetBird configuration was updated by your IT policy.",
|
||||||
map[string]string{
|
map[string]string{"source": "mdm", "type": "policy_applied"},
|
||||||
proto.MetadataSourceKey: proto.MetadataSourceMDM,
|
|
||||||
proto.MetadataTypeKey: proto.MetadataTypePolicyApplied,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -128,8 +124,8 @@ func (s *Server) publishConfigChangedEvent(source string) {
|
|||||||
fmt.Sprintf("daemon config changed (source=%s)", source),
|
fmt.Sprintf("daemon config changed (source=%s)", source),
|
||||||
"",
|
"",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
proto.MetadataSourceKey: source,
|
"source": source,
|
||||||
proto.MetadataTypeKey: proto.MetadataTypeConfigChanged,
|
"type": "config_changed",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -164,7 +160,7 @@ func (s *Server) restartEngineForMDMLocked() error {
|
|||||||
s.clientGiveUpChan = make(chan struct{})
|
s.clientGiveUpChan = make(chan struct{})
|
||||||
log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config")
|
log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config")
|
||||||
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||||
s.publishConfigChangedEvent(proto.MetadataSourceMDM)
|
s.publishConfigChangedEvent("mdm")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,37 +181,6 @@ 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
|
// conflictString builds a conflictCheck for a string MDM key. An empty
|
||||||
// `got` is treated as "field not set" (no override requested); otherwise
|
// `got` is treated as "field not set" (no override requested); otherwise
|
||||||
// the check returns true only when the policy contains the key and its
|
// the check returns true only when the policy contains the key and its
|
||||||
@@ -291,7 +256,7 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
|
|||||||
}
|
}
|
||||||
|
|
||||||
return resolveConflicts(policy, []conflictCheck{
|
return resolveConflicts(policy, []conflictCheck{
|
||||||
conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
conflictString(mdm.KeyManagementURL, msg.ManagementUrl),
|
||||||
conflictString(mdm.KeyPreSharedKey, pskGot),
|
conflictString(mdm.KeyPreSharedKey, pskGot),
|
||||||
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
||||||
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||||
@@ -412,7 +377,7 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
|
|||||||
}
|
}
|
||||||
|
|
||||||
return resolveConflicts(policy, []conflictCheck{
|
return resolveConflicts(policy, []conflictCheck{
|
||||||
conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
|
conflictString(mdm.KeyManagementURL, msg.ManagementUrl),
|
||||||
conflictString(mdm.KeyPreSharedKey, pskGot),
|
conflictString(mdm.KeyPreSharedKey, pskGot),
|
||||||
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
||||||
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||||
|
|||||||
@@ -172,17 +172,6 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
|
|||||||
if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
|
if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
|
||||||
return nil, fmt.Errorf("select routes: %w", err)
|
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())
|
routeManager.TriggerSelection(routeManager.GetClientRoutes())
|
||||||
|
|
||||||
@@ -260,38 +249,3 @@ func toNetIDs(routes []string) []route.NetID {
|
|||||||
}
|
}
|
||||||
return netIDs
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
gstatus "google.golang.org/grpc/status"
|
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/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/expose"
|
"github.com/netbirdio/netbird/client/internal/expose"
|
||||||
@@ -68,19 +67,7 @@ type Server struct {
|
|||||||
|
|
||||||
logFile string
|
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
|
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
|
mutex sync.Mutex
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
@@ -100,7 +87,7 @@ type Server struct {
|
|||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
sessionWatcher *internal.SessionWatcher
|
sessionWatcher *internal.SessionWatcher
|
||||||
|
|
||||||
probeThrottle *probeThrottle
|
lastProbe time.Time
|
||||||
persistSyncResponse bool
|
persistSyncResponse bool
|
||||||
isSessionActive atomic.Bool
|
isSessionActive atomic.Bool
|
||||||
|
|
||||||
@@ -148,8 +135,6 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
|||||||
captureEnabled: captureEnabled,
|
captureEnabled: captureEnabled,
|
||||||
networksDisabled: networksDisabled,
|
networksDisabled: networksDisabled,
|
||||||
jwtCache: newJWTCache(),
|
jwtCache: newJWTCache(),
|
||||||
extendAuthSessionFlow: auth.NewPendingFlow(),
|
|
||||||
probeThrottle: newProbeThrottle(probeThreshold),
|
|
||||||
}
|
}
|
||||||
agent := &serverAgent{s}
|
agent := &serverAgent{s}
|
||||||
s.sleepHandler = sleephandler.New(agent)
|
s.sleepHandler = sleephandler.New(agent)
|
||||||
@@ -167,15 +152,6 @@ func (s *Server) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state := internal.CtxGetState(s.rootCtx)
|
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 {
|
if err := handlePanicLog(); err != nil {
|
||||||
log.Warnf("failed to redirect stderr: %v", err)
|
log.Warnf("failed to redirect stderr: %v", err)
|
||||||
@@ -259,7 +235,7 @@ func (s *Server) Start() error {
|
|||||||
s.clientRunningChan = make(chan struct{})
|
s.clientRunningChan = make(chan struct{})
|
||||||
s.clientGiveUpChan = make(chan struct{})
|
s.clientGiveUpChan = make(chan struct{})
|
||||||
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||||
s.publishConfigChangedEvent(proto.MetadataSourceStartup)
|
s.publishConfigChangedEvent("startup")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,10 +252,6 @@ func (s *Server) Start() error {
|
|||||||
// "intent" (clientRunning) is maintained by the RPC handlers, not by this
|
// "intent" (clientRunning) is maintained by the RPC handlers, not by this
|
||||||
// goroutine.
|
// goroutine.
|
||||||
func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) {
|
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() {
|
defer func() {
|
||||||
if giveUpChan != nil {
|
if giveUpChan != nil {
|
||||||
close(giveUpChan)
|
close(giveUpChan)
|
||||||
@@ -318,15 +290,6 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
|
|||||||
runOperation := func() error {
|
runOperation := func() error {
|
||||||
err := s.connect(ctx, profileConfig, statusRecorder, runningChan)
|
err := s.connect(ctx, profileConfig, statusRecorder, runningChan)
|
||||||
if err != nil {
|
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)
|
log.Debugf("run client connection exited with error: %v. Will retry in the background", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -461,7 +424,7 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
|
|||||||
wgPort := int(*msg.WireguardPort)
|
wgPort := int(*msg.WireguardPort)
|
||||||
config.WireguardPort = &wgPort
|
config.WireguardPort = &wgPort
|
||||||
}
|
}
|
||||||
if msg.OptionalPreSharedKey != nil {
|
if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != "" {
|
||||||
config.PreSharedKey = msg.OptionalPreSharedKey
|
config.PreSharedKey = msg.OptionalPreSharedKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,6 +575,8 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
return &proto.LoginResponse{}, nil
|
return &proto.LoginResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.Set(internal.StatusConnecting)
|
||||||
|
|
||||||
if msg.SetupKey == "" {
|
if msg.SetupKey == "" {
|
||||||
hint := ""
|
hint := ""
|
||||||
if msg.Hint != nil {
|
if msg.Hint != nil {
|
||||||
@@ -626,7 +591,6 @@ 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.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) {
|
||||||
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
|
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
|
||||||
log.Debugf("using previous oauth flow info")
|
log.Debugf("using previous oauth flow info")
|
||||||
state.Set(internal.StatusNeedsLogin)
|
|
||||||
return &proto.LoginResponse{
|
return &proto.LoginResponse{
|
||||||
NeedsSSOLogin: true,
|
NeedsSSOLogin: true,
|
||||||
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
|
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
|
||||||
@@ -663,11 +627,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
}, nil
|
}, 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 {
|
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
|
||||||
state.Set(loginStatus)
|
state.Set(loginStatus)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -676,43 +635,8 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
return &proto.LoginResponse{}, nil
|
return &proto.LoginResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitSSOLogin validates the supplied userCode against the in-flight OAuth
|
// WaitSSOLogin uses the userCode to validate the TokenInfo and
|
||||||
// device/PKCE flow and blocks until the user finishes the browser leg.
|
// waits for the user to continue with the login on a browser
|
||||||
//
|
|
||||||
// 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) {
|
func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLoginRequest) (*proto.WaitSSOLoginResponse, error) {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
if s.actCancel != nil {
|
if s.actCancel != nil {
|
||||||
@@ -720,21 +644,6 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(s.rootCtx)
|
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)
|
md, ok := metadata.FromIncomingContext(callerCtx)
|
||||||
if ok {
|
if ok {
|
||||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||||
@@ -760,11 +669,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Hold NeedsLogin for the whole browser wait — the login is not done
|
state.Set(internal.StatusConnecting)
|
||||||
// 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()
|
s.mutex.Lock()
|
||||||
flowInfo := s.oauthAuthFlow.info
|
flowInfo := s.oauthAuthFlow.info
|
||||||
@@ -791,30 +696,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
|||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
s.oauthAuthFlow.expiresAt = time.Now()
|
s.oauthAuthFlow.expiresAt = time.Now()
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
switch {
|
state.Set(internal.StatusLoginFailed)
|
||||||
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)
|
log.Errorf("waiting for browser login failed: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -871,22 +753,6 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
|||||||
return nil, err
|
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 {
|
if status != internal.StatusIdle {
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
return nil, fmt.Errorf("up already in progress: current status %s", status)
|
return nil, fmt.Errorf("up already in progress: current status %s", status)
|
||||||
@@ -949,12 +815,9 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
|||||||
s.clientGiveUpChan = make(chan struct{})
|
s.clientGiveUpChan = make(chan struct{})
|
||||||
|
|
||||||
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||||
s.publishConfigChangedEvent(proto.MetadataSourceUpRPC)
|
s.publishConfigChangedEvent("up_rpc")
|
||||||
|
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
if msg.GetAsync() {
|
|
||||||
return &proto.UpResponse{}, nil
|
|
||||||
}
|
|
||||||
return s.waitForUp(callerCtx)
|
return s.waitForUp(callerCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,10 +927,6 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
|
|||||||
|
|
||||||
s.config = config
|
s.config = config
|
||||||
|
|
||||||
if msg != nil && msg.ProfileName != nil {
|
|
||||||
s.publishProfileListChanged(*msg.ProfileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil
|
return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,37 +943,23 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state := internal.CtxGetState(s.rootCtx)
|
||||||
|
state.Set(internal.StatusIdle)
|
||||||
|
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
// Wait for the connectWithRetryRuns goroutine to finish with a short timeout.
|
// Wait for the connectWithRetryRuns goroutine to finish with a short timeout.
|
||||||
// This prevents the goroutine from setting ErrResetConnection after Down() returns.
|
// This prevents the goroutine from setting ErrResetConnection after Down() returns.
|
||||||
// The giveUpChan is closed by the goroutine's deferred cleanup (see
|
// The giveUpChan is closed at the end of connectWithRetryRuns.
|
||||||
// connectWithRetryRuns) on every exit path. A timeout here typically
|
|
||||||
// means the goroutine is still wedged inside a slow teardown step.
|
|
||||||
if giveUpChan != nil {
|
if giveUpChan != nil {
|
||||||
select {
|
select {
|
||||||
case <-giveUpChan:
|
case <-giveUpChan:
|
||||||
log.Debugf("client goroutine finished, giveUpChan closed")
|
log.Debugf("client goroutine finished successfully")
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
log.Warnf("timeout waiting for client goroutine to finish, proceeding anyway")
|
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
|
return &proto.DownResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1329,19 +1174,7 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := mgmClient.Logout(); err != nil {
|
return mgmClient.Logout()
|
||||||
// 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
|
// Status returns the daemon status
|
||||||
@@ -1394,24 +1227,9 @@ func (s *Server) Status(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.buildStatusResponse(ctx, msg)
|
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
if err != nil {
|
||||||
// state.Status() blanks the status when err is set (e.g. management
|
return nil, err
|
||||||
// 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() {
|
if status == internal.StatusNeedsLogin && s.isSessionActive.Load() {
|
||||||
@@ -1422,20 +1240,15 @@ func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusReque
|
|||||||
|
|
||||||
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
|
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.UpdateManagementAddress(s.config.ManagementURL.String())
|
||||||
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
|
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
|
||||||
|
|
||||||
if msg.GetFullPeerStatus {
|
if msg.GetFullPeerStatus {
|
||||||
s.runProbes(ctx, msg.ShouldRunProbes)
|
s.runProbes(msg.ShouldRunProbes)
|
||||||
fullStatus := s.statusRecorder.GetFullStatus()
|
fullStatus := s.statusRecorder.GetFullStatus()
|
||||||
pbFullStatus := fullStatus.ToProto()
|
pbFullStatus := fullStatus.ToProto()
|
||||||
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
||||||
pbFullStatus.SshServerState = s.getSSHServerState()
|
pbFullStatus.SshServerState = s.getSSHServerState()
|
||||||
pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision()
|
|
||||||
statusResponse.FullStatus = pbFullStatus
|
statusResponse.FullStatus = pbFullStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,151 +1469,6 @@ func (s *Server) WaitJWTToken(
|
|||||||
}, nil
|
}, 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.
|
// ExposeService exposes a local port via the NetBird reverse proxy.
|
||||||
func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error {
|
func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
@@ -1867,7 +1535,7 @@ func isUnixRunningDesktop() bool {
|
|||||||
return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
|
return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) {
|
func (s *Server) runProbes(waitForProbeResult bool) {
|
||||||
if s.connectClient == nil {
|
if s.connectClient == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1877,7 +1545,15 @@ func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.probeThrottle.Run(ctx, engine, s.statusRecorder, waitForProbeResult)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfig of the daemon.
|
// GetConfig of the daemon.
|
||||||
@@ -2006,8 +1682,6 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
|
|||||||
return nil, fmt.Errorf("failed to create profile: %w", err)
|
return nil, fmt.Errorf("failed to create profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.publishProfileListChanged(msg.ProfileName)
|
|
||||||
|
|
||||||
return &proto.AddProfileResponse{Id: created.ID.String()}, nil
|
return &proto.AddProfileResponse{Id: created.ID.String()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2034,8 +1708,6 @@ func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequ
|
|||||||
return nil, fmt.Errorf("failed to rename profile: %w", err)
|
return nil, fmt.Errorf("failed to rename profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.publishProfileListChanged(msg.NewProfileName)
|
|
||||||
|
|
||||||
return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil
|
return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2066,51 +1738,9 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
|
|||||||
return nil, fmt.Errorf("failed to remove profile: %w", err)
|
return nil, fmt.Errorf("failed to remove profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.publishProfileListChanged(msg.ProfileName)
|
|
||||||
|
|
||||||
return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil
|
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.
|
// ListProfiles lists all profiles in the daemon.
|
||||||
func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) {
|
func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
@@ -2182,33 +1812,11 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
|
|||||||
DisableProfiles: s.checkProfilesDisabled(),
|
DisableProfiles: s.checkProfilesDisabled(),
|
||||||
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
|
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
|
||||||
DisableNetworks: s.checkNetworksDisabled(),
|
DisableNetworks: s.checkNetworksDisabled(),
|
||||||
DisableAdvancedView: s.checkDisableAdvancedView(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return features, nil
|
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 {
|
func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}) error {
|
||||||
log.Tracef("running client connection")
|
log.Tracef("running client connection")
|
||||||
client := internal.NewConnectClient(ctx, config, statusRecorder)
|
client := internal.NewConnectClient(ctx, config, statusRecorder)
|
||||||
@@ -2378,28 +1986,3 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -60,25 +59,9 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestConnectStopsRetryOnPermissionDenied verifies connectWithRetryRuns stops after a single login
|
// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables
|
||||||
// attempt on PermissionDenied, despite the fast retry config that would otherwise drive several.
|
// we will use a management server started via to simulate the server and capture the number of retries
|
||||||
func TestConnectStopsRetryOnPermissionDenied(t *testing.T) {
|
func TestConnectWithRetryRuns(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
|
// start the signal server
|
||||||
_, signalAddr, err := startSignal(t)
|
_, signalAddr, err := startSignal(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,8 +113,8 @@ func TestConnectStopsRetryOnPermissionDenied(t *testing.T) {
|
|||||||
t.Setenv(retryMultiplierVar, "1")
|
t.Setenv(retryMultiplierVar, "1")
|
||||||
|
|
||||||
s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
|
s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
|
||||||
if counter != 1 {
|
if counter < 3 {
|
||||||
t.Fatalf("expected exactly 1 login attempt (PermissionDenied must stop the retry loop), got %d", counter)
|
t.Fatalf("expected counter > 2, got %d", counter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,43 +181,6 @@ func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
|
|||||||
require.NotNil(t, resp)
|
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) {
|
func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||||
// No MDM policy active: any field can be written.
|
// No MDM policy active: any field can be written.
|
||||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,3 @@
|
|||||||
// This file is intentionally named test.go (not test_test.go) so the exported
|
|
||||||
// StartTestServer helper is visible to the ssh/proxy and ssh/client external
|
|
||||||
// test packages, not just this package's own tests. The //go:build !js tag
|
|
||||||
// keeps its "testing" import — and the whole testing/flag/regexp transitive
|
|
||||||
// chain it drags in — out of the wasm client, which links ssh/server through
|
|
||||||
// the engine but never runs Go tests under GOOS=js.
|
|
||||||
//go:build !js
|
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -55,10 +55,6 @@ type ConvertOptions struct {
|
|||||||
IPsFilter map[string]struct{}
|
IPsFilter map[string]struct{}
|
||||||
ConnectionTypeFilter string
|
ConnectionTypeFilter string
|
||||||
ProfileName 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 {
|
type PeerStateDetailOutput struct {
|
||||||
@@ -159,11 +155,6 @@ type OutputOverview struct {
|
|||||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"`
|
LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"`
|
||||||
ProfileName string `json:"profileName" yaml:"profileName"`
|
ProfileName string `json:"profileName" yaml:"profileName"`
|
||||||
SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"`
|
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.
|
// ConvertToStatusOutputOverview converts protobuf status to the output overview.
|
||||||
@@ -210,10 +201,6 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
|||||||
ProfileName: opts.ProfileName,
|
ProfileName: opts.ProfileName,
|
||||||
SSHServerState: sshServerOverview,
|
SSHServerState: sshServerOverview,
|
||||||
}
|
}
|
||||||
if !opts.SessionExpiresAt.IsZero() {
|
|
||||||
t := opts.SessionExpiresAt
|
|
||||||
overview.SessionExpiresAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.Anonymize {
|
if opts.Anonymize {
|
||||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||||
@@ -560,15 +547,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
|||||||
|
|
||||||
peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
|
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
|
var forwardingRulesString string
|
||||||
if o.NumberOfForwardingRules > 0 {
|
if o.NumberOfForwardingRules > 0 {
|
||||||
forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules)
|
forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules)
|
||||||
@@ -615,7 +593,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
|||||||
"SSH Server: %s\n"+
|
"SSH Server: %s\n"+
|
||||||
"Networks: %s\n"+
|
"Networks: %s\n"+
|
||||||
"%s"+
|
"%s"+
|
||||||
"%s"+
|
|
||||||
"Peers count: %s\n",
|
"Peers count: %s\n",
|
||||||
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
|
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
|
||||||
daemonVersion,
|
daemonVersion,
|
||||||
@@ -635,7 +612,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
|||||||
sshServerStatus,
|
sshServerStatus,
|
||||||
networks,
|
networks,
|
||||||
forwardingRulesString,
|
forwardingRulesString,
|
||||||
sessionExpiryString,
|
|
||||||
peersCountString,
|
peersCountString,
|
||||||
)
|
)
|
||||||
return summary
|
return summary
|
||||||
@@ -1049,57 +1025,3 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
|||||||
overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -648,53 +648,6 @@ 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) {
|
func TestMapRelaysTransport(t *testing.T) {
|
||||||
out := mapRelays([]*proto.RelayState{
|
out := mapRelays([]*proto.RelayState{
|
||||||
{URI: "rels://relay.example:443", Available: true, Transport: "quic"},
|
{URI: "rels://relay.example:443", Available: true, Transport: "quic"},
|
||||||
|
|||||||
8
client/ui/.gitignore
vendored
@@ -1,8 +0,0 @@
|
|||||||
.task
|
|
||||||
bin
|
|
||||||
frontend/dist
|
|
||||||
frontend/node_modules
|
|
||||||
frontend/bindings
|
|
||||||
frontend/.vite
|
|
||||||
build/linux/appimage/build
|
|
||||||
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
|
||||||
BIN
client/ui/Netbird.icns
Normal file
@@ -1,58 +0,0 @@
|
|||||||
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
|
|
||||||
BIN
client/ui/assets/connected.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
client/ui/assets/disconnected.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
client/ui/assets/netbird-disconnected.ico
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
client/ui/assets/netbird-disconnected.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 526 B |
|
Before Width: | Height: | Size: 739 B |
|
Before Width: | Height: | Size: 838 B |
|
Before Width: | Height: | Size: 508 B |
|
Before Width: | Height: | Size: 615 B |
|
Before Width: | Height: | Size: 452 B |
|
Before Width: | Height: | Size: 520 B |
|
Before Width: | Height: | Size: 637 B |
|
Before Width: | Height: | Size: 452 B |
|
Before Width: | Height: | Size: 532 B |
|
Before Width: | Height: | Size: 629 B |
|
Before Width: | Height: | Size: 433 B |
|
Before Width: | Height: | Size: 490 B |
|
Before Width: | Height: | Size: 602 B |
|
Before Width: | Height: | Size: 483 B |
|
Before Width: | Height: | Size: 512 B |
|
Before Width: | Height: | Size: 605 B |
|
Before Width: | Height: | Size: 456 B |