mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-12 01:29:56 +00:00
Compare commits
29 Commits
dependabot
...
update-pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
583555b81e | ||
|
|
d0d6dd4b0c | ||
|
|
47352e6e45 | ||
|
|
91acb8147c | ||
|
|
c9d387bd0d | ||
|
|
3aa6c02b93 | ||
|
|
f6900fb07c | ||
|
|
4b3dd9103d | ||
|
|
8e3b284f4b | ||
|
|
21aa933584 | ||
|
|
1dfa85a917 | ||
|
|
859fe19fff | ||
|
|
e40cb294f6 | ||
|
|
e203e0f42a | ||
|
|
167be3a30f | ||
|
|
1d8b5f6e5c | ||
|
|
7d4736de55 | ||
|
|
06839a4731 | ||
|
|
eb422a5cd3 | ||
|
|
0aa0f7c76b | ||
|
|
7c0d8cbae0 | ||
|
|
2ab99eefa6 | ||
|
|
ff04ffb534 | ||
|
|
980598ed4a | ||
|
|
92a66cdd20 | ||
|
|
3be90f06b2 | ||
|
|
4ef65294e9 | ||
|
|
5b5f11740a | ||
|
|
3de889d529 |
18
.coderabbit.yaml
Normal file
18
.coderabbit.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||||
|
language: en-US
|
||||||
|
reviews:
|
||||||
|
profile: chill
|
||||||
|
request_changes_workflow: false
|
||||||
|
high_level_summary: true
|
||||||
|
poem: false
|
||||||
|
review_status: true
|
||||||
|
auto_review:
|
||||||
|
enabled: true
|
||||||
|
drafts: false
|
||||||
|
path_filters:
|
||||||
|
- "!**/*.tsx"
|
||||||
|
- "!**/*.ts"
|
||||||
|
- "!**/*.js"
|
||||||
|
- "!**/*.svg"
|
||||||
|
chat:
|
||||||
|
auto_reply: true
|
||||||
@@ -6,7 +6,6 @@ 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
|
||||||
|
|||||||
68
.github/workflows/agent-network-e2e.yml
vendored
Normal file
68
.github/workflows/agent-network-e2e.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
name: Agent Network E2E
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Nightly at 03:00 UTC, plus on demand from the Actions tab.
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
name: Agent Network E2E
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
|
with:
|
||||||
|
go-version-file: "go.mod"
|
||||||
|
|
||||||
|
# Container-driver builder so the harness can build the combined/proxy/
|
||||||
|
# client images from source with a local layer cache.
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
|
# Persist the Docker layer cache across runs. This caches the base, apt,
|
||||||
|
# and go-mod-download layers; the Go compile still re-runs, as BuildKit
|
||||||
|
# mount caches cannot be exported to the GitHub cache.
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: /tmp/.buildx-cache
|
||||||
|
key: ${{ runner.os }}-anet-e2e-buildx-${{ hashFiles('go.sum', 'combined/Dockerfile.multistage', 'proxy/Dockerfile.multistage', 'e2e/harness/Dockerfile.client') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-anet-e2e-buildx-
|
||||||
|
|
||||||
|
- name: Run agent-network e2e
|
||||||
|
env:
|
||||||
|
# Build the images from source (this branch's code) with the shared
|
||||||
|
# local layer cache.
|
||||||
|
NB_E2E_BUILDX_CACHE: /tmp/.buildx-cache
|
||||||
|
# Provider credentials. Each provider scenario skips if its
|
||||||
|
# token (and URL, for gateways) is unset, so partial coverage is fine.
|
||||||
|
OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }}
|
||||||
|
ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }}
|
||||||
|
VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }}
|
||||||
|
VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }}
|
||||||
|
OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }}
|
||||||
|
OPENROUTER_TOKEN: ${{ secrets.E2E_OPENROUTER_TOKEN }}
|
||||||
|
CLOUDFLARE_URL: ${{ secrets.E2E_CLOUDFLARE_URL }}
|
||||||
|
CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }}
|
||||||
|
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }}
|
||||||
|
AWS_REGION: ${{ secrets.E2E_AWS_REGION }}
|
||||||
|
# Vertex (Anthropic-on-Vertex): SA + project required; region defaults
|
||||||
|
# to "global", model to a pinned claude snapshot.
|
||||||
|
GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }}
|
||||||
|
GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }}
|
||||||
|
GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }}
|
||||||
|
GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }}
|
||||||
|
run: go test -tags e2e -timeout 40m -v ./e2e/...
|
||||||
98
.github/workflows/frontend-ui.yml
vendored
Normal file
98
.github/workflows/frontend-ui.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
name: UI Frontend
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "client/ui/frontend/**"
|
||||||
|
- "client/ui/i18n/**"
|
||||||
|
- "client/ui/**/*.go"
|
||||||
|
- ".github/workflows/frontend-ui.yml"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "client/ui/frontend/**"
|
||||||
|
- "client/ui/i18n/**"
|
||||||
|
- "client/ui/**/*.go"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-build:
|
||||||
|
name: Lint & Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: client/ui/frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||||
|
with:
|
||||||
|
version: 11
|
||||||
|
|
||||||
|
# Bindings are generated by wails3 from the Go service definitions and
|
||||||
|
# are not checked in (see client/ui/frontend/bindings/). Without them,
|
||||||
|
# typecheck/build fail on missing module imports.
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
|
with:
|
||||||
|
go-version-file: "go.mod"
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
# wails3 CLI links against GTK4 / WebKitGTK 6.0 via its internal/operatingsystem
|
||||||
|
# package, so the dev libraries must be present before `go install`.
|
||||||
|
- name: Install Wails Linux system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config \
|
||||||
|
libgtk-4-dev \
|
||||||
|
libwebkitgtk-6.0-dev
|
||||||
|
|
||||||
|
- name: Install wails3 CLI
|
||||||
|
# Version derived from go.mod so the binding generator always matches
|
||||||
|
# the wails runtime the daemon links against.
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||||
|
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-store
|
||||||
|
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache pnpm store
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-store.outputs.path }}
|
||||||
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('client/ui/frontend/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate Wails bindings
|
||||||
|
run: pnpm run bindings
|
||||||
|
|
||||||
|
- name: Lint, typecheck, format
|
||||||
|
run: pnpm check
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
10
.github/workflows/golang-test-darwin.yml
vendored
10
.github/workflows/golang-test-darwin.yml
vendored
@@ -45,7 +45,15 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged)
|
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||||
|
# which fails to compile until the frontend has been built. The Wails UI
|
||||||
|
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||||
|
# before goreleaser.
|
||||||
|
# `go list -e` lets the listing succeed even though the embed fails to
|
||||||
|
# resolve; the grep then drops the broken package by path. Without -e,
|
||||||
|
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||||
|
# root, which has no Go files.
|
||||||
|
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged)
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- 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
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-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||||
|
|
||||||
- name: Install 32-bit libpcap
|
- 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-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||||
|
|
||||||
- name: Install 32-bit libpcap
|
- name: Install 32-bit libpcap
|
||||||
if: matrix.arch == '386'
|
if: matrix.arch == '386'
|
||||||
@@ -158,7 +158,15 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||||
|
# which fails to compile until the frontend has been built. The Wails UI
|
||||||
|
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||||
|
# before goreleaser.
|
||||||
|
# `go list -e` lets the listing succeed even though the embed fails to
|
||||||
|
# resolve; the grep then drops the broken package by path. Without -e,
|
||||||
|
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||||
|
# root, which has no Go files.
|
||||||
|
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,CGO_ENABLED' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged)
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
@@ -168,7 +176,6 @@ 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]
|
||||||
@@ -229,7 +236,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 -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged)
|
go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged)
|
||||||
'
|
'
|
||||||
|
|
||||||
test_relay:
|
test_relay:
|
||||||
|
|||||||
9
.github/workflows/golang-test-windows.yml
vendored
9
.github/workflows/golang-test-windows.yml
vendored
@@ -65,8 +65,15 @@ jobs:
|
|||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe 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 ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
|
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' }
|
||||||
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
$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
|
||||||
|
|||||||
23
.github/workflows/golangci-lint.yml
vendored
23
.github/workflows/golangci-lint.yml
vendored
@@ -21,8 +21,16 @@ jobs:
|
|||||||
- name: codespell
|
- name: codespell
|
||||||
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
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals,flate,recordin,unparseable
|
||||||
skip: go.mod,go.sum,**/proxy/web/**
|
# Non-English UI translations trip codespell on real foreign words
|
||||||
|
# (de: "Sie", "oder", "ist"). Only en/common.json is the source of
|
||||||
|
# truth that should be spell-checked. List each translated locale
|
||||||
|
# dir below and add new ones as languages are added under
|
||||||
|
# client/ui/i18n/locales/. Single-star globs are matched per path
|
||||||
|
# segment by codespell and behave the same across versions; the
|
||||||
|
# recursive "**" form did not take effect with the codespell shipped
|
||||||
|
# by this action.
|
||||||
|
skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md
|
||||||
golangci:
|
golangci:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -54,7 +62,16 @@ 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-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
|
- name: Stub Wails frontend bundle
|
||||||
|
# client/ui/main.go has //go:embed all:frontend/dist. The
|
||||||
|
# directory is produced by `pnpm run build` and is gitignored, so
|
||||||
|
# lint-only runs (no frontend toolchain) need a placeholder file
|
||||||
|
# for the embed pattern to match.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p client/ui/frontend/dist
|
||||||
|
touch client/ui/frontend/dist/.embed-placeholder
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||||
with:
|
with:
|
||||||
|
|||||||
86
.github/workflows/release.yml
vendored
86
.github/workflows/release.yml
vendored
@@ -9,7 +9,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SIGN_PIPE_VER: "v0.1.6"
|
SIGN_PIPE_VER: "v0.1.8"
|
||||||
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/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
@@ -293,8 +293,11 @@ 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") | select(.goarch == "amd64") | .name | select(startswith("ghcr.io/"))' /tmp/goreleaser-artifacts.json
|
jq -r '.[] | select(.type == "Docker Image") | .name | select(startswith("ghcr.io/") and endswith("-amd64"))' /tmp/goreleaser-artifacts.json
|
||||||
)
|
)
|
||||||
|
|
||||||
for src in "${src_images[@]}"; do
|
for src in "${src_images[@]}"; do
|
||||||
@@ -394,8 +397,18 @@ 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 libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
- name: Decode GPG signing key
|
- 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
|
||||||
@@ -414,10 +427,16 @@ 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/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso
|
run: goversioninfo -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
@@ -486,6 +505,20 @@ 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
|
||||||
@@ -573,23 +606,6 @@ 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:
|
||||||
@@ -612,6 +628,28 @@ 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
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-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
- name: Install golangci-lint
|
- 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
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
.claude
|
||||||
.idea
|
.idea
|
||||||
.run
|
.run
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ 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,6 +212,7 @@ 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:
|
||||||
@@ -226,6 +227,7 @@ 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,6 +2,15 @@ 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
|
||||||
@@ -62,6 +71,8 @@ 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:
|
||||||
@@ -71,9 +82,9 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/netbird.desktop
|
- src: client/ui/build/linux/netbird.desktop
|
||||||
dst: /usr/share/applications/netbird.desktop
|
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||||
- src: client/ui/assets/netbird.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
@@ -81,6 +92,8 @@ 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:
|
||||||
@@ -90,12 +103,13 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/netbird.desktop
|
- src: client/ui/build/linux/netbird.desktop
|
||||||
dst: /usr/share/applications/netbird.desktop
|
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||||
- src: client/ui/assets/netbird.png
|
- src: client/ui/build/appicon.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,6 +1,15 @@
|
|||||||
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
|
||||||
@@ -20,8 +29,6 @@ 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,13 +79,21 @@ dependencies are installed. Here is a short guide on how that can be done.
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
#### Go 1.21
|
#### Go 1.25
|
||||||
|
|
||||||
Follow the installation guide from https://go.dev/
|
Follow the installation guide from https://go.dev/
|
||||||
|
|
||||||
#### UI client - Fyne toolkit
|
#### UI client - Wails v3 + React
|
||||||
|
|
||||||
We use the fyne toolkit in our UI client. You can follow its requirement guide to have all its dependencies installed: https://developer.fyne.io/started/#prerequisites
|
The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need:
|
||||||
|
|
||||||
|
- Go ≥ 1.25
|
||||||
|
- Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`)
|
||||||
|
- The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
|
||||||
|
- The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest`
|
||||||
|
- Linux only: `libwebkitgtk-6.0-dev`, `libgtk-4-dev`, `libsoup-3.0-dev`
|
||||||
|
|
||||||
|
All UI build, dev-loop, and cross-compile commands are described in the [UI client](#ui-client) section below.
|
||||||
|
|
||||||
#### gRPC
|
#### 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.
|
||||||
@@ -214,6 +222,39 @@ 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:
|
||||||
@@ -251,10 +292,10 @@ Create dist directory
|
|||||||
mkdir -p dist/netbird_windows_amd64
|
mkdir -p dist/netbird_windows_amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
UI client
|
UI client (built with Wails v3 — see the [UI client](#ui-client) section above)
|
||||||
```shell
|
```shell
|
||||||
CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -o netbird-ui.exe -ldflags "-s -w -H windowsgui" ./client/ui
|
(cd client/ui && CGO_ENABLED=1 task windows:build)
|
||||||
mv netbird-ui.exe ./dist/netbird_windows_amd64/
|
mv client/ui/bin/netbird-ui.exe ./dist/netbird_windows_amd64/
|
||||||
```
|
```
|
||||||
|
|
||||||
Client
|
Client
|
||||||
@@ -291,8 +332,6 @@ 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
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ var (
|
|||||||
EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay
|
EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay
|
||||||
|
|
||||||
// EnvKeyNBLazyConn Exported for Android java client to configure lazy connection
|
// EnvKeyNBLazyConn Exported for Android java client to configure lazy connection
|
||||||
EnvKeyNBLazyConn = lazyconn.EnvEnableLazyConn
|
EnvKeyNBLazyConn = lazyconn.EnvLazyConn
|
||||||
|
|
||||||
// EnvKeyNBInactivityThreshold Exported for Android java client to configure connection inactivity threshold
|
// EnvKeyNBInactivityThreshold Exported for Android java client to configure connection inactivity threshold
|
||||||
EnvKeyNBInactivityThreshold = lazyconn.EnvInactivityThreshold
|
EnvKeyNBInactivityThreshold = lazyconn.EnvInactivityThreshold
|
||||||
|
|||||||
@@ -22,11 +22,19 @@ 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{
|
||||||
@@ -61,6 +69,16 @@ 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 {
|
||||||
@@ -152,6 +170,65 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -71,12 +71,14 @@ var (
|
|||||||
extraIFaceBlackList []string
|
extraIFaceBlackList []string
|
||||||
anonymizeFlag bool
|
anonymizeFlag bool
|
||||||
dnsRouteInterval time.Duration
|
dnsRouteInterval time.Duration
|
||||||
lazyConnEnabled bool
|
// lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection
|
||||||
mtu uint16
|
// flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead).
|
||||||
profilesDisabled bool
|
lazyConnEnabled bool
|
||||||
updateSettingsDisabled bool
|
mtu uint16
|
||||||
captureEnabled bool
|
profilesDisabled bool
|
||||||
networksDisabled bool
|
updateSettingsDisabled bool
|
||||||
|
captureEnabled bool
|
||||||
|
networksDisabled bool
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "netbird",
|
Use: "netbird",
|
||||||
@@ -210,7 +212,8 @@ func init() {
|
|||||||
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
|
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
|
||||||
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
|
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
|
||||||
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
|
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
|
||||||
upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "[Experimental] Enable the lazy connection feature. If enabled, the client will establish connections on-demand. Note: this setting may be overridden by management configuration.")
|
upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.")
|
||||||
|
_ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -22,15 +23,21 @@ var serviceCmd = &cobra.Command{
|
|||||||
Short: "Manage the NetBird daemon service",
|
Short: "Manage the NetBird daemon service",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultJSONSocket = "unix:///var/run/netbird-http.sock"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
serviceName string
|
serviceName string
|
||||||
serviceEnvVars []string
|
serviceEnvVars []string
|
||||||
|
jsonSocket string
|
||||||
|
enableJSONSocket bool
|
||||||
)
|
)
|
||||||
|
|
||||||
type program struct {
|
type program struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
serv *grpc.Server
|
serv *grpc.Server
|
||||||
|
jsonServ *http.Server
|
||||||
|
jsonServMu sync.Mutex
|
||||||
serverInstance *server.Server
|
serverInstance *server.Server
|
||||||
serverInstanceMu sync.Mutex
|
serverInstanceMu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -46,6 +53,8 @@ func init() {
|
|||||||
serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings")
|
serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings")
|
||||||
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
|
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
|
||||||
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
|
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
|
||||||
|
serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket")
|
||||||
|
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
|
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
|
||||||
serviceEnvDesc := `Sets extra environment variables for the service. ` +
|
serviceEnvDesc := `Sets extra environment variables for the service. ` +
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
@@ -22,41 +19,56 @@ import (
|
|||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func validateJSONSocketFlags() error {
|
||||||
|
if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket {
|
||||||
|
return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *program) Start(svc service.Service) error {
|
func (p *program) Start(svc service.Service) error {
|
||||||
// Start should not block. Do the actual work async.
|
// Start should not block. Do the actual work async.
|
||||||
log.Info("starting NetBird service") //nolint
|
log.Info("starting NetBird service") //nolint
|
||||||
|
|
||||||
|
if err := validateJSONSocketFlags(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Collect static system and platform information
|
// Collect static system and platform information
|
||||||
system.UpdateStaticInfoAsync()
|
system.UpdateStaticInfoAsync()
|
||||||
|
|
||||||
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
|
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
|
||||||
p.serv = grpc.NewServer()
|
p.serv = grpc.NewServer()
|
||||||
|
|
||||||
split := strings.Split(daemonAddr, "://")
|
daemonListener, err := listenOnAddress(daemonAddr)
|
||||||
switch split[0] {
|
|
||||||
case "unix":
|
|
||||||
// cleanup failed close
|
|
||||||
stat, err := os.Stat(split[1])
|
|
||||||
if err == nil && !stat.IsDir() {
|
|
||||||
if err := os.Remove(split[1]); err != nil {
|
|
||||||
log.Debugf("remove socket file: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "tcp":
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported daemon address protocol: %v", split[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
listen, err := net.Listen(split[0], split[1])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listen daemon interface: %w", err)
|
return fmt.Errorf("listen daemon interface: %w", err)
|
||||||
}
|
}
|
||||||
go func() {
|
|
||||||
defer listen.Close()
|
|
||||||
|
|
||||||
if split[0] == "unix" {
|
var jsonListener *socketListener
|
||||||
if err := os.Chmod(split[1], 0666); err != nil {
|
if enableJSONSocket {
|
||||||
log.Errorf("failed setting daemon permissions: %v", split[1])
|
jsonListener, err = listenOnAddress(jsonSocket)
|
||||||
|
if err != nil {
|
||||||
|
_ = daemonListener.Close()
|
||||||
|
return fmt.Errorf("listen daemon JSON interface: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removeStaleUnixSocketForAddress(jsonSocket)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer daemonListener.Close()
|
||||||
|
if jsonListener != nil {
|
||||||
|
defer jsonListener.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if jsonListener != nil {
|
||||||
|
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
|
||||||
|
log.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,8 +83,16 @@ func (p *program) Start(svc service.Service) error {
|
|||||||
p.serverInstance = serverInstance
|
p.serverInstance = serverInstance
|
||||||
p.serverInstanceMu.Unlock()
|
p.serverInstanceMu.Unlock()
|
||||||
|
|
||||||
log.Printf("started daemon server: %v", split[1])
|
if jsonListener != nil {
|
||||||
if err := p.serv.Serve(listen); err != nil {
|
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
||||||
|
log.Fatalf("failed to start daemon JSON server: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Debug("daemon JSON socket disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("started daemon server: %v", daemonListener.address)
|
||||||
|
if err := p.serv.Serve(daemonListener.Listener); err != nil {
|
||||||
log.Errorf("failed to serve daemon requests: %v", err)
|
log.Errorf("failed to serve daemon requests: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -92,6 +112,20 @@ func (p *program) Stop(srv service.Service) error {
|
|||||||
|
|
||||||
p.cancel()
|
p.cancel()
|
||||||
|
|
||||||
|
p.jsonServMu.Lock()
|
||||||
|
jsonServ := p.jsonServ
|
||||||
|
p.jsonServMu.Unlock()
|
||||||
|
if jsonServ != nil {
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
if err := jsonServ.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Errorf("failed to stop daemon JSON server gracefully: %v", err)
|
||||||
|
if err := jsonServ.Close(); err != nil {
|
||||||
|
log.Errorf("failed to close daemon JSON server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shutdownCancel()
|
||||||
|
}
|
||||||
|
|
||||||
if p.serv != nil {
|
if p.serv != nil {
|
||||||
p.serv.Stop()
|
p.serv.Stop()
|
||||||
}
|
}
|
||||||
@@ -148,6 +182,9 @@ var runCmd = &cobra.Command{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := validateJSONSocketFlags(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return s.Run()
|
return s.Run()
|
||||||
},
|
},
|
||||||
@@ -162,6 +199,9 @@ var startCmd = &cobra.Command{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := validateJSONSocketFlags(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.Start(); err != nil {
|
if err := s.Start(); err != nil {
|
||||||
return fmt.Errorf("start service: %w", err)
|
return fmt.Errorf("start service: %w", err)
|
||||||
@@ -198,6 +238,9 @@ var restartCmd = &cobra.Command{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := validateJSONSocketFlags(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.Restart(); err != nil {
|
if err := s.Restart(); err != nil {
|
||||||
return fmt.Errorf("restart service: %w", err)
|
return fmt.Errorf("restart service: %w", err)
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ func buildServiceArguments() []string {
|
|||||||
args = append(args, "--disable-networks")
|
args = append(args, "--disable-networks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if enableJSONSocket {
|
||||||
|
args = append(args, "--enable-json-socket", "--json-socket", jsonSocket)
|
||||||
|
}
|
||||||
|
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +110,10 @@ func configurePlatformSpecificSettings(svcConfig *service.Config) error {
|
|||||||
|
|
||||||
// Create fully configured service config for install/reconfigure
|
// Create fully configured service config for install/reconfigure
|
||||||
func createServiceConfigForInstall() (*service.Config, error) {
|
func createServiceConfigForInstall() (*service.Config, error) {
|
||||||
|
if err := validateJSONSocketFlags(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
svcConfig, err := newSVCConfig()
|
svcConfig, err := newSVCConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create service config: %w", err)
|
return nil, fmt.Errorf("create service config: %w", err)
|
||||||
|
|||||||
52
client/cmd/service_json_gateway.go
Normal file
52
client/cmd/service_json_gateway.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//go:build !ios && !android
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func grpcGatewayEndpoint(addr string) string {
|
||||||
|
return strings.TrimPrefix(addr, "tcp://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
|
||||||
|
mux := runtime.NewServeMux()
|
||||||
|
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||||
|
if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonServer := &http.Server{
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
BaseContext: func(net.Listener) context.Context {
|
||||||
|
return p.ctx
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p.jsonServMu.Lock()
|
||||||
|
p.jsonServ = jsonServer
|
||||||
|
p.jsonServMu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("started daemon JSON server: %v", jsonListener.address)
|
||||||
|
if err := jsonServer.Serve(jsonListener.Listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Errorf("failed to serve daemon JSON requests: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
176
client/cmd/service_json_socket_test.go
Normal file
176
client/cmd/service_json_socket_test.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
//go:build !ios && !android
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func preserveJSONSocketTestState(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
origJSONSocket := jsonSocket
|
||||||
|
origEnableJSONSocket := enableJSONSocket
|
||||||
|
origChanged := map[string]bool{}
|
||||||
|
serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
|
||||||
|
origChanged[flag.Name] = flag.Changed
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
jsonSocket = origJSONSocket
|
||||||
|
enableJSONSocket = origEnableJSONSocket
|
||||||
|
serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
|
||||||
|
flag.Changed = origChanged[flag.Name]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONSocketFlagsArePositiveEnableOnly(t *testing.T) {
|
||||||
|
assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("enable-json-socket"))
|
||||||
|
assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("json-socket"))
|
||||||
|
assert.Nil(t, serviceCmd.PersistentFlags().Lookup("disable-json-socket"))
|
||||||
|
assert.Equal(t, "false", serviceCmd.PersistentFlags().Lookup("enable-json-socket").DefValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildServiceArgumentsDefaultDisablesJSONSocket(t *testing.T) {
|
||||||
|
preserveJSONSocketTestState(t)
|
||||||
|
|
||||||
|
enableJSONSocket = false
|
||||||
|
jsonSocket = "tcp://127.0.0.1:8080"
|
||||||
|
|
||||||
|
args := buildServiceArguments()
|
||||||
|
|
||||||
|
assert.NotContains(t, args, "--enable-json-socket")
|
||||||
|
assert.NotContains(t, args, "--json-socket")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildServiceArgumentsIncludesJSONSocketWhenEnabled(t *testing.T) {
|
||||||
|
preserveJSONSocketTestState(t)
|
||||||
|
|
||||||
|
enableJSONSocket = true
|
||||||
|
jsonSocket = "tcp://127.0.0.1:8080"
|
||||||
|
|
||||||
|
args := buildServiceArguments()
|
||||||
|
|
||||||
|
enableIndex := indexOfArg(args, "--enable-json-socket")
|
||||||
|
jsonIndex := indexOfArg(args, "--json-socket")
|
||||||
|
require.NotEqual(t, -1, enableIndex)
|
||||||
|
require.NotEqual(t, -1, jsonIndex)
|
||||||
|
require.Less(t, enableIndex, jsonIndex)
|
||||||
|
require.Less(t, jsonIndex+1, len(args))
|
||||||
|
assert.Equal(t, "tcp://127.0.0.1:8080", args[jsonIndex+1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONSocketWithoutEnableValidation(t *testing.T) {
|
||||||
|
preserveJSONSocketTestState(t)
|
||||||
|
|
||||||
|
enableJSONSocket = false
|
||||||
|
require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080"))
|
||||||
|
|
||||||
|
err := validateJSONSocketFlags()
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "--enable-json-socket")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONSocketWithEnableValidation(t *testing.T) {
|
||||||
|
preserveJSONSocketTestState(t)
|
||||||
|
|
||||||
|
require.NoError(t, serviceCmd.PersistentFlags().Set("enable-json-socket", "true"))
|
||||||
|
require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080"))
|
||||||
|
|
||||||
|
assert.NoError(t, validateJSONSocketFlags())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONSocketServiceParamsPersistEnableAndAddress(t *testing.T) {
|
||||||
|
preserveJSONSocketTestState(t)
|
||||||
|
serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
|
||||||
|
flag.Changed = false
|
||||||
|
})
|
||||||
|
|
||||||
|
enableJSONSocket = true
|
||||||
|
jsonSocket = "tcp://127.0.0.1:8080"
|
||||||
|
|
||||||
|
params := currentServiceParams()
|
||||||
|
require.True(t, params.EnableJSONSocket)
|
||||||
|
require.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket)
|
||||||
|
|
||||||
|
enableJSONSocket = false
|
||||||
|
jsonSocket = defaultJSONSocket
|
||||||
|
applyServiceParams(testServiceEnvCommand(), params)
|
||||||
|
|
||||||
|
assert.True(t, enableJSONSocket)
|
||||||
|
assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveStaleUnixSocketDoesNotRemoveRegularFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "netbird-http.sock")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("not a socket"), 0600))
|
||||||
|
|
||||||
|
removeStaleUnixSocket(path)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("not a socket"), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveStaleUnixSocketRemovesSocket(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("unix sockets are not available on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "netbird-http.sock")
|
||||||
|
addr := &net.UnixAddr{Name: path, Net: "unix"}
|
||||||
|
listener, err := net.ListenUnix("unix", addr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
listener.SetUnlinkOnClose(false)
|
||||||
|
require.NoError(t, listener.Close())
|
||||||
|
|
||||||
|
_, err = os.Lstat(path)
|
||||||
|
require.NoError(t, err, "test setup must leave a stale Unix socket path")
|
||||||
|
|
||||||
|
removeStaleUnixSocket(path)
|
||||||
|
|
||||||
|
_, err = os.Lstat(path)
|
||||||
|
assert.True(t, os.IsNotExist(err), "expected stale Unix socket to be removed, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveStaleUnixSocketDoesNotRemoveLiveSocket(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("unix sockets are not available on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "netbird-http.sock")
|
||||||
|
listener, err := net.Listen("unix", path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
removeStaleUnixSocket(path)
|
||||||
|
|
||||||
|
_, err = os.Lstat(path)
|
||||||
|
assert.NoError(t, err, "expected live Unix socket to be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServiceEnvCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().StringSlice("service-env", nil, "")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOfArg(args []string, arg string) int {
|
||||||
|
for i, candidate := range args {
|
||||||
|
if candidate == arg {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ const serviceParamsFile = "service.json"
|
|||||||
type serviceParams struct {
|
type serviceParams struct {
|
||||||
LogLevel string `json:"log_level"`
|
LogLevel string `json:"log_level"`
|
||||||
DaemonAddr string `json:"daemon_addr"`
|
DaemonAddr string `json:"daemon_addr"`
|
||||||
|
JSONSocket string `json:"json_socket"`
|
||||||
ManagementURL string `json:"management_url,omitempty"`
|
ManagementURL string `json:"management_url,omitempty"`
|
||||||
ConfigPath string `json:"config_path,omitempty"`
|
ConfigPath string `json:"config_path,omitempty"`
|
||||||
LogFiles []string `json:"log_files,omitempty"`
|
LogFiles []string `json:"log_files,omitempty"`
|
||||||
@@ -30,6 +31,7 @@ type serviceParams struct {
|
|||||||
DisableUpdateSettings bool `json:"disable_update_settings,omitempty"`
|
DisableUpdateSettings bool `json:"disable_update_settings,omitempty"`
|
||||||
EnableCapture bool `json:"enable_capture,omitempty"`
|
EnableCapture bool `json:"enable_capture,omitempty"`
|
||||||
DisableNetworks bool `json:"disable_networks,omitempty"`
|
DisableNetworks bool `json:"disable_networks,omitempty"`
|
||||||
|
EnableJSONSocket bool `json:"enable_json_socket,omitempty"`
|
||||||
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
|
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ func currentServiceParams() *serviceParams {
|
|||||||
params := &serviceParams{
|
params := &serviceParams{
|
||||||
LogLevel: logLevel,
|
LogLevel: logLevel,
|
||||||
DaemonAddr: daemonAddr,
|
DaemonAddr: daemonAddr,
|
||||||
|
JSONSocket: jsonSocket,
|
||||||
ManagementURL: managementURL,
|
ManagementURL: managementURL,
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
LogFiles: logFiles,
|
LogFiles: logFiles,
|
||||||
@@ -82,6 +85,7 @@ func currentServiceParams() *serviceParams {
|
|||||||
DisableUpdateSettings: updateSettingsDisabled,
|
DisableUpdateSettings: updateSettingsDisabled,
|
||||||
EnableCapture: captureEnabled,
|
EnableCapture: captureEnabled,
|
||||||
DisableNetworks: networksDisabled,
|
DisableNetworks: networksDisabled,
|
||||||
|
EnableJSONSocket: enableJSONSocket,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(serviceEnvVars) > 0 {
|
if len(serviceEnvVars) > 0 {
|
||||||
@@ -113,9 +117,8 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// For fields with non-empty defaults (log-level, daemon-addr), keep the
|
// For fields with non-empty defaults, keep the != "" guard so that an older
|
||||||
// != "" guard so that an older service.json missing the field doesn't
|
// service.json missing the field doesn't clobber the default with an empty string.
|
||||||
// clobber the default with an empty string.
|
|
||||||
if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" {
|
if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" {
|
||||||
logLevel = params.LogLevel
|
logLevel = params.LogLevel
|
||||||
}
|
}
|
||||||
@@ -124,6 +127,14 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
|||||||
daemonAddr = params.DaemonAddr
|
daemonAddr = params.DaemonAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" {
|
||||||
|
jsonSocket = params.JSONSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
if !serviceCmd.PersistentFlags().Changed("enable-json-socket") {
|
||||||
|
enableJSONSocket = params.EnableJSONSocket
|
||||||
|
}
|
||||||
|
|
||||||
// For optional fields where empty means "use default", always apply so
|
// For optional fields where empty means "use default", always apply so
|
||||||
// that an explicit clear (--management-url "") persists across reinstalls.
|
// that an explicit clear (--management-url "") persists across reinstalls.
|
||||||
if !rootCmd.PersistentFlags().Changed("management-url") {
|
if !rootCmd.PersistentFlags().Changed("management-url") {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ func TestSaveAndLoadServiceParams(t *testing.T) {
|
|||||||
params := &serviceParams{
|
params := &serviceParams{
|
||||||
LogLevel: "debug",
|
LogLevel: "debug",
|
||||||
DaemonAddr: "unix:///var/run/netbird.sock",
|
DaemonAddr: "unix:///var/run/netbird.sock",
|
||||||
|
JSONSocket: "tcp://127.0.0.1:8080",
|
||||||
|
EnableJSONSocket: true,
|
||||||
ManagementURL: "https://my.server.com",
|
ManagementURL: "https://my.server.com",
|
||||||
ConfigPath: "/etc/netbird/config.json",
|
ConfigPath: "/etc/netbird/config.json",
|
||||||
LogFiles: []string{"/var/log/netbird/client.log", "console"},
|
LogFiles: []string{"/var/log/netbird/client.log", "console"},
|
||||||
@@ -63,6 +65,8 @@ func TestSaveAndLoadServiceParams(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, params.LogLevel, loaded.LogLevel)
|
assert.Equal(t, params.LogLevel, loaded.LogLevel)
|
||||||
assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr)
|
assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr)
|
||||||
|
assert.Equal(t, params.JSONSocket, loaded.JSONSocket)
|
||||||
|
assert.Equal(t, params.EnableJSONSocket, loaded.EnableJSONSocket)
|
||||||
assert.Equal(t, params.ManagementURL, loaded.ManagementURL)
|
assert.Equal(t, params.ManagementURL, loaded.ManagementURL)
|
||||||
assert.Equal(t, params.ConfigPath, loaded.ConfigPath)
|
assert.Equal(t, params.ConfigPath, loaded.ConfigPath)
|
||||||
assert.Equal(t, params.LogFiles, loaded.LogFiles)
|
assert.Equal(t, params.LogFiles, loaded.LogFiles)
|
||||||
@@ -101,6 +105,8 @@ func TestLoadServiceParams_InvalidJSON(t *testing.T) {
|
|||||||
func TestCurrentServiceParams(t *testing.T) {
|
func TestCurrentServiceParams(t *testing.T) {
|
||||||
origLogLevel := logLevel
|
origLogLevel := logLevel
|
||||||
origDaemonAddr := daemonAddr
|
origDaemonAddr := daemonAddr
|
||||||
|
origJSONSocket := jsonSocket
|
||||||
|
origEnableJSONSocket := enableJSONSocket
|
||||||
origManagementURL := managementURL
|
origManagementURL := managementURL
|
||||||
origConfigPath := configPath
|
origConfigPath := configPath
|
||||||
origLogFiles := logFiles
|
origLogFiles := logFiles
|
||||||
@@ -110,6 +116,8 @@ func TestCurrentServiceParams(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
logLevel = origLogLevel
|
logLevel = origLogLevel
|
||||||
daemonAddr = origDaemonAddr
|
daemonAddr = origDaemonAddr
|
||||||
|
jsonSocket = origJSONSocket
|
||||||
|
enableJSONSocket = origEnableJSONSocket
|
||||||
managementURL = origManagementURL
|
managementURL = origManagementURL
|
||||||
configPath = origConfigPath
|
configPath = origConfigPath
|
||||||
logFiles = origLogFiles
|
logFiles = origLogFiles
|
||||||
@@ -120,6 +128,8 @@ func TestCurrentServiceParams(t *testing.T) {
|
|||||||
|
|
||||||
logLevel = "trace"
|
logLevel = "trace"
|
||||||
daemonAddr = "tcp://127.0.0.1:9999"
|
daemonAddr = "tcp://127.0.0.1:9999"
|
||||||
|
jsonSocket = "tcp://127.0.0.1:8080"
|
||||||
|
enableJSONSocket = true
|
||||||
managementURL = "https://mgmt.example.com"
|
managementURL = "https://mgmt.example.com"
|
||||||
configPath = "/tmp/test-config.json"
|
configPath = "/tmp/test-config.json"
|
||||||
logFiles = []string{"/tmp/test.log"}
|
logFiles = []string{"/tmp/test.log"}
|
||||||
@@ -131,6 +141,8 @@ func TestCurrentServiceParams(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, "trace", params.LogLevel)
|
assert.Equal(t, "trace", params.LogLevel)
|
||||||
assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr)
|
assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr)
|
||||||
|
assert.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket)
|
||||||
|
assert.True(t, params.EnableJSONSocket)
|
||||||
assert.Equal(t, "https://mgmt.example.com", params.ManagementURL)
|
assert.Equal(t, "https://mgmt.example.com", params.ManagementURL)
|
||||||
assert.Equal(t, "/tmp/test-config.json", params.ConfigPath)
|
assert.Equal(t, "/tmp/test-config.json", params.ConfigPath)
|
||||||
assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles)
|
assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles)
|
||||||
@@ -142,6 +154,8 @@ func TestCurrentServiceParams(t *testing.T) {
|
|||||||
func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
|
func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
|
||||||
origLogLevel := logLevel
|
origLogLevel := logLevel
|
||||||
origDaemonAddr := daemonAddr
|
origDaemonAddr := daemonAddr
|
||||||
|
origJSONSocket := jsonSocket
|
||||||
|
origEnableJSONSocket := enableJSONSocket
|
||||||
origManagementURL := managementURL
|
origManagementURL := managementURL
|
||||||
origConfigPath := configPath
|
origConfigPath := configPath
|
||||||
origLogFiles := logFiles
|
origLogFiles := logFiles
|
||||||
@@ -151,6 +165,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
logLevel = origLogLevel
|
logLevel = origLogLevel
|
||||||
daemonAddr = origDaemonAddr
|
daemonAddr = origDaemonAddr
|
||||||
|
jsonSocket = origJSONSocket
|
||||||
|
enableJSONSocket = origEnableJSONSocket
|
||||||
managementURL = origManagementURL
|
managementURL = origManagementURL
|
||||||
configPath = origConfigPath
|
configPath = origConfigPath
|
||||||
logFiles = origLogFiles
|
logFiles = origLogFiles
|
||||||
@@ -162,6 +178,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
|
|||||||
// Reset all flags to defaults.
|
// Reset all flags to defaults.
|
||||||
logLevel = "info"
|
logLevel = "info"
|
||||||
daemonAddr = "unix:///var/run/netbird.sock"
|
daemonAddr = "unix:///var/run/netbird.sock"
|
||||||
|
jsonSocket = defaultJSONSocket
|
||||||
|
enableJSONSocket = false
|
||||||
managementURL = ""
|
managementURL = ""
|
||||||
configPath = "/etc/netbird/config.json"
|
configPath = "/etc/netbird/config.json"
|
||||||
logFiles = []string{"/var/log/netbird/client.log"}
|
logFiles = []string{"/var/log/netbird/client.log"}
|
||||||
@@ -184,6 +202,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
|
|||||||
saved := &serviceParams{
|
saved := &serviceParams{
|
||||||
LogLevel: "debug",
|
LogLevel: "debug",
|
||||||
DaemonAddr: "tcp://127.0.0.1:5555",
|
DaemonAddr: "tcp://127.0.0.1:5555",
|
||||||
|
JSONSocket: "tcp://127.0.0.1:8080",
|
||||||
|
EnableJSONSocket: true,
|
||||||
ManagementURL: "https://saved.example.com",
|
ManagementURL: "https://saved.example.com",
|
||||||
ConfigPath: "/saved/config.json",
|
ConfigPath: "/saved/config.json",
|
||||||
LogFiles: []string{"/saved/client.log"},
|
LogFiles: []string{"/saved/client.log"},
|
||||||
@@ -201,6 +221,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
|
|||||||
|
|
||||||
// All other fields were not Changed, so they should use saved values.
|
// All other fields were not Changed, so they should use saved values.
|
||||||
assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr)
|
assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr)
|
||||||
|
assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket)
|
||||||
|
assert.True(t, enableJSONSocket)
|
||||||
assert.Equal(t, "https://saved.example.com", managementURL)
|
assert.Equal(t, "https://saved.example.com", managementURL)
|
||||||
assert.Equal(t, "/saved/config.json", configPath)
|
assert.Equal(t, "/saved/config.json", configPath)
|
||||||
assert.Equal(t, []string{"/saved/client.log"}, logFiles)
|
assert.Equal(t, []string{"/saved/client.log"}, logFiles)
|
||||||
@@ -212,14 +234,17 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
|
|||||||
func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) {
|
func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) {
|
||||||
origProfilesDisabled := profilesDisabled
|
origProfilesDisabled := profilesDisabled
|
||||||
origUpdateSettingsDisabled := updateSettingsDisabled
|
origUpdateSettingsDisabled := updateSettingsDisabled
|
||||||
|
origEnableJSONSocket := enableJSONSocket
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
profilesDisabled = origProfilesDisabled
|
profilesDisabled = origProfilesDisabled
|
||||||
updateSettingsDisabled = origUpdateSettingsDisabled
|
updateSettingsDisabled = origUpdateSettingsDisabled
|
||||||
|
enableJSONSocket = origEnableJSONSocket
|
||||||
})
|
})
|
||||||
|
|
||||||
// Simulate current state where booleans are true (e.g. set by previous install).
|
// Simulate current state where booleans are true (e.g. set by previous install).
|
||||||
profilesDisabled = true
|
profilesDisabled = true
|
||||||
updateSettingsDisabled = true
|
updateSettingsDisabled = true
|
||||||
|
enableJSONSocket = true
|
||||||
|
|
||||||
// Reset Changed state so flags appear unset.
|
// Reset Changed state so flags appear unset.
|
||||||
serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||||
@@ -238,6 +263,7 @@ func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) {
|
|||||||
|
|
||||||
assert.False(t, profilesDisabled, "saved false should override current true")
|
assert.False(t, profilesDisabled, "saved false should override current true")
|
||||||
assert.False(t, updateSettingsDisabled, "saved false should override current true")
|
assert.False(t, updateSettingsDisabled, "saved false should override current true")
|
||||||
|
assert.False(t, enableJSONSocket, "saved false should override current true")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyServiceParams_ClearManagementURL(t *testing.T) {
|
func TestApplyServiceParams_ClearManagementURL(t *testing.T) {
|
||||||
@@ -530,6 +556,7 @@ func fieldToGlobalVar(field string) string {
|
|||||||
m := map[string]string{
|
m := map[string]string{
|
||||||
"LogLevel": "logLevel",
|
"LogLevel": "logLevel",
|
||||||
"DaemonAddr": "daemonAddr",
|
"DaemonAddr": "daemonAddr",
|
||||||
|
"JSONSocket": "jsonSocket",
|
||||||
"ManagementURL": "managementURL",
|
"ManagementURL": "managementURL",
|
||||||
"ConfigPath": "configPath",
|
"ConfigPath": "configPath",
|
||||||
"LogFiles": "logFiles",
|
"LogFiles": "logFiles",
|
||||||
@@ -537,6 +564,7 @@ func fieldToGlobalVar(field string) string {
|
|||||||
"DisableUpdateSettings": "updateSettingsDisabled",
|
"DisableUpdateSettings": "updateSettingsDisabled",
|
||||||
"EnableCapture": "captureEnabled",
|
"EnableCapture": "captureEnabled",
|
||||||
"DisableNetworks": "networksDisabled",
|
"DisableNetworks": "networksDisabled",
|
||||||
|
"EnableJSONSocket": "enableJSONSocket",
|
||||||
"ServiceEnvVars": "serviceEnvVars",
|
"ServiceEnvVars": "serviceEnvVars",
|
||||||
}
|
}
|
||||||
if v, ok := m[field]; ok {
|
if v, ok := m[field]; ok {
|
||||||
|
|||||||
111
client/cmd/service_socket.go
Normal file
111
client/cmd/service_socket.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
//go:build !ios && !android
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type socketListener struct {
|
||||||
|
net.Listener
|
||||||
|
network string
|
||||||
|
address string
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenOnAddress(addr string) (*socketListener, error) {
|
||||||
|
network, address, err := parseListenAddress(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if network == "unix" {
|
||||||
|
removeStaleUnixSocket(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen(network, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &socketListener{Listener: listener, network: network, address: address}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseListenAddress(addr string) (string, string, error) {
|
||||||
|
network, address, ok := strings.Cut(addr, "://")
|
||||||
|
if !ok || network == "" || address == "" {
|
||||||
|
return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch network {
|
||||||
|
case "unix", "tcp":
|
||||||
|
return network, address, nil
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeStaleUnixSocket(path string) {
|
||||||
|
stat, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
log.Debugf("stat socket file: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat.Mode()&os.ModeSocket == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isStaleUnixSocket(path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
log.Debugf("remove socket file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStaleUnixSocket(path string) bool {
|
||||||
|
conn, err := net.DialTimeout("unix", path, 100*time.Millisecond)
|
||||||
|
if err == nil {
|
||||||
|
if closeErr := conn.Close(); closeErr != nil {
|
||||||
|
log.Debugf("close unix socket probe: %v", closeErr)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.IsNotExist(err) || os.IsPermission(err) || os.IsTimeout(err) {
|
||||||
|
log.Debugf("not removing unix socket %s after probe error: %v", path, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Is(err, syscall.ECONNREFUSED)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeStaleUnixSocketForAddress(addr string) {
|
||||||
|
network, address, err := parseListenAddress(addr)
|
||||||
|
if err != nil || network != "unix" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removeStaleUnixSocket(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *socketListener) chmodUnixSocket(description string) error {
|
||||||
|
if l == nil || l.network != "unix" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(l.address, 0666); err != nil {
|
||||||
|
return fmt.Errorf("failed setting %s permissions for %s: %w", description, l.address, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ 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"
|
||||||
@@ -115,6 +116,11 @@ 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(),
|
||||||
@@ -125,6 +131,7 @@ 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 {
|
||||||
|
|||||||
@@ -479,10 +479,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
|||||||
req.DisableIpv6 = &disableIPv6
|
req.DisableIpv6 = &disableIPv6
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
|
||||||
req.LazyConnectionEnabled = &lazyConnEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
return &req
|
return &req
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,9 +596,6 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
|||||||
ic.DisableIPv6 = &disableIPv6
|
ic.DisableIPv6 = &disableIPv6
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
|
||||||
ic.LazyConnectionEnabled = &lazyConnEnabled
|
|
||||||
}
|
|
||||||
return &ic, nil
|
return &ic, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,9 +711,6 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
|||||||
loginRequest.DisableIpv6 = &disableIPv6
|
loginRequest.DisableIpv6 = &disableIPv6
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
|
||||||
loginRequest.LazyConnectionEnabled = &lazyConnEnabled
|
|
||||||
}
|
|
||||||
return &loginRequest, nil
|
return &loginRequest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(false)
|
_ = engine.RunHealthProbes(context.Background(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ import (
|
|||||||
|
|
||||||
type KernelConfigurer struct {
|
type KernelConfigurer struct {
|
||||||
deviceName string
|
deviceName string
|
||||||
|
statsCache *statsCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKernelConfigurer(deviceName string) *KernelConfigurer {
|
func NewKernelConfigurer(deviceName string) *KernelConfigurer {
|
||||||
return &KernelConfigurer{
|
c := &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 {
|
||||||
@@ -246,12 +249,6 @@ func (c *KernelConfigurer) configure(config wgtypes.Config) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// validate if device with name exists
|
|
||||||
_, err = wg.Device(c.deviceName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return wg.ConfigureDevice(c.deviceName, config)
|
return wg.ConfigureDevice(c.deviceName, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +297,14 @@ 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 {
|
||||||
@@ -326,7 +331,3 @@ func (c *KernelConfigurer) GetStats() (map[string]WGStats, error) {
|
|||||||
}
|
}
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *KernelConfigurer) LastActivities() map[string]monotime.Time {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
52
client/iface/configurer/stats_cache.go
Normal file
52
client/iface/configurer/stats_cache.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package configurer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
)
|
||||||
|
|
||||||
|
const statsCacheTTL = 1 * time.Second
|
||||||
|
|
||||||
|
type statsCache struct {
|
||||||
|
ttl time.Duration
|
||||||
|
fetch func() (map[string]WGStats, error)
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
value map[string]WGStats
|
||||||
|
expireAt time.Time
|
||||||
|
|
||||||
|
sf singleflight.Group
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStatsCache(ttl time.Duration, fetch func() (map[string]WGStats, error)) *statsCache {
|
||||||
|
return &statsCache{ttl: ttl, fetch: fetch}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *statsCache) get() (map[string]WGStats, error) {
|
||||||
|
c.mu.RLock()
|
||||||
|
if c.value != nil && time.Now().Before(c.expireAt) {
|
||||||
|
value := c.value
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
value, err, _ := c.sf.Do("stats", func() (interface{}, error) {
|
||||||
|
res, err := c.fetch()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.value = res
|
||||||
|
c.expireAt = time.Now().Add(c.ttl)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return res, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return value.(map[string]WGStats), nil
|
||||||
|
}
|
||||||
70
client/iface/configurer/stats_cache_test.go
Normal file
70
client/iface/configurer/stats_cache_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package configurer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatsCache_CachesWithinTTL(t *testing.T) {
|
||||||
|
var calls atomic.Int64
|
||||||
|
c := newStatsCache(50*time.Millisecond, func() (map[string]WGStats, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return map[string]WGStats{"p": {}}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, err := c.get()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
require.Equal(t, int64(1), calls.Load(), "within TTL only one underlying fetch")
|
||||||
|
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
_, err := c.get()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(2), calls.Load(), "after TTL expiry a fresh fetch happens")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsCache_SingleFlight(t *testing.T) {
|
||||||
|
var calls atomic.Int64
|
||||||
|
release := make(chan struct{})
|
||||||
|
c := newStatsCache(time.Minute, func() (map[string]WGStats, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
<-release
|
||||||
|
return map[string]WGStats{}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
const n = 50
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _ = c.get()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
require.Equal(t, int64(1), calls.Load(), "concurrent misses collapse into one fetch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsCache_ErrorNotCached(t *testing.T) {
|
||||||
|
var calls atomic.Int64
|
||||||
|
wantErr := errors.New("dump failed")
|
||||||
|
c := newStatsCache(time.Minute, func() (map[string]WGStats, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return nil, wantErr
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := c.get()
|
||||||
|
require.ErrorIs(t, err, wantErr)
|
||||||
|
_, err = c.get()
|
||||||
|
require.ErrorIs(t, err, wantErr)
|
||||||
|
require.Equal(t, int64(2), calls.Load(), "errors are not cached; each call retries")
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -50,16 +51,19 @@ 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 {
|
||||||
return &WGUSPConfigurer{
|
wgCfg := &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 {
|
||||||
@@ -348,6 +352,10 @@ 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)
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ func (p *ProxyBind) CloseConn() error {
|
|||||||
return p.close()
|
return p.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectPacket is a no-op for the userspace proxy: first-packet reinjection is kernel-only.
|
||||||
|
func (p *ProxyBind) InjectPacket(_ []byte) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *ProxyBind) close() error {
|
func (p *ProxyBind) close() error {
|
||||||
if p.remoteConn == nil {
|
if p.remoteConn == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -219,6 +219,17 @@ func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) {
|
|||||||
p.pausedCond.L.Unlock()
|
p.pausedCond.L.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectPacket writes b to the remote peer over the underlying transport.
|
||||||
|
func (p *ProxyWrapper) InjectPacket(b []byte) error {
|
||||||
|
if p.remoteConn == nil {
|
||||||
|
return errors.New("proxy not started")
|
||||||
|
}
|
||||||
|
if _, err := p.remoteConn.Write(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CloseConn close the remoteConn and automatically remove the conn instance from the map
|
// CloseConn close the remoteConn and automatically remove the conn instance from the map
|
||||||
func (p *ProxyWrapper) CloseConn() error {
|
func (p *ProxyWrapper) CloseConn() error {
|
||||||
if p.cancel == nil {
|
if p.cancel == nil {
|
||||||
|
|||||||
@@ -18,4 +18,9 @@ type Proxy interface {
|
|||||||
RedirectAs(endpoint *net.UDPAddr)
|
RedirectAs(endpoint *net.UDPAddr)
|
||||||
CloseConn() error
|
CloseConn() error
|
||||||
SetDisconnectListener(disconnected func())
|
SetDisconnectListener(disconnected func())
|
||||||
|
|
||||||
|
// InjectPacket writes a raw packet directly to the remote peer over the underlying transport,
|
||||||
|
// bypassing WireGuard. Used to replay the captured lazyconn handshake initiation. Only the
|
||||||
|
// kernel-mode proxies act on it; the userspace proxy is a no-op since reinjection is kernel-only.
|
||||||
|
InjectPacket(b []byte) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,17 @@ func (p *WGUDPProxy) RedirectAs(endpoint *net.UDPAddr) {
|
|||||||
p.sendPkg = p.srcFakerConn.SendPkg
|
p.sendPkg = p.srcFakerConn.SendPkg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectPacket writes b to the remote peer over the underlying transport.
|
||||||
|
func (p *WGUDPProxy) InjectPacket(b []byte) error {
|
||||||
|
if p.remoteConn == nil {
|
||||||
|
return errors.New("proxy not started")
|
||||||
|
}
|
||||||
|
if _, err := p.remoteConn.Write(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CloseConn close the localConn
|
// CloseConn close the localConn
|
||||||
func (p *WGUDPProxy) CloseConn() error {
|
func (p *WGUDPProxy) CloseConn() error {
|
||||||
if p.cancel == nil {
|
if p.cancel == nil {
|
||||||
|
|||||||
@@ -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\\assets\\netbird.ico"
|
!define ICON "ui\\build\\windows\\icon.ico"
|
||||||
!define BANNER "ui\\build\\banner.bmp"
|
!define BANNER "ui\\build\\banner.bmp"
|
||||||
!define LICENSE_DATA "..\\LICENSE"
|
!define LICENSE_DATA "..\\LICENSE"
|
||||||
|
|
||||||
@@ -79,8 +79,6 @@ 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
|
||||||
@@ -97,40 +95,12 @@ 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."
|
||||||
@@ -201,8 +171,6 @@ 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.
|
||||||
@@ -260,17 +228,12 @@ 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}"
|
||||||
|
|
||||||
; Create autostart registry entry based on checkbox
|
; Autostart is owned by the UI's per-user setting (HKCU\...\Run via Wails),
|
||||||
DetailPrint "Autostart enabled: $AutostartEnabled"
|
; not the installer. Drop the machine-wide entry older installers wrote so the
|
||||||
${If} $AutostartEnabled == "1"
|
; toggle is the single source of truth. HKCU is left untouched -- it may hold
|
||||||
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
; the user's own toggle state, which must survive upgrades.
|
||||||
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
DetailPrint "Removing installer-managed autostart registry entry if present..."
|
||||||
${Else}
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
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"
|
||||||
@@ -280,6 +243,43 @@ 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,11 +299,14 @@ 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 entry
|
; Remove autostart registry entries
|
||||||
DetailPrint "Removing autostart registry entry if exists..."
|
DetailPrint "Removing autostart registry entries if they exist..."
|
||||||
|
; Legacy machine-wide entry written by older installers.
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
; Per-user entry the UI toggle writes via Wails (value name is the lowercase
|
||||||
|
; app-name slug). Uninstall removes the app, so drop it too.
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${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..."
|
||||||
@@ -326,9 +329,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"
|
||||||
!if ${ARCH} == "amd64"
|
# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove
|
||||||
|
# any leftover copy on uninstall so old upgrades don't leave it behind.
|
||||||
Delete "$INSTDIR\opengl32.dll"
|
Delete "$INSTDIR\opengl32.dll"
|
||||||
!endif
|
|
||||||
DetailPrint "Removing application directory..."
|
DetailPrint "Removing application directory..."
|
||||||
RmDir /r "$INSTDIR"
|
RmDir /r "$INSTDIR"
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,6 +22,25 @@ 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 {
|
||||||
@@ -184,6 +204,15 @@ 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
|
||||||
}
|
}
|
||||||
@@ -322,7 +351,6 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
|||||||
a.config.BlockLANAccess,
|
a.config.BlockLANAccess,
|
||||||
a.config.BlockInbound,
|
a.config.BlockInbound,
|
||||||
a.config.DisableIPv6,
|
a.config.DisableIPv6,
|
||||||
a.config.LazyConnectionEnabled,
|
|
||||||
a.config.EnableSSHRoot,
|
a.config.EnableSSHRoot,
|
||||||
a.config.EnableSSHSFTP,
|
a.config.EnableSSHSFTP,
|
||||||
a.config.EnableSSHLocalPortForwarding,
|
a.config.EnableSSHLocalPortForwarding,
|
||||||
@@ -474,3 +502,16 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
80
client/internal/auth/auth_test.go
Normal file
80
client/internal/auth/auth_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsPeerLoginExpired(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil",
|
||||||
|
err: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain error (not a gRPC status)",
|
||||||
|
err: errors.New("network read: connection reset"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PermissionDenied with different message",
|
||||||
|
err: status.Error(codes.PermissionDenied, "user is blocked"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unauthenticated with the expected phrase",
|
||||||
|
// Wrong status code — must still return false.
|
||||||
|
err: status.Error(codes.Unauthenticated, "peer login has expired, please log in once more"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact server message",
|
||||||
|
err: status.Error(codes.PermissionDenied, "peer login has expired, please log in once more"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "phrase as substring",
|
||||||
|
// Future-proofing: if mgm reworords but keeps the phrase,
|
||||||
|
// the friendly fallback must still kick in.
|
||||||
|
err: status.Error(codes.PermissionDenied, "session refused: peer login has expired (account=foo)"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := isPeerLoginExpired(tc.err); got != tc.want {
|
||||||
|
t.Fatalf("isPeerLoginExpired(%v) = %v, want %v", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrSetupKeyOnSSOExpiredPeer(t *testing.T) {
|
||||||
|
// Sentinel must surface as PermissionDenied so the upstream
|
||||||
|
// isPermissionDenied / isAuthError checks classify it correctly
|
||||||
|
// (short-circuit retry backoff, set StatusNeedsLogin).
|
||||||
|
if !isPermissionDenied(errSetupKeyOnSSOExpiredPeer) {
|
||||||
|
t.Fatalf("errSetupKeyOnSSOExpiredPeer must be a PermissionDenied gRPC error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message must actually mention SSO and `netbird up` so it is
|
||||||
|
// actionable for the end user. Loose substring checks keep the
|
||||||
|
// test resilient to copy edits.
|
||||||
|
s, _ := status.FromError(errSetupKeyOnSSOExpiredPeer)
|
||||||
|
msg := strings.ToLower(s.Message())
|
||||||
|
for _, want := range []string{"sso", "netbird up"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("sentinel message should contain %q, got %q", want, s.Message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
client/internal/auth/pending_flow.go
Normal file
89
client/internal/auth/pending_flow.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PendingFlow stores an in-progress OAuth flow between the RPC that
|
||||||
|
// initiates it (returns the verification URI to the UI) and the RPC
|
||||||
|
// that waits for the user to complete it. The flow handle, the
|
||||||
|
// device-code info, and the absolute expiry are kept together so the
|
||||||
|
// waiting RPC can validate the device code and reuse the same flow.
|
||||||
|
//
|
||||||
|
// PendingFlow is safe for concurrent use; callers must not access the
|
||||||
|
// stored fields directly.
|
||||||
|
type PendingFlow struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
flow OAuthFlow
|
||||||
|
info AuthFlowInfo
|
||||||
|
expiresAt time.Time
|
||||||
|
waitCancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPendingFlow returns an empty PendingFlow ready to be populated by Set.
|
||||||
|
func NewPendingFlow() *PendingFlow {
|
||||||
|
return &PendingFlow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores the flow and its authorization info, computing the absolute
|
||||||
|
// expiry from info.ExpiresIn (seconds, as returned by the IdP).
|
||||||
|
func (p *PendingFlow) Set(flow OAuthFlow, info AuthFlowInfo) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.flow = flow
|
||||||
|
p.info = info
|
||||||
|
p.expiresAt = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the stored flow, info, and whether a flow is currently
|
||||||
|
// pending. Returns (nil, zero, false) after Clear or before Set.
|
||||||
|
func (p *PendingFlow) Get() (OAuthFlow, AuthFlowInfo, bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.flow == nil {
|
||||||
|
return nil, AuthFlowInfo{}, false
|
||||||
|
}
|
||||||
|
return p.flow, p.info, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiresAt returns the absolute expiry of the pending flow. Returns
|
||||||
|
// the zero time when no flow is pending.
|
||||||
|
func (p *PendingFlow) ExpiresAt() time.Time {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWaitCancel records the cancel function for the goroutine currently
|
||||||
|
// blocked in WaitToken so a new RequestAuth can preempt it.
|
||||||
|
func (p *PendingFlow) SetWaitCancel(cancel context.CancelFunc) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.waitCancel = cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelWait invokes and clears the stored wait-cancel, if any. Safe to
|
||||||
|
// call when no wait is in progress.
|
||||||
|
func (p *PendingFlow) CancelWait() {
|
||||||
|
p.mu.Lock()
|
||||||
|
cancel := p.waitCancel
|
||||||
|
p.waitCancel = nil
|
||||||
|
p.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear resets the pending flow to empty. Any stored wait-cancel is
|
||||||
|
// dropped without being invoked — call CancelWait first if the waiting
|
||||||
|
// goroutine must be stopped.
|
||||||
|
func (p *PendingFlow) Clear() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.flow = nil
|
||||||
|
p.info = AuthFlowInfo{}
|
||||||
|
p.expiresAt = time.Time{}
|
||||||
|
p.waitCancel = nil
|
||||||
|
}
|
||||||
82
client/internal/auth/sessionwatch/event.go
Normal file
82
client/internal/auth/sessionwatch/event.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package sessionwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// internal event kinds are no longer exposed: the watcher drives the Sink
|
||||||
|
// directly (NotifyStateChange on deadline change/clear, PublishEvent at
|
||||||
|
// each warning lead). Tests use a mock Sink to observe what the watcher
|
||||||
|
// emits.
|
||||||
|
|
||||||
|
// Metadata keys attached by the daemon to session-warning SystemEvents.
|
||||||
|
// The UI tray reads these to build a locale-aware notification without
|
||||||
|
// relying on the daemon's locale-less UserMessage string, and to
|
||||||
|
// disambiguate the T-WarningLead notification from the T-FinalWarningLead
|
||||||
|
// fallback that auto-opens the SessionAboutToExpire dialog.
|
||||||
|
const (
|
||||||
|
// MetaSessionWarning is set to "true" on both warning events (T-10 and
|
||||||
|
// T-2) so the UI can detect a session-warning SystemEvent without
|
||||||
|
// matching on the message text. Use MetaSessionFinal to distinguish
|
||||||
|
// the two.
|
||||||
|
MetaSessionWarning = "session_warning"
|
||||||
|
// MetaSessionFinal is set to "true" on the T-FinalWarningLead event
|
||||||
|
// only. Consumers that need to auto-open the SessionAboutToExpire
|
||||||
|
// dialog gate on this; T-WarningLead events leave the field unset.
|
||||||
|
MetaSessionFinal = "session_final_warning"
|
||||||
|
// MetaSessionExpiresAt carries the absolute UTC deadline encoded with
|
||||||
|
// FormatExpiresAt; consumers must decode with ParseExpiresAt so a
|
||||||
|
// future format change stays a single edit.
|
||||||
|
MetaSessionExpiresAt = "session_expires_at"
|
||||||
|
// MetaSessionLeadMinutes carries the lead in whole minutes (WarningLead
|
||||||
|
// for the T-10 event, FinalWarningLead for the T-2 event) so the UI
|
||||||
|
// can show "expires in ~N minutes" without hardcoding either constant.
|
||||||
|
MetaSessionLeadMinutes = "lead_minutes"
|
||||||
|
// MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION
|
||||||
|
// SystemEvent the daemon emits when it discards a deadline from the
|
||||||
|
// management server (pre-epoch, too far in the future, or past the
|
||||||
|
// clock-skew tolerance). The value is the rejection reason string.
|
||||||
|
// userMessage is left empty; the UI detects the event via this key
|
||||||
|
// and builds a localized notification — same pattern as the session
|
||||||
|
// warnings above.
|
||||||
|
MetaSessionDeadlineRejected = "session_deadline_rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// expiresAtLayout is the wire format used for MetaSessionExpiresAt.
|
||||||
|
// Producer and consumers both go through FormatExpiresAt/ParseExpiresAt
|
||||||
|
// so this layout stays a single source of truth.
|
||||||
|
const expiresAtLayout = time.RFC3339
|
||||||
|
|
||||||
|
// FormatExpiresAt encodes a deadline for MetaSessionExpiresAt. Always
|
||||||
|
// emits UTC so a consumer in another timezone reads the same wall-clock
|
||||||
|
// deadline.
|
||||||
|
func FormatExpiresAt(t time.Time) string {
|
||||||
|
return t.UTC().Format(expiresAtLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseExpiresAt decodes the MetaSessionExpiresAt value back to a UTC
|
||||||
|
// time. Returns an error when the field is empty or malformed; the
|
||||||
|
// caller decides whether to fall back (zero value) or propagate.
|
||||||
|
func ParseExpiresAt(s string) (time.Time, error) {
|
||||||
|
t, err := time.Parse(expiresAtLayout, s)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return t.UTC(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatLeadMinutes encodes a lead duration for MetaSessionLeadMinutes
|
||||||
|
// as the integer count of whole minutes. Sub-minute residuals are
|
||||||
|
// truncated — the field is informational ("expires in ~N minutes") and
|
||||||
|
// fractional minutes don't change what the UI displays.
|
||||||
|
func FormatLeadMinutes(d time.Duration) string {
|
||||||
|
return strconv.Itoa(int(d / time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLeadMinutes decodes a MetaSessionLeadMinutes value. Returns 0
|
||||||
|
// and the parse error for malformed input; consumers that prefer a
|
||||||
|
// silent fallback can simply ignore the error.
|
||||||
|
func ParseLeadMinutes(s string) (int, error) {
|
||||||
|
return strconv.Atoi(s)
|
||||||
|
}
|
||||||
387
client/internal/auth/sessionwatch/watcher.go
Normal file
387
client/internal/auth/sessionwatch/watcher.go
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
// Package sessionwatch tracks the SSO session expiry deadline that the
|
||||||
|
// management server publishes via LoginResponse / SyncResponse and fires
|
||||||
|
// two warning events at fixed lead times before expiry: an interactive
|
||||||
|
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
|
||||||
|
// fallback dialog.
|
||||||
|
//
|
||||||
|
// The watcher is idempotent: Update may be called as often as the network
|
||||||
|
// map snapshots arrive. Repeating the same deadline is a no-op; a new
|
||||||
|
// deadline reschedules the timers and arms a fresh warning cycle.
|
||||||
|
//
|
||||||
|
// Warning firing is edge-detected. Each unique deadline value fires each
|
||||||
|
// warning callback at most once.
|
||||||
|
package sessionwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Skew tolerates a small clock difference between the management
|
||||||
|
// server and this peer before treating a deadline as "in the past".
|
||||||
|
// Slightly above typical NTP drift; tight enough that the UI doesn't
|
||||||
|
// paint a stale expiry as if it were valid.
|
||||||
|
Skew = 30 * time.Second
|
||||||
|
|
||||||
|
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||||
|
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||||
|
// glitch, and silently arming a 100-year timer would hide the bug.
|
||||||
|
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
|
||||||
|
|
||||||
|
// WarningLead is how far before expiry the first (interactive)
|
||||||
|
// warning fires. Drives the T-10 OS notification with
|
||||||
|
// Extend/Dismiss actions.
|
||||||
|
WarningLead = 10 * time.Minute
|
||||||
|
|
||||||
|
// FinalWarningLead is how far before expiry the fallback final
|
||||||
|
// warning fires. Drives the auto-opened SessionAboutToExpire dialog,
|
||||||
|
// but only when the user has not dismissed the T-WarningLead warning
|
||||||
|
// for the same deadline. Must be strictly less than WarningLead.
|
||||||
|
FinalWarningLead = 2 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrDeadlineBeforeEpoch is returned by Update when the supplied
|
||||||
|
// deadline pre-dates 1970-01-01.
|
||||||
|
ErrDeadlineBeforeEpoch = errors.New("session deadline before unix epoch")
|
||||||
|
|
||||||
|
// ErrDeadlineTooFarFuture is returned by Update when the supplied
|
||||||
|
// deadline is more than maxDeadlineHorizon in the future.
|
||||||
|
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
|
||||||
|
|
||||||
|
// ErrDeadlineInPast is returned by Update when the supplied deadline
|
||||||
|
// is more than Skew in the past.
|
||||||
|
ErrDeadlineInPast = errors.New("session deadline in the past")
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusRecorder is the side-effect surface the watcher drives on every
|
||||||
|
// state transition. Production wires this to peer.Status (SetSessionExpiresAt
|
||||||
|
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
||||||
|
// a fake recorder so the same surface is observable without an engine.
|
||||||
|
//
|
||||||
|
// The watcher is the single owner of the deadline propagated to the
|
||||||
|
// recorder: every set, clear, sanity-check rejection and Close routes the
|
||||||
|
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
|
||||||
|
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
|
||||||
|
// fans out its own state-change notification, so no separate notify is
|
||||||
|
// needed.) The recorder is server-scoped and outlives this engine-scoped
|
||||||
|
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
|
||||||
|
// a profile switch) would leave the next session showing the previous one's
|
||||||
|
// stale "expires in" value.
|
||||||
|
//
|
||||||
|
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
|
||||||
|
// composes the metadata internally so the wire format (MetaSession*) is
|
||||||
|
// owned by sessionwatch, not the caller.
|
||||||
|
type StatusRecorder interface {
|
||||||
|
SetSessionExpiresAt(deadline time.Time)
|
||||||
|
PublishEvent(
|
||||||
|
severity cProto.SystemEvent_Severity,
|
||||||
|
category cProto.SystemEvent_Category,
|
||||||
|
message string,
|
||||||
|
userMessage string,
|
||||||
|
metadata map[string]string,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher observes the latest session deadline and fires two warnings
|
||||||
|
// before it expires: the interactive T-WarningLead notification, and the
|
||||||
|
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
|
||||||
|
// the first one for the same deadline). Safe for concurrent use.
|
||||||
|
type Watcher struct {
|
||||||
|
lead time.Duration
|
||||||
|
finalLead time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
current time.Time
|
||||||
|
timer *time.Timer
|
||||||
|
finalTimer *time.Timer
|
||||||
|
firedAt time.Time // deadline value the T-WarningLead callback last fired against
|
||||||
|
finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against
|
||||||
|
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
|
||||||
|
closed bool
|
||||||
|
recorder StatusRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a watcher with the package defaults WarningLead and
|
||||||
|
// FinalWarningLead. Pass nil for recorder to silence side effects (handy
|
||||||
|
// in unit tests that exercise sanity checks without observing the publish
|
||||||
|
// path).
|
||||||
|
func New(recorder StatusRecorder) *Watcher {
|
||||||
|
return NewWithLeads(WarningLead, FinalWarningLead, recorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithLeads returns a watcher with custom lead times. Useful for tests.
|
||||||
|
// final must be strictly less than lead; otherwise both timers fire in the
|
||||||
|
// wrong order or simultaneously and the UI flow breaks. A zero final lead
|
||||||
|
// disables the final-warning timer entirely (see armTimerLocked) so a
|
||||||
|
// millisecond-scale deadline doesn't flush both timers in one tick.
|
||||||
|
func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||||
|
return &Watcher{
|
||||||
|
lead: lead,
|
||||||
|
finalLead: final,
|
||||||
|
recorder: recorder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
|
||||||
|
// a Sync push from the server omits the field because login expiration
|
||||||
|
// was disabled).
|
||||||
|
//
|
||||||
|
// Same-value updates are no-ops. A different non-zero value cancels any
|
||||||
|
// pending timer, resets the "already fired" guard, and arms a new one.
|
||||||
|
//
|
||||||
|
// Returns one of the sentinel Err* values when the deadline fails the
|
||||||
|
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
|
||||||
|
// In every error case the watcher first clears its state so it stays
|
||||||
|
// consistent with what the caller will push into its other sinks (e.g.
|
||||||
|
// applySessionDeadline forces a zero deadline into the status recorder
|
||||||
|
// after a non-nil error).
|
||||||
|
func (w *Watcher) Update(deadline time.Time) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if deadline.IsZero() {
|
||||||
|
w.clearLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
switch {
|
||||||
|
case deadline.Before(time.Unix(0, 0)):
|
||||||
|
w.clearLocked()
|
||||||
|
return fmt.Errorf("%w: %v", ErrDeadlineBeforeEpoch, deadline)
|
||||||
|
case deadline.After(now.Add(maxDeadlineHorizon)):
|
||||||
|
w.clearLocked()
|
||||||
|
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
|
||||||
|
case deadline.Before(now.Add(-Skew)):
|
||||||
|
w.clearLocked()
|
||||||
|
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deadline.Equal(w.current) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w.stopTimerLocked()
|
||||||
|
w.current = deadline
|
||||||
|
// Reset every per-deadline guard so a refreshed deadline arms a fresh
|
||||||
|
// warning cycle: both edge triggers and the user Dismiss decision
|
||||||
|
// (the user agreed to the old deadline expiring; a new deadline
|
||||||
|
// restarts the contract).
|
||||||
|
w.firedAt = time.Time{}
|
||||||
|
w.finalFiredAt = time.Time{}
|
||||||
|
w.dismissedAt = time.Time{}
|
||||||
|
|
||||||
|
w.armTimerLocked(deadline)
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder != nil {
|
||||||
|
recorder.SetSessionExpiresAt(deadline)
|
||||||
|
}
|
||||||
|
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline returns the most recently observed deadline. Zero when no
|
||||||
|
// deadline is currently tracked.
|
||||||
|
func (w *Watcher) Deadline() time.Time {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.current
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss records the user's "Dismiss" action against the current deadline
|
||||||
|
// and suppresses the upcoming final-warning callback for that deadline.
|
||||||
|
// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
|
||||||
|
// deadline resets the dismissal so the final-warning cycle re-arms.
|
||||||
|
//
|
||||||
|
// No-op when the watcher holds no deadline or has been closed.
|
||||||
|
func (w *Watcher) Dismiss() {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed || w.current.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.dismissedAt.Equal(w.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.dismissedAt = w.current
|
||||||
|
// Cancel the armed final-warning timer eagerly. fireFinal would also
|
||||||
|
// gate on dismissedAt, but stopping the timer avoids a wakeup with
|
||||||
|
// nothing to do and makes the intent visible.
|
||||||
|
if w.finalTimer != nil {
|
||||||
|
w.finalTimer.Stop()
|
||||||
|
w.finalTimer = nil
|
||||||
|
}
|
||||||
|
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops any pending timer and drops the deadline on the status
|
||||||
|
// recorder. Update calls after Close are ignored. Clearing the recorder
|
||||||
|
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
|
||||||
|
// from leaving the next session showing this one's stale "expires in"
|
||||||
|
// value — the recorder is server-scoped and outlives this engine-scoped
|
||||||
|
// watcher, so nothing else drops the anchor on teardown.
|
||||||
|
func (w *Watcher) Close() {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.closed = true
|
||||||
|
w.stopTimerLocked()
|
||||||
|
hadDeadline := !w.current.IsZero()
|
||||||
|
w.current = time.Time{}
|
||||||
|
w.firedAt = time.Time{}
|
||||||
|
w.finalFiredAt = time.Time{}
|
||||||
|
w.dismissedAt = time.Time{}
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder != nil && hadDeadline {
|
||||||
|
recorder.SetSessionExpiresAt(time.Time{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||||
|
// downstream consumers (SubscribeStatus stream, UI) drop their anchor.
|
||||||
|
// The caller must hold w.mu; this helper releases it before invoking
|
||||||
|
// the recorder.
|
||||||
|
func (w *Watcher) clearLocked() {
|
||||||
|
if w.current.IsZero() {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.stopTimerLocked()
|
||||||
|
w.current = time.Time{}
|
||||||
|
w.firedAt = time.Time{}
|
||||||
|
w.finalFiredAt = time.Time{}
|
||||||
|
w.dismissedAt = time.Time{}
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder != nil {
|
||||||
|
recorder.SetSessionExpiresAt(time.Time{})
|
||||||
|
}
|
||||||
|
log.Infof("auth session deadline cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) stopTimerLocked() {
|
||||||
|
if w.timer != nil {
|
||||||
|
w.timer.Stop()
|
||||||
|
w.timer = nil
|
||||||
|
}
|
||||||
|
if w.finalTimer != nil {
|
||||||
|
w.finalTimer.Stop()
|
||||||
|
w.finalTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) armTimerLocked(deadline time.Time) {
|
||||||
|
w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) })
|
||||||
|
// finalLead <= 0 disables the final-warning timer entirely. Used by
|
||||||
|
// tests that predate the final-warning fallback so a millisecond-scale
|
||||||
|
// deadline does not flush both timers at once.
|
||||||
|
if w.finalLead > 0 {
|
||||||
|
w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) fire(armedFor time.Time) {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed || !w.current.Equal(armedFor) {
|
||||||
|
// Deadline moved while we were waiting (e.g. a successful extend).
|
||||||
|
// The reschedule path armed a fresh timer; this one is stale.
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.firedAt = armedFor
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("auth session expiry soon warning fired")
|
||||||
|
publishWarning(recorder, armedFor, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra
|
||||||
|
// dismiss-gate: if the user dismissed the T-WarningLead notification for
|
||||||
|
// this deadline, the final warning is suppressed entirely.
|
||||||
|
func (w *Watcher) fireFinal(armedFor time.Time) {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed || !w.current.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.dismissedAt.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
log.Infof("auth session final-warning skipped (dismissed by user)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.finalFiredAt = armedFor
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("auth session final-warning fired")
|
||||||
|
publishWarning(recorder, armedFor, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
|
||||||
|
// past it dispatches on the next scheduler tick so a state-change recorder
|
||||||
|
// notification (invoked after w.mu is released) lands first. Caller must
|
||||||
|
// hold w.mu.
|
||||||
|
func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer {
|
||||||
|
delay := time.Until(fireAt)
|
||||||
|
if delay <= 0 {
|
||||||
|
return time.AfterFunc(0, cb)
|
||||||
|
}
|
||||||
|
return time.AfterFunc(delay, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishWarning composes the SystemEvent for a watcher-fired warning and
|
||||||
|
// pushes it through the recorder. Severity is CRITICAL on both — bypassing
|
||||||
|
// the user's Notifications toggle is deliberate: missing the warning
|
||||||
|
// window forces the post-mortem SessionExpired flow (tunnel torn down,
|
||||||
|
// lock icon, manual re-login), which is the UX we are trying to avoid.
|
||||||
|
func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
|
||||||
|
lead := WarningLead
|
||||||
|
message := "session expiry warning"
|
||||||
|
meta := map[string]string{
|
||||||
|
MetaSessionWarning: "true",
|
||||||
|
MetaSessionExpiresAt: FormatExpiresAt(deadline),
|
||||||
|
}
|
||||||
|
if final {
|
||||||
|
lead = FinalWarningLead
|
||||||
|
message = "session expiry final warning"
|
||||||
|
meta[MetaSessionFinal] = "true"
|
||||||
|
}
|
||||||
|
meta[MetaSessionLeadMinutes] = FormatLeadMinutes(lead)
|
||||||
|
|
||||||
|
recorder.PublishEvent(
|
||||||
|
cProto.SystemEvent_CRITICAL,
|
||||||
|
cProto.SystemEvent_AUTHENTICATION,
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
meta,
|
||||||
|
)
|
||||||
|
}
|
||||||
519
client/internal/auth/sessionwatch/watcher_test.go
Normal file
519
client/internal/auth/sessionwatch/watcher_test.go
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
package sessionwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRecorder satisfies StatusRecorder and records every call so tests
|
||||||
|
// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent
|
||||||
|
// land in the same ordered events slice (with the Kind distinguishing
|
||||||
|
// them) so tests that care about ordering still work. lastDeadline holds
|
||||||
|
// the most recent value passed to SetSessionExpiresAt so tests can assert
|
||||||
|
// the recorder ended up cleared/set as expected.
|
||||||
|
type fakeRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []event
|
||||||
|
lastDeadline time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateChange eventKind = iota
|
||||||
|
publish
|
||||||
|
)
|
||||||
|
|
||||||
|
type event struct {
|
||||||
|
kind eventKind
|
||||||
|
// Set only for publish events.
|
||||||
|
severity cProto.SystemEvent_Severity
|
||||||
|
category cProto.SystemEvent_Category
|
||||||
|
message string
|
||||||
|
meta map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op,
|
||||||
|
// a real change records the new value and fans out a state-change (the
|
||||||
|
// production recorder calls notifyStateChange internally). The baseline
|
||||||
|
// is the zero time, so an initial clear before any deadline is set emits
|
||||||
|
// nothing — matching the real recorder.
|
||||||
|
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.lastDeadline.Equal(deadline) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.lastDeadline = deadline
|
||||||
|
r.events = append(r.events, event{kind: stateChange})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRecorder) deadline() time.Time {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.lastDeadline
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRecorder) PublishEvent(
|
||||||
|
severity cProto.SystemEvent_Severity,
|
||||||
|
category cProto.SystemEvent_Category,
|
||||||
|
message string,
|
||||||
|
_ string,
|
||||||
|
metadata map[string]string,
|
||||||
|
) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.events = append(r.events, event{
|
||||||
|
kind: publish,
|
||||||
|
severity: severity,
|
||||||
|
category: category,
|
||||||
|
message: message,
|
||||||
|
meta: metadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRecorder) snapshot() []event {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]event, len(r.events))
|
||||||
|
copy(out, r.events)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e event) isFinalWarning() bool {
|
||||||
|
return e.kind == publish && e.meta[MetaSessionFinal] == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e event) isWarning() bool {
|
||||||
|
return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
func countWhere(events []event, pred func(event) bool) int {
|
||||||
|
n := 0
|
||||||
|
for _, e := range events {
|
||||||
|
if pred(e) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if got := r.snapshot(); len(got) >= want {
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
got := r.snapshot()
|
||||||
|
t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
|
||||||
|
// matching the lead-only behaviour the pre-final-warning tests assume.
|
||||||
|
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
||||||
|
return NewWithLeads(lead, 0, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
_ = w.Update(time.Time{})
|
||||||
|
|
||||||
|
if got := r.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no events on initial zero, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNonZeroFiresStateChange(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(time.Hour)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 1)
|
||||||
|
if events[0].kind != stateChange {
|
||||||
|
t.Fatalf("expected stateChange, got %+v", events[0])
|
||||||
|
}
|
||||||
|
if !w.Deadline().Equal(d) {
|
||||||
|
t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameDeadlineIsNoop(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(time.Hour)
|
||||||
|
_ = w.Update(d)
|
||||||
|
_ = w.Update(d)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 1)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
lead := 50 * time.Millisecond
|
||||||
|
w := newWatcher(lead, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
// Deadline 80ms out — warning should fire after ~30ms.
|
||||||
|
d := time.Now().Add(80 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if events[0].kind != stateChange {
|
||||||
|
t.Fatalf("event[0] should be stateChange, got %+v", events[0])
|
||||||
|
}
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("event[1] should be a warning publish, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r) // lead > delta => fire immediately
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(10 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("expected immediate warning publish, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
lead := 50 * time.Millisecond
|
||||||
|
w := newWatcher(lead, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
|
||||||
|
_ = w.Update(first)
|
||||||
|
|
||||||
|
// Replace with a far-future deadline before the warning fires.
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
second := time.Now().Add(time.Hour)
|
||||||
|
_ = w.Update(second)
|
||||||
|
|
||||||
|
// Wait past when first's warning would have fired.
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
|
||||||
|
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
|
||||||
|
t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
lead := 30 * time.Millisecond
|
||||||
|
w := newWatcher(lead, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
first := time.Now().Add(50 * time.Millisecond)
|
||||||
|
_ = w.Update(first)
|
||||||
|
|
||||||
|
// Wait for stateChange + warning of the first cycle.
|
||||||
|
waitForEvents(t, r, 2)
|
||||||
|
|
||||||
|
// Simulate a successful extend: brand new deadline.
|
||||||
|
second := time.Now().Add(60 * time.Millisecond)
|
||||||
|
_ = w.Update(second)
|
||||||
|
|
||||||
|
// 4 events total: stateChange, warning (first), stateChange, warning (second).
|
||||||
|
events := waitForEvents(t, r, 4)
|
||||||
|
if events[2].kind != stateChange {
|
||||||
|
t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2])
|
||||||
|
}
|
||||||
|
if !events[3].isWarning() {
|
||||||
|
t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(2 * time.Hour)
|
||||||
|
_ = w.Update(d)
|
||||||
|
waitForEvents(t, r, 1)
|
||||||
|
|
||||||
|
_ = w.Update(time.Time{})
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if events[1].kind != stateChange {
|
||||||
|
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("Deadline should be zero after clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRejectsBeforeEpoch(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
good := time.Now().Add(time.Hour)
|
||||||
|
if err := w.Update(good); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := w.Update(time.Unix(-100, 0))
|
||||||
|
if !errors.Is(err, ErrDeadlineBeforeEpoch) {
|
||||||
|
t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
good := time.Now().Add(time.Hour)
|
||||||
|
if err := w.Update(good); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour))
|
||||||
|
if !errors.Is(err, ErrDeadlineTooFarFuture) {
|
||||||
|
t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
good := time.Now().Add(time.Hour)
|
||||||
|
if err := w.Update(good); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
// Drain the stateChange from the seed.
|
||||||
|
waitForEvents(t, r, 1)
|
||||||
|
|
||||||
|
err := w.Update(time.Now().Add(-1 * time.Hour))
|
||||||
|
if !errors.Is(err, ErrDeadlineInPast) {
|
||||||
|
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
|
||||||
|
}
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if events[1].kind != stateChange {
|
||||||
|
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateWithinSkewAccepted(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
|
||||||
|
d := time.Now().Add(-5 * time.Second)
|
||||||
|
if err := w.Update(d); err != nil {
|
||||||
|
t.Fatalf("within-skew Update should succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().Equal(d) {
|
||||||
|
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseSilencesUpdates(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
_ = w.Update(time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if got := r.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no events after Close, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
|
||||||
|
// holding a live deadline must zero the recorder on Close so the next
|
||||||
|
// engine's watcher (and the UI reading the shared server-scoped recorder)
|
||||||
|
// doesn't start out showing the previous session's stale "expires in".
|
||||||
|
func TestCloseClearsRecorderDeadline(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r)
|
||||||
|
|
||||||
|
d := time.Now().Add(2 * time.Hour)
|
||||||
|
if err := w.Update(d); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
if got := r.deadline(); !got.Equal(d) {
|
||||||
|
t.Fatalf("recorder deadline after Update = %v, want %v", got, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
if got := r.deadline(); !got.IsZero() {
|
||||||
|
t.Fatalf("recorder deadline after Close = %v, want zero", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric
|
||||||
|
// case: closing a watcher that never held a deadline must not emit a
|
||||||
|
// redundant clear (the recorder may legitimately hold a value written by
|
||||||
|
// some other path; the watcher only owns what it set).
|
||||||
|
func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r)
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
if got := r.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no events from Close on an empty watcher, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
// Warning fires at deadline-80ms, final at deadline-30ms.
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
// Expect stateChange + warning + final-warning.
|
||||||
|
events := waitForEvents(t, r, 3)
|
||||||
|
|
||||||
|
if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 stateChange, got %+v", events)
|
||||||
|
}
|
||||||
|
if countWhere(events, event.isWarning) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 warning publish, got %+v", events)
|
||||||
|
}
|
||||||
|
if countWhere(events, event.isFinalWarning) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 final-warning publish, got %+v", events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warning must precede final (same deadline, longer lead fires first).
|
||||||
|
var wIdx, fIdx int
|
||||||
|
for i, e := range events {
|
||||||
|
switch {
|
||||||
|
case e.isWarning():
|
||||||
|
wIdx = i
|
||||||
|
case e.isFinalWarning():
|
||||||
|
fIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wIdx > fIdx {
|
||||||
|
t.Fatalf("warning must publish before final-warning, got order %+v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissSuppressesFinalWarning(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
// Wait for the warning publish so we know we're inside the warning
|
||||||
|
// window, then dismiss before the final timer would fire.
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if countWhere(r.snapshot(), event.isWarning) >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if countWhere(r.snapshot(), event.isWarning) < 1 {
|
||||||
|
t.Fatalf("warning did not publish in time, events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Dismiss()
|
||||||
|
|
||||||
|
// Now wait past when the final would have fired.
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
|
||||||
|
if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 {
|
||||||
|
t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissResetByNewDeadline(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
first := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(first)
|
||||||
|
|
||||||
|
// Dismiss against the first deadline.
|
||||||
|
w.Dismiss()
|
||||||
|
|
||||||
|
// Replace with a fresh deadline before the first's timers complete.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
second := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(second)
|
||||||
|
|
||||||
|
// The second cycle must publish a final-warning (the dismiss state
|
||||||
|
// did not carry over).
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if countWhere(r.snapshot(), event.isFinalWarning) < 1 {
|
||||||
|
t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
|
||||||
|
w.Dismiss()
|
||||||
|
|
||||||
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
// Final warning should still publish — Dismiss only acts on the current
|
||||||
|
// deadline, and there was none at the time of the call.
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
@@ -16,6 +16,16 @@ import (
|
|||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// lazyForce is the resolved local decision for lazy connections, layered above the
|
||||||
|
// management feature flag. lazyForceNone defers to management.
|
||||||
|
type lazyForce int
|
||||||
|
|
||||||
|
const (
|
||||||
|
lazyForceNone lazyForce = iota
|
||||||
|
lazyForceOn
|
||||||
|
lazyForceOff
|
||||||
|
)
|
||||||
|
|
||||||
// ConnMgr coordinates both lazy connections (established on-demand) and permanent peer connections.
|
// ConnMgr coordinates both lazy connections (established on-demand) and permanent peer connections.
|
||||||
//
|
//
|
||||||
// The connection manager is responsible for:
|
// The connection manager is responsible for:
|
||||||
@@ -28,7 +38,7 @@ type ConnMgr struct {
|
|||||||
peerStore *peerstore.Store
|
peerStore *peerstore.Store
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
iface lazyconn.WGIface
|
iface lazyconn.WGIface
|
||||||
enabledLocally bool
|
force lazyForce
|
||||||
rosenpassEnabled bool
|
rosenpassEnabled bool
|
||||||
|
|
||||||
lazyConnMgr *manager.Manager
|
lazyConnMgr *manager.Manager
|
||||||
@@ -43,28 +53,34 @@ func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerSto
|
|||||||
peerStore: peerStore,
|
peerStore: peerStore,
|
||||||
statusRecorder: statusRecorder,
|
statusRecorder: statusRecorder,
|
||||||
iface: iface,
|
iface: iface,
|
||||||
|
force: resolveLazyForce(engineConfig.LazyConnection),
|
||||||
rosenpassEnabled: engineConfig.RosenpassEnabled,
|
rosenpassEnabled: engineConfig.RosenpassEnabled,
|
||||||
}
|
}
|
||||||
if engineConfig.LazyConnectionEnabled || lazyconn.IsLazyConnEnabledByEnv() {
|
|
||||||
e.enabledLocally = true
|
|
||||||
}
|
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initializes the connection manager and starts the lazy connection manager if enabled by env var or cmd line option.
|
// Start initializes the connection manager. It starts the lazy connection manager when a
|
||||||
|
// local override forces it on; with no local override it waits for the management feature flag.
|
||||||
func (e *ConnMgr) Start(ctx context.Context) {
|
func (e *ConnMgr) Start(ctx context.Context) {
|
||||||
if e.lazyConnMgr != nil {
|
if e.lazyConnMgr != nil {
|
||||||
log.Errorf("lazy connection manager is already started")
|
log.Errorf("lazy connection manager is already started")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !e.enabledLocally {
|
switch e.force {
|
||||||
log.Infof("lazy connection manager is disabled")
|
case lazyForceOff:
|
||||||
|
log.Infof("lazy connection manager is disabled by local override (%s or MDM policy)", lazyconn.EnvLazyConn)
|
||||||
|
e.statusRecorder.UpdateLazyConnection(false)
|
||||||
|
return
|
||||||
|
case lazyForceNone:
|
||||||
|
log.Infof("lazy connection manager is managed by the management feature flag")
|
||||||
|
e.statusRecorder.UpdateLazyConnection(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if e.rosenpassEnabled {
|
if e.rosenpassEnabled {
|
||||||
log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started")
|
log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started")
|
||||||
|
e.statusRecorder.UpdateLazyConnection(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +92,8 @@ func (e *ConnMgr) Start(ctx context.Context) {
|
|||||||
// If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again.
|
// If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again.
|
||||||
// If disabled, then it closes the lazy connection manager and open the connections to all peers.
|
// If disabled, then it closes the lazy connection manager and open the connections to all peers.
|
||||||
func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error {
|
func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error {
|
||||||
// do not disable lazy connection manager if it was enabled by env var
|
// a local override (NB_LAZY_CONN or local config) takes precedence over management
|
||||||
if e.enabledLocally {
|
if e.force != lazyForceNone {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +105,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er
|
|||||||
|
|
||||||
if e.rosenpassEnabled {
|
if e.rosenpassEnabled {
|
||||||
log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started")
|
log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started")
|
||||||
|
e.statusRecorder.UpdateLazyConnection(false)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +115,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er
|
|||||||
return e.addPeersToLazyConnManager()
|
return e.addPeersToLazyConnManager()
|
||||||
} else {
|
} else {
|
||||||
if e.lazyConnMgr == nil {
|
if e.lazyConnMgr == nil {
|
||||||
|
e.statusRecorder.UpdateLazyConnection(false)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Infof("lazy connection manager is disabled by management feature flag")
|
log.Infof("lazy connection manager is disabled by management feature flag")
|
||||||
@@ -309,6 +327,25 @@ func (e *ConnMgr) isStartedWithLazyMgr() bool {
|
|||||||
return e.lazyConnMgr != nil && e.lazyCtxCancel != nil
|
return e.lazyConnMgr != nil && e.lazyCtxCancel != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveLazyForce determines the local override. NB_LAZY_CONN takes precedence; when it
|
||||||
|
// is unset the MDM policy override (mdmState) applies. Either wins in both directions over
|
||||||
|
// the management feature flag; StateUnset for both defers to management.
|
||||||
|
func resolveLazyForce(mdmState lazyconn.State) lazyForce {
|
||||||
|
state := lazyconn.EnvState()
|
||||||
|
if state == lazyconn.StateUnset {
|
||||||
|
state = mdmState
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case lazyconn.StateOn:
|
||||||
|
return lazyForceOn
|
||||||
|
case lazyconn.StateOff:
|
||||||
|
return lazyForceOff
|
||||||
|
default:
|
||||||
|
return lazyForceNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func inactivityThresholdEnv() *time.Duration {
|
func inactivityThresholdEnv() *time.Duration {
|
||||||
envValue := os.Getenv(lazyconn.EnvInactivityThreshold)
|
envValue := os.Getenv(lazyconn.EnvInactivityThreshold)
|
||||||
if envValue == "" {
|
if envValue == "" {
|
||||||
|
|||||||
40
client/internal/conn_mgr_test.go
Normal file
40
client/internal/conn_mgr_test.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveLazyForce(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
envSet bool
|
||||||
|
mdm lazyconn.State
|
||||||
|
want lazyForce
|
||||||
|
}{
|
||||||
|
{name: "env unset, mdm unset -> defer to management", mdm: lazyconn.StateUnset, want: lazyForceNone},
|
||||||
|
{name: "env on -> force on", env: "on", envSet: true, mdm: lazyconn.StateUnset, want: lazyForceOn},
|
||||||
|
{name: "env off -> force off", env: "off", envSet: true, mdm: lazyconn.StateUnset, want: lazyForceOff},
|
||||||
|
{name: "env unset, mdm on -> force on", mdm: lazyconn.StateOn, want: lazyForceOn},
|
||||||
|
{name: "env unset, mdm off -> force off", mdm: lazyconn.StateOff, want: lazyForceOff},
|
||||||
|
{name: "env on beats mdm off", env: "on", envSet: true, mdm: lazyconn.StateOff, want: lazyForceOn},
|
||||||
|
{name: "env off beats mdm on", env: "off", envSet: true, mdm: lazyconn.StateOn, want: lazyForceOff},
|
||||||
|
{name: "unrecognized env, mdm on -> mdm wins", env: "auto", envSet: true, mdm: lazyconn.StateOn, want: lazyForceOn},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Setenv(lazyconn.EnvLazyConn, tt.env)
|
||||||
|
if !tt.envSet {
|
||||||
|
os.Unsetenv(lazyconn.EnvLazyConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := resolveLazyForce(tt.mdm); got != tt.want {
|
||||||
|
t.Fatalf("resolveLazyForce(%v) = %v, want %v", tt.mdm, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
"github.com/netbirdio/netbird/client/internal/dns"
|
"github.com/netbirdio/netbird/client/internal/dns"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||||
"github.com/netbirdio/netbird/client/internal/listener"
|
"github.com/netbirdio/netbird/client/internal/listener"
|
||||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
@@ -276,6 +277,15 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
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)
|
||||||
@@ -314,6 +324,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), true)
|
c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), true)
|
||||||
c.statusRecorder.MarkManagementConnected()
|
c.statusRecorder.MarkManagementConnected()
|
||||||
|
|
||||||
|
if metricsConfig := loginResp.GetNetbirdConfig().GetMetrics(); metricsConfig != nil {
|
||||||
|
c.clientMetrics.UpdatePushFromMgm(c.ctx, metricsConfig.GetEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
localPeerState := peer.LocalPeerState{
|
localPeerState := peer.LocalPeerState{
|
||||||
IP: loginResp.GetPeerConfig().GetAddress(),
|
IP: loginResp.GetPeerConfig().GetAddress(),
|
||||||
PubKey: myPrivateKey.PublicKey().String(),
|
PubKey: myPrivateKey.PublicKey().String(),
|
||||||
@@ -399,6 +413,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
StateManager: stateManager,
|
StateManager: stateManager,
|
||||||
UpdateManager: c.updateManager,
|
UpdateManager: c.updateManager,
|
||||||
ClientMetrics: c.clientMetrics,
|
ClientMetrics: c.clientMetrics,
|
||||||
|
MetricsCtx: c.ctx,
|
||||||
}, mobileDependency)
|
}, mobileDependency)
|
||||||
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
||||||
c.engine = engine
|
c.engine = engine
|
||||||
@@ -409,6 +424,10 @@ 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)
|
||||||
|
|
||||||
@@ -445,6 +464,10 @@ 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)
|
||||||
@@ -596,7 +619,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
|||||||
BlockInbound: config.BlockInbound,
|
BlockInbound: config.BlockInbound,
|
||||||
DisableIPv6: config.DisableIPv6,
|
DisableIPv6: config.DisableIPv6,
|
||||||
|
|
||||||
LazyConnectionEnabled: config.LazyConnectionEnabled,
|
LazyConnection: lazyconn.ParseState(config.LazyConnection),
|
||||||
|
|
||||||
MTU: selectMTU(config.MTU, peerConfig.Mtu),
|
MTU: selectMTU(config.MTU, peerConfig.Mtu),
|
||||||
LogPath: logPath,
|
LogPath: logPath,
|
||||||
@@ -670,7 +693,6 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
|||||||
config.BlockLANAccess,
|
config.BlockLANAccess,
|
||||||
config.BlockInbound,
|
config.BlockInbound,
|
||||||
config.DisableIPv6,
|
config.DisableIPv6,
|
||||||
config.LazyConnectionEnabled,
|
|
||||||
config.EnableSSHRoot,
|
config.EnableSSHRoot,
|
||||||
config.EnableSSHSFTP,
|
config.EnableSSHSFTP,
|
||||||
config.EnableSSHLocalPortForwarding,
|
config.EnableSSHLocalPortForwarding,
|
||||||
|
|||||||
@@ -229,9 +229,16 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -249,6 +256,7 @@ 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
|
||||||
@@ -276,6 +284,7 @@ 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
|
||||||
@@ -300,6 +309,7 @@ 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,
|
||||||
@@ -411,6 +421,10 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -681,7 +695,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
|||||||
configContent.WriteString(fmt.Sprintf("ClientCertKeyPath: %s\n", g.internalConfig.ClientCertKeyPath))
|
configContent.WriteString(fmt.Sprintf("ClientCertKeyPath: %s\n", g.internalConfig.ClientCertKeyPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
configContent.WriteString(fmt.Sprintf("LazyConnectionEnabled: %v\n", g.internalConfig.LazyConnectionEnabled))
|
configContent.WriteString(fmt.Sprintf("LazyConnection: %q\n", g.internalConfig.LazyConnection))
|
||||||
configContent.WriteString(fmt.Sprintf("MTU: %d\n", g.internalConfig.MTU))
|
configContent.WriteString(fmt.Sprintf("MTU: %d\n", g.internalConfig.MTU))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,7 +1000,7 @@ func (g *BundleGenerator) addLogfile() error {
|
|||||||
return fmt.Errorf("add client log file to zip: %w", err)
|
return fmt.Errorf("add client log file to zip: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
g.addRotatedLogFiles(logDir)
|
g.addRotatedLogFiles(logDir, clientLogPrefix)
|
||||||
|
|
||||||
stdErrLogPath := filepath.Join(logDir, errorLogFile)
|
stdErrLogPath := filepath.Join(logDir, errorLogFile)
|
||||||
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
|
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
|
||||||
@@ -1006,6 +1020,25 @@ 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)
|
||||||
@@ -1078,14 +1111,16 @@ 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.
|
||||||
func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
// prefix is the base log name without extension (e.g. "client", "gui-client");
|
||||||
|
// the glob matches both files rotated by us and by logrotate on linux.
|
||||||
|
func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) {
|
||||||
if g.logFileCount == 0 {
|
if g.logFileCount == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// This regex will match both logs rotated by us and logrotate on linux
|
// This pattern matches both logs rotated by us and logrotate on linux
|
||||||
pattern := filepath.Join(logDir, "client*.log.*")
|
pattern := filepath.Join(logDir, prefix+"*.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,6 +40,25 @@ 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) {
|
||||||
@@ -67,6 +86,10 @@ 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
|
||||||
@@ -74,7 +97,7 @@ func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[st
|
|||||||
archive: zip.NewWriter(&buf),
|
archive: zip.NewWriter(&buf),
|
||||||
logFileCount: logFileCount,
|
logFileCount: logFileCount,
|
||||||
}
|
}
|
||||||
g.addRotatedLogFiles(dir)
|
g.addRotatedLogFiles(dir, prefix)
|
||||||
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()))
|
||||||
|
|||||||
@@ -885,7 +885,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
|||||||
DNSRouteInterval: 5 * time.Second,
|
DNSRouteInterval: 5 * time.Second,
|
||||||
ClientCertPath: "/tmp/cert",
|
ClientCertPath: "/tmp/cert",
|
||||||
ClientCertKeyPath: "/tmp/key",
|
ClientCertKeyPath: "/tmp/key",
|
||||||
LazyConnectionEnabled: true,
|
LazyConnection: "on",
|
||||||
MTU: 1280,
|
MTU: 1280,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/dnsfwd"
|
"github.com/netbirdio/netbird/client/internal/dnsfwd"
|
||||||
"github.com/netbirdio/netbird/client/internal/expose"
|
"github.com/netbirdio/netbird/client/internal/expose"
|
||||||
"github.com/netbirdio/netbird/client/internal/ingressgw"
|
"github.com/netbirdio/netbird/client/internal/ingressgw"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow"
|
"github.com/netbirdio/netbird/client/internal/netflow"
|
||||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
@@ -82,6 +83,12 @@ const (
|
|||||||
PeerConnectionTimeoutMax = 45000 // ms
|
PeerConnectionTimeoutMax = 45000 // ms
|
||||||
PeerConnectionTimeoutMin = 30000 // ms
|
PeerConnectionTimeoutMin = 30000 // ms
|
||||||
disableAutoUpdate = "disabled"
|
disableAutoUpdate = "disabled"
|
||||||
|
|
||||||
|
// systemInfoTimeout bounds how long the sync loop waits for system info / posture
|
||||||
|
// check gathering. The gathering runs uncancellable system calls (process scan,
|
||||||
|
// exec, os.Stat); without this bound a single stuck call freezes handleSync, and
|
||||||
|
// thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes).
|
||||||
|
systemInfoTimeout = 15 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrResetConnection = fmt.Errorf("reset connection")
|
var ErrResetConnection = fmt.Errorf("reset connection")
|
||||||
@@ -141,7 +148,9 @@ type EngineConfig struct {
|
|||||||
BlockInbound bool
|
BlockInbound bool
|
||||||
DisableIPv6 bool
|
DisableIPv6 bool
|
||||||
|
|
||||||
LazyConnectionEnabled bool
|
// LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to
|
||||||
|
// the env var and management feature flag.
|
||||||
|
LazyConnection lazyconn.State
|
||||||
|
|
||||||
MTU uint16
|
MTU uint16
|
||||||
|
|
||||||
@@ -166,6 +175,7 @@ type EngineServices struct {
|
|||||||
StateManager *statemanager.Manager
|
StateManager *statemanager.Manager
|
||||||
UpdateManager *updater.Manager
|
UpdateManager *updater.Manager
|
||||||
ClientMetrics *metrics.ClientMetrics
|
ClientMetrics *metrics.ClientMetrics
|
||||||
|
MetricsCtx context.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
||||||
@@ -258,11 +268,26 @@ type Engine struct {
|
|||||||
|
|
||||||
// clientMetrics collects and pushes metrics
|
// clientMetrics collects and pushes metrics
|
||||||
clientMetrics *metrics.ClientMetrics
|
clientMetrics *metrics.ClientMetrics
|
||||||
|
metricsCtx context.Context
|
||||||
|
|
||||||
jobExecutor *jobexec.Executor
|
jobExecutor *jobexec.Executor
|
||||||
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
|
||||||
@@ -310,9 +335,21 @@ func NewEngine(
|
|||||||
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
|
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
|
||||||
jobExecutor: jobexec.NewExecutor(),
|
jobExecutor: jobexec.NewExecutor(),
|
||||||
clientMetrics: services.ClientMetrics,
|
clientMetrics: services.ClientMetrics,
|
||||||
|
metricsCtx: services.MetricsCtx,
|
||||||
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
|
||||||
@@ -379,6 +416,10 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -920,6 +961,8 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
return e.ctx.Err()
|
return e.ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(update.GetSessionExpiresAt())
|
||||||
|
|
||||||
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
||||||
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
||||||
}
|
}
|
||||||
@@ -991,6 +1034,8 @@ func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error {
|
|||||||
return fmt.Errorf("handle the flow configuration: %w", err)
|
return fmt.Errorf("handle the flow configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.handleMetricsUpdate(wCfg.GetMetrics())
|
||||||
|
|
||||||
if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
|
if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
|
||||||
log.Warnf("Failed to update DNS server config: %v", err)
|
log.Warnf("Failed to update DNS server config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1060,6 +1105,14 @@ func (e *Engine) handleFlowUpdate(config *mgmProto.FlowConfig) error {
|
|||||||
return e.flowManager.Update(flowConfig)
|
return e.flowManager.Update(flowConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) handleMetricsUpdate(config *mgmProto.MetricsConfig) {
|
||||||
|
if config == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("received metrics configuration from management: enabled=%v", config.GetEnabled())
|
||||||
|
e.clientMetrics.UpdatePushFromMgm(e.metricsCtx, config.GetEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
func toFlowLoggerConfig(config *mgmProto.FlowConfig) (*nftypes.FlowConfig, error) {
|
func toFlowLoggerConfig(config *mgmProto.FlowConfig) (*nftypes.FlowConfig, error) {
|
||||||
if config.GetInterval() == nil {
|
if config.GetInterval() == nil {
|
||||||
return nil, errors.New("flow interval is nil")
|
return nil, errors.New("flow interval is nil")
|
||||||
@@ -1084,11 +1137,22 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
|||||||
}
|
}
|
||||||
e.checks = checks
|
e.checks = checks
|
||||||
|
|
||||||
info, err := system.GetInfoWithChecks(e.ctx, checks, e.overlayAddresses()...)
|
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
|
||||||
if err != nil {
|
if !ok {
|
||||||
log.Warnf("failed to get system info with checks: %v", err)
|
// Gathering timed out; skip the meta sync this cycle rather than blocking the
|
||||||
info = system.GetInfo(e.ctx)
|
// sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
e.applyInfoFlags(info)
|
||||||
|
|
||||||
|
if err := e.mgmClient.SyncMeta(info); err != nil {
|
||||||
|
return fmt.Errorf("could not sync meta: error %s", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyInfoFlags sets the engine's config-derived feature flags on the gathered system info.
|
||||||
|
func (e *Engine) applyInfoFlags(info *system.Info) {
|
||||||
info.SetFlags(
|
info.SetFlags(
|
||||||
e.config.RosenpassEnabled,
|
e.config.RosenpassEnabled,
|
||||||
e.config.RosenpassPermissive,
|
e.config.RosenpassPermissive,
|
||||||
@@ -1100,19 +1164,12 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
|||||||
e.config.BlockLANAccess,
|
e.config.BlockLANAccess,
|
||||||
e.config.BlockInbound,
|
e.config.BlockInbound,
|
||||||
e.config.DisableIPv6,
|
e.config.DisableIPv6,
|
||||||
e.config.LazyConnectionEnabled,
|
|
||||||
e.config.EnableSSHRoot,
|
e.config.EnableSSHRoot,
|
||||||
e.config.EnableSSHSFTP,
|
e.config.EnableSSHSFTP,
|
||||||
e.config.EnableSSHLocalPortForwarding,
|
e.config.EnableSSHLocalPortForwarding,
|
||||||
e.config.EnableSSHRemotePortForwarding,
|
e.config.EnableSSHRemotePortForwarding,
|
||||||
e.config.DisableSSHAuth,
|
e.config.DisableSSHAuth,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := e.mgmClient.SyncMeta(info); err != nil {
|
|
||||||
log.Errorf("could not sync meta: error %s", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
|
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
|
||||||
@@ -1241,7 +1298,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(true)
|
e.RunHealthProbes(e.ctx, true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1272,31 +1329,15 @@ func (e *Engine) receiveManagementEvents() {
|
|||||||
e.shutdownWg.Add(1)
|
e.shutdownWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer e.shutdownWg.Done()
|
defer e.shutdownWg.Done()
|
||||||
info, err := system.GetInfoWithChecks(e.ctx, e.checks, e.overlayAddresses()...)
|
info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
|
||||||
if err != nil {
|
if !ok {
|
||||||
log.Warnf("failed to get system info with checks: %v", err)
|
// Gathering timed out; connect the stream with base info so management
|
||||||
|
// connectivity still comes up rather than blocking here.
|
||||||
info = system.GetInfo(e.ctx)
|
info = system.GetInfo(e.ctx)
|
||||||
}
|
}
|
||||||
info.SetFlags(
|
e.applyInfoFlags(info)
|
||||||
e.config.RosenpassEnabled,
|
|
||||||
e.config.RosenpassPermissive,
|
|
||||||
&e.config.ServerSSHAllowed,
|
|
||||||
e.config.DisableClientRoutes,
|
|
||||||
e.config.DisableServerRoutes,
|
|
||||||
e.config.DisableDNS,
|
|
||||||
e.config.DisableFirewall,
|
|
||||||
e.config.BlockLANAccess,
|
|
||||||
e.config.BlockInbound,
|
|
||||||
e.config.DisableIPv6,
|
|
||||||
e.config.LazyConnectionEnabled,
|
|
||||||
e.config.EnableSSHRoot,
|
|
||||||
e.config.EnableSSHSFTP,
|
|
||||||
e.config.EnableSSHLocalPortForwarding,
|
|
||||||
e.config.EnableSSHRemotePortForwarding,
|
|
||||||
e.config.DisableSSHAuth,
|
|
||||||
)
|
|
||||||
|
|
||||||
err = e.mgmClient.Sync(e.ctx, info, e.handleSync)
|
err := e.mgmClient.Sync(e.ctx, info, e.handleSync)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// happens if management is unavailable for a long time.
|
// happens if management is unavailable for a long time.
|
||||||
// We want to cancel the operation of the whole client
|
// We want to cancel the operation of the whole client
|
||||||
@@ -1991,7 +2032,6 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
|||||||
e.config.BlockLANAccess,
|
e.config.BlockLANAccess,
|
||||||
e.config.BlockInbound,
|
e.config.BlockInbound,
|
||||||
e.config.DisableIPv6,
|
e.config.DisableIPv6,
|
||||||
e.config.LazyConnectionEnabled,
|
|
||||||
e.config.EnableSSHRoot,
|
e.config.EnableSSHRoot,
|
||||||
e.config.EnableSSHSFTP,
|
e.config.EnableSSHSFTP,
|
||||||
e.config.EnableSSHLocalPortForwarding,
|
e.config.EnableSSHLocalPortForwarding,
|
||||||
@@ -2184,7 +2224,20 @@ 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()
|
||||||
@@ -2207,9 +2260,9 @@ func (e *Engine) RunHealthProbes(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(e.ctx, stuns, turns)
|
results = e.probeStunTurn.ProbeAllWaitResult(ctx, stuns, turns)
|
||||||
} else {
|
} else {
|
||||||
results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns)
|
results = e.probeStunTurn.ProbeAll(ctx, stuns, turns)
|
||||||
}
|
}
|
||||||
e.statusRecorder.UpdateRelayStates(results)
|
e.statusRecorder.UpdateRelayStates(results)
|
||||||
|
|
||||||
|
|||||||
108
client/internal/engine_authsession.go
Normal file
108
client/internal/engine_authsession.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||||
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
|
"github.com/netbirdio/netbird/client/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplySessionDeadline propagates the absolute SSO session deadline carried on
|
||||||
|
// LoginResponse / SyncResponse to both the watcher (for the edge-triggered
|
||||||
|
// warning) and the status recorder (for the SubscribeStatus / Status RPC
|
||||||
|
// snapshot the UI consumes).
|
||||||
|
//
|
||||||
|
// The wire field is 3-state:
|
||||||
|
// - nil → snapshot carries no info; keep the
|
||||||
|
// previously-anchored deadline (no-op)
|
||||||
|
// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is
|
||||||
|
// disabled; clear both sinks
|
||||||
|
// - valid timestamp → new deadline; arm watcher, expose on
|
||||||
|
// status recorder
|
||||||
|
//
|
||||||
|
// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected
|
||||||
|
// value is treated as a clear on both sinks: the alternative — leaving the
|
||||||
|
// previously-known deadline in place — risks the UI confidently displaying
|
||||||
|
// a stale "expires in X" while the server has actually invalidated it.
|
||||||
|
func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
|
||||||
|
if ts == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var deadline time.Time
|
||||||
|
// Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled".
|
||||||
|
// Everything else flows through Watcher.Update, whose sanity-checks
|
||||||
|
// reject out-of-range / pre-epoch / far-future / too-stale values and
|
||||||
|
// clear on rejection.
|
||||||
|
if ts.GetSeconds() != 0 || ts.GetNanos() != 0 {
|
||||||
|
deadline = ts.AsTime().UTC()
|
||||||
|
}
|
||||||
|
if e.sessionWatcher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Watcher.Update owns the propagation to the status recorder (the
|
||||||
|
// SubscribeStatus / Status snapshot the UI reads): a set writes the
|
||||||
|
// deadline, a clear or a sanity-check rejection writes the zero value.
|
||||||
|
// Keeping a single writer is what stops the recorder from drifting out
|
||||||
|
// of sync with the warning timers.
|
||||||
|
if err := e.sessionWatcher.Update(deadline); err != nil {
|
||||||
|
log.Errorf("auth session deadline rejected: %v, clearing", err)
|
||||||
|
e.statusRecorder.PublishEvent(
|
||||||
|
cProto.SystemEvent_ERROR,
|
||||||
|
cProto.SystemEvent_AUTHENTICATION,
|
||||||
|
"session deadline rejected",
|
||||||
|
"",
|
||||||
|
map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissSessionWarning records the user's "Dismiss" click on the
|
||||||
|
// T-WarningLead interactive notification and suppresses the upcoming
|
||||||
|
// T-FinalWarningLead fallback for the current deadline. No-op when the
|
||||||
|
// watcher is not running or holds no deadline.
|
||||||
|
func (e *Engine) DismissSessionWarning() {
|
||||||
|
if e.sessionWatcher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.sessionWatcher.Dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtendAuthSession asks the management server to refresh the SSO session
|
||||||
|
// expiry deadline using the supplied JWT, then mirrors the new deadline into
|
||||||
|
// the daemon's state. The tunnel is untouched; no resync, no reconnect.
|
||||||
|
//
|
||||||
|
// Returns the new absolute UTC deadline (or zero time when the server
|
||||||
|
// reports the peer is not eligible for extension).
|
||||||
|
func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) {
|
||||||
|
if jwtToken == "" {
|
||||||
|
return time.Time{}, errors.New("jwt token is required")
|
||||||
|
}
|
||||||
|
if e.mgmClient == nil {
|
||||||
|
return time.Time{}, errors.New("management client is not initialised")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := system.GetInfoWithChecks(ctx, e.checks)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to collect system info for session extend: %v", err)
|
||||||
|
info = system.GetInfo(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("extend auth session on management: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(resp.GetSessionExpiresAt())
|
||||||
|
|
||||||
|
if resp.GetSessionExpiresAt().IsValid() {
|
||||||
|
return resp.GetSessionExpiresAt().AsTime().UTC(), nil
|
||||||
|
}
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
78
client/internal/engine_session_deadline_test.go
Normal file
78
client/internal/engine_session_deadline_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the
|
||||||
|
// wire field carried on LoginResponse / SyncResponse:
|
||||||
|
//
|
||||||
|
// - nil pointer → no info; previously-anchored deadline survives
|
||||||
|
// - explicit zero value → "expiry disabled" sentinel; both sinks cleared
|
||||||
|
// - valid future timestamp → new deadline propagated to both sinks
|
||||||
|
func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
||||||
|
newEngine := func() *Engine {
|
||||||
|
recorder := peer.NewRecorder("")
|
||||||
|
return &Engine{
|
||||||
|
statusRecorder: recorder,
|
||||||
|
sessionWatcher: sessionwatch.New(recorder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(deadline))
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline),
|
||||||
|
"status recorder should hold the new deadline")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(nil)
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded),
|
||||||
|
"nil snapshot must not disturb the existing deadline")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||||
|
|
||||||
|
// Explicit zero Timestamp{} (seconds=0, nanos=0) is the
|
||||||
|
// "expiry disabled / not SSO" sentinel.
|
||||||
|
e.ApplySessionDeadline(×tamppb.Timestamp{})
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||||
|
"explicit zero sentinel must clear the deadline")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid timestamp clears the deadline", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||||
|
|
||||||
|
// Out-of-range nanos → IsValid()==false; same-meaning as the
|
||||||
|
// disabled sentinel for downstream sinks.
|
||||||
|
e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1})
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||||
|
"invalid timestamp must clear the deadline")
|
||||||
|
})
|
||||||
|
}
|
||||||
16
client/internal/engine_sessionwatch.go
Normal file
16
client/internal/engine_sessionwatch.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !js
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newSessionWatcher returns the real SSO session expiry watcher for every
|
||||||
|
// non-wasm build. The js/wasm build gets a no-op stub from
|
||||||
|
// engine_sessionwatch_js.go so the sessionwatch package (and its timer
|
||||||
|
// machinery) never links into the wasm binary.
|
||||||
|
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
||||||
|
return sessionwatch.New(recorder)
|
||||||
|
}
|
||||||
44
client/internal/engine_sessionwatch_js.go
Normal file
44
client/internal/engine_sessionwatch_js.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
//go:build js
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The
|
||||||
|
// wasm client never runs the engine's session-warning flow (the interactive
|
||||||
|
// T-WarningLead notification and the T-FinalWarningLead fallback dialog live
|
||||||
|
// in the desktop UI), so linking the full sessionwatch package (timers, event
|
||||||
|
// composition) would only bloat the binary.
|
||||||
|
//
|
||||||
|
// It still mirrors the deadline into the status recorder so the SubscribeStatus
|
||||||
|
// / Status snapshot the UI consumes stays correct — only the timer-driven
|
||||||
|
// warnings are dropped.
|
||||||
|
type noopSessionWatcher struct {
|
||||||
|
recorder *peer.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
||||||
|
return noopSessionWatcher{recorder: recorder}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mirrors the real watcher's recorder propagation without the timers or
|
||||||
|
// sanity-check sentinels: a valid deadline is exposed on the status snapshot,
|
||||||
|
// the zero time clears it.
|
||||||
|
func (w noopSessionWatcher) Update(deadline time.Time) error {
|
||||||
|
if w.recorder != nil {
|
||||||
|
w.recorder.SetSessionExpiresAt(deadline)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (noopSessionWatcher) Dismiss() {
|
||||||
|
// No-op: only suppresses the timer-driven final-warning, which this stub never arms.
|
||||||
|
}
|
||||||
|
|
||||||
|
func (noopSessionWatcher) Close() {
|
||||||
|
// No-op: no timers to stop and no state to unwind; the recorder is cleared via Update(zero).
|
||||||
|
}
|
||||||
@@ -178,6 +178,10 @@ func (m *MockWGIface) LastActivities() map[string]monotime.Time {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockWGIface) MTU() uint16 {
|
||||||
|
return 1280
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockWGIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
|
func (m *MockWGIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,4 +44,5 @@ type wgIfaceBase interface {
|
|||||||
FullStats() (*configurer.Stats, error)
|
FullStats() (*configurer.Stats, error)
|
||||||
LastActivities() map[string]monotime.Time
|
LastActivities() map[string]monotime.Time
|
||||||
SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
|
SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
|
||||||
|
MTU() uint16
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ func (d *BindListener) ReadPackets() {
|
|||||||
d.done.Done()
|
d.done.Done()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CapturedPacket is unused in userspace bind mode: first-packet reinjection is kernel-only.
|
||||||
|
func (d *BindListener) CapturedPacket() []byte {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Close stops the listener and cleans up resources.
|
// Close stops the listener and cleans up resources.
|
||||||
func (d *BindListener) Close() {
|
func (d *BindListener) Close() {
|
||||||
d.peerCfg.Log.Infof("closing activity listener (LazyConn)")
|
d.peerCfg.Log.Infof("closing activity listener (LazyConn)")
|
||||||
|
|||||||
@@ -45,10 +45,6 @@ type MockWGIfaceBind struct {
|
|||||||
endpointMgr *mockEndpointManager
|
endpointMgr *mockEndpointManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockWGIfaceBind) RemovePeer(string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockWGIfaceBind) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
|
func (m *MockWGIfaceBind) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -68,6 +64,10 @@ func (m *MockWGIfaceBind) GetBind() device.EndpointManager {
|
|||||||
return m.endpointMgr
|
return m.endpointMgr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockWGIfaceBind) MTU() uint16 {
|
||||||
|
return 1280
|
||||||
|
}
|
||||||
|
|
||||||
func TestBindListener_Creation(t *testing.T) {
|
func TestBindListener_Creation(t *testing.T) {
|
||||||
mockEndpointMgr := newMockEndpointManager()
|
mockEndpointMgr := newMockEndpointManager()
|
||||||
mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr}
|
mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr}
|
||||||
@@ -207,8 +207,9 @@ func TestManager_BindMode(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case peerConnID := <-mgr.OnActivityChan:
|
case ev := <-mgr.OnActivityChan:
|
||||||
assert.Equal(t, cfg.PeerConnID, peerConnID, "Received peer connection ID should match")
|
assert.Equal(t, cfg.PeerConnID, ev.PeerConnID, "Received peer connection ID should match")
|
||||||
|
assert.Nil(t, ev.FirstPacket, "Bind mode does not capture packets: reinjection is kernel-only")
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("timeout waiting for activity notification")
|
t.Fatal("timeout waiting for activity notification")
|
||||||
}
|
}
|
||||||
@@ -266,8 +267,8 @@ func TestManager_BindMode_MultiplePeers(t *testing.T) {
|
|||||||
receivedPeers := make(map[peerid.ConnID]bool)
|
receivedPeers := make(map[peerid.ConnID]bool)
|
||||||
for i := 0; i < 2; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
select {
|
select {
|
||||||
case peerConnID := <-mgr.OnActivityChan:
|
case ev := <-mgr.OnActivityChan:
|
||||||
receivedPeers[peerConnID] = true
|
receivedPeers[ev.PeerConnID] = true
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("timeout waiting for activity notifications")
|
t.Fatal("timeout waiting for activity notifications")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package activity
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/iface/bufsize"
|
||||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +22,8 @@ type UDPListener struct {
|
|||||||
done sync.Mutex
|
done sync.Mutex
|
||||||
|
|
||||||
isClosed atomic.Bool
|
isClosed atomic.Bool
|
||||||
|
|
||||||
|
capturedPacket []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUDPListener creates a listener that detects activity via UDP socket reads.
|
// NewUDPListener creates a listener that detects activity via UDP socket reads.
|
||||||
@@ -46,9 +50,13 @@ func NewUDPListener(wgIface WgInterface, cfg lazyconn.PeerConfig) (*UDPListener,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ReadPackets blocks reading from the UDP socket until activity is detected or the listener is closed.
|
// ReadPackets blocks reading from the UDP socket until activity is detected or the listener is closed.
|
||||||
|
// The first packet that triggers activity is captured so it can be reinjected through the real
|
||||||
|
// transport once it is established. Without this, kernel WireGuard's handshake initiation would be
|
||||||
|
// dropped and WG would only retry after REKEY_TIMEOUT.
|
||||||
func (d *UDPListener) ReadPackets() {
|
func (d *UDPListener) ReadPackets() {
|
||||||
for {
|
for {
|
||||||
n, remoteAddr, err := d.conn.ReadFromUDP(make([]byte, 1))
|
buf := make([]byte, int(d.wgIface.MTU())+bufsize.WGBufferOverhead)
|
||||||
|
n, remoteAddr, err := d.conn.ReadFromUDP(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if d.isClosed.Load() {
|
if d.isClosed.Load() {
|
||||||
d.peerCfg.Log.Infof("exit from activity listener")
|
d.peerCfg.Log.Infof("exit from activity listener")
|
||||||
@@ -62,20 +70,24 @@ func (d *UDPListener) ReadPackets() {
|
|||||||
d.peerCfg.Log.Warnf("received %d bytes from %s, too short", n, remoteAddr)
|
d.peerCfg.Log.Warnf("received %d bytes from %s, too short", n, remoteAddr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
d.peerCfg.Log.Infof("activity detected")
|
d.capturedPacket = slices.Clone(buf[:n])
|
||||||
|
d.peerCfg.Log.Infof("activity detected, captured %d bytes for reinjection", n)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
d.peerCfg.Log.Debugf("removing lazy endpoint: %s", d.endpoint.String())
|
// Leave the peer in place. ConfigureWGEndpoint will UpdatePeer with the real endpoint;
|
||||||
if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil {
|
// removing the peer here wipes kernel WG's staged queue and drops the user packet that
|
||||||
d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err)
|
// triggered activation.
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore close error as it may return "use of closed network connection" if already closed.
|
|
||||||
_ = d.conn.Close()
|
_ = d.conn.Close()
|
||||||
d.done.Unlock()
|
d.done.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CapturedPacket returns the first packet that triggered activity, or nil if none was captured.
|
||||||
|
// Safe to call after ReadPackets returns.
|
||||||
|
func (d *UDPListener) CapturedPacket() []byte {
|
||||||
|
return d.capturedPacket
|
||||||
|
}
|
||||||
|
|
||||||
// Close stops the listener and cleans up resources.
|
// Close stops the listener and cleans up resources.
|
||||||
func (d *UDPListener) Close() {
|
func (d *UDPListener) Close() {
|
||||||
d.peerCfg.Log.Infof("closing activity listener: %s", d.conn.LocalAddr().String())
|
d.peerCfg.Log.Infof("closing activity listener: %s", d.conn.LocalAddr().String())
|
||||||
|
|||||||
@@ -19,17 +19,25 @@ import (
|
|||||||
type listener interface {
|
type listener interface {
|
||||||
ReadPackets()
|
ReadPackets()
|
||||||
Close()
|
Close()
|
||||||
|
CapturedPacket() []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event reports activity on a managed peer. FirstPacket is the bytes that triggered activation,
|
||||||
|
// captured for reinjection through the real transport.
|
||||||
|
type Event struct {
|
||||||
|
PeerConnID peerid.ConnID
|
||||||
|
FirstPacket []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
type WgInterface interface {
|
type WgInterface interface {
|
||||||
RemovePeer(peerKey string) error
|
|
||||||
UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
|
UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
|
||||||
IsUserspaceBind() bool
|
IsUserspaceBind() bool
|
||||||
Address() wgaddr.Address
|
Address() wgaddr.Address
|
||||||
|
MTU() uint16
|
||||||
}
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
OnActivityChan chan peerid.ConnID
|
OnActivityChan chan Event
|
||||||
|
|
||||||
wgIface WgInterface
|
wgIface WgInterface
|
||||||
|
|
||||||
@@ -41,7 +49,7 @@ type Manager struct {
|
|||||||
|
|
||||||
func NewManager(wgIface WgInterface) *Manager {
|
func NewManager(wgIface WgInterface) *Manager {
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
OnActivityChan: make(chan peerid.ConnID, 1),
|
OnActivityChan: make(chan Event, 1),
|
||||||
wgIface: wgIface,
|
wgIface: wgIface,
|
||||||
peers: make(map[peerid.ConnID]listener),
|
peers: make(map[peerid.ConnID]listener),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
@@ -116,12 +124,12 @@ func (m *Manager) waitForTraffic(l listener, peerConnID peerid.ConnID) {
|
|||||||
delete(m.peers, peerConnID)
|
delete(m.peers, peerConnID)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
m.notify(peerConnID)
|
m.notify(Event{PeerConnID: peerConnID, FirstPacket: l.CapturedPacket()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) notify(peerConnID peerid.ConnID) {
|
func (m *Manager) notify(ev Event) {
|
||||||
select {
|
select {
|
||||||
case <-m.done:
|
case <-m.done:
|
||||||
case m.OnActivityChan <- peerConnID:
|
case m.OnActivityChan <- ev:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package activity
|
package activity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -25,10 +26,6 @@ func (m *MocPeer) ConnID() peerid.ConnID {
|
|||||||
type MocWGIface struct {
|
type MocWGIface struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m MocWGIface) RemovePeer(string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MocWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
|
func (m MocWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -44,6 +41,10 @@ func (m MocWGIface) Address() wgaddr.Address {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m MocWGIface) MTU() uint16 {
|
||||||
|
return 1280
|
||||||
|
}
|
||||||
|
|
||||||
// GetPeerListener is a test helper to access listeners
|
// GetPeerListener is a test helper to access listeners
|
||||||
func (m *Manager) GetPeerListener(peerConnID peerid.ConnID) (listener, bool) {
|
func (m *Manager) GetPeerListener(peerConnID peerid.ConnID) (listener, bool) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -86,11 +87,15 @@ func TestManager_MonitorPeerActivity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case peerConnID := <-mgr.OnActivityChan:
|
case ev := <-mgr.OnActivityChan:
|
||||||
if peerConnID != peerCfg1.PeerConnID {
|
if ev.PeerConnID != peerCfg1.PeerConnID {
|
||||||
t.Fatalf("unexpected peerConnID: %v", peerConnID)
|
t.Fatalf("unexpected peerConnID: %v", ev.PeerConnID)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(ev.FirstPacket, []byte{0x01, 0x02, 0x03, 0x04, 0x05}) {
|
||||||
|
t.Fatalf("unexpected first packet: %v", ev.FirstPacket)
|
||||||
}
|
}
|
||||||
case <-time.After(1 * time.Second):
|
case <-time.After(1 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for activity")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,57 @@ package lazyconn
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
EnvEnableLazyConn = "NB_ENABLE_EXPERIMENTAL_LAZY_CONN"
|
EnvLazyConn = "NB_LAZY_CONN"
|
||||||
EnvInactivityThreshold = "NB_LAZY_CONN_INACTIVITY_THRESHOLD"
|
EnvInactivityThreshold = "NB_LAZY_CONN_INACTIVITY_THRESHOLD"
|
||||||
)
|
)
|
||||||
|
|
||||||
func IsLazyConnEnabledByEnv() bool {
|
// State is the tri-state local override for lazy connections read from the environment.
|
||||||
val := os.Getenv(EnvEnableLazyConn)
|
type State int
|
||||||
if val == "" {
|
|
||||||
return false
|
const (
|
||||||
}
|
// StateUnset means no local override; defer to the management feature flag.
|
||||||
enabled, err := strconv.ParseBool(val)
|
StateUnset State = iota
|
||||||
if err != nil {
|
// StateOn forces lazy connections on, overriding management.
|
||||||
log.Warnf("failed to parse %s: %v", EnvEnableLazyConn, err)
|
StateOn
|
||||||
return false
|
// StateOff forces lazy connections off, overriding management.
|
||||||
}
|
StateOff
|
||||||
return enabled
|
)
|
||||||
|
|
||||||
|
// EnvState reads NB_LAZY_CONN and returns the local override state.
|
||||||
|
func EnvState() State {
|
||||||
|
return ParseState(os.Getenv(EnvLazyConn))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseState interprets a lazy-connection override value (from the environment or an MDM
|
||||||
|
// policy). It accepts the on/off aliases plus any value strconv.ParseBool understands
|
||||||
|
// (true/false/1/0). An empty or unrecognized value returns StateUnset so that the
|
||||||
|
// management feature flag remains in control.
|
||||||
|
func ParseState(raw string) State {
|
||||||
|
if raw == "" {
|
||||||
|
return StateUnset
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
switch normalized {
|
||||||
|
case "on":
|
||||||
|
return StateOn
|
||||||
|
case "off":
|
||||||
|
return StateOff
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled, err := strconv.ParseBool(normalized)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to parse lazy connection value %q (from %s env or MDM policy): %v", raw, EnvLazyConn, err)
|
||||||
|
return StateUnset
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
return StateOn
|
||||||
|
}
|
||||||
|
return StateOff
|
||||||
}
|
}
|
||||||
|
|||||||
45
client/internal/lazyconn/env_test.go
Normal file
45
client/internal/lazyconn/env_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package lazyconn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnvState(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
value string
|
||||||
|
set bool
|
||||||
|
want State
|
||||||
|
}{
|
||||||
|
{set: false, want: StateUnset},
|
||||||
|
{value: "", set: true, want: StateUnset},
|
||||||
|
{value: "on", set: true, want: StateOn},
|
||||||
|
{value: "ON", set: true, want: StateOn},
|
||||||
|
{value: "true", set: true, want: StateOn},
|
||||||
|
{value: "1", set: true, want: StateOn},
|
||||||
|
{value: " on ", set: true, want: StateOn},
|
||||||
|
{value: "off", set: true, want: StateOff},
|
||||||
|
{value: "OFF", set: true, want: StateOff},
|
||||||
|
{value: "false", set: true, want: StateOff},
|
||||||
|
{value: "0", set: true, want: StateOff},
|
||||||
|
{value: "auto", set: true, want: StateUnset},
|
||||||
|
{value: "garbage", set: true, want: StateUnset},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
name := tt.value
|
||||||
|
if !tt.set {
|
||||||
|
name = "unset"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Setenv(EnvLazyConn, tt.value)
|
||||||
|
if !tt.set {
|
||||||
|
os.Unsetenv(EnvLazyConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := EnvState(); got != tt.want {
|
||||||
|
t.Fatalf("EnvState() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,8 +130,8 @@ func (m *Manager) Start(ctx context.Context) {
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case peerConnID := <-m.activityManager.OnActivityChan:
|
case ev := <-m.activityManager.OnActivityChan:
|
||||||
m.onPeerActivity(peerConnID)
|
m.onPeerActivity(ev)
|
||||||
case peerIDs := <-m.inactivityManager.InactivePeersChan():
|
case peerIDs := <-m.inactivityManager.InactivePeersChan():
|
||||||
m.onPeerInactivityTimedOut(peerIDs)
|
m.onPeerInactivityTimedOut(peerIDs)
|
||||||
}
|
}
|
||||||
@@ -513,13 +513,13 @@ func (m *Manager) checkHaGroupActivity(haGroup route.HAUniqueID, peerID string,
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) {
|
func (m *Manager) onPeerActivity(ev activity.Event) {
|
||||||
m.managedPeersMu.Lock()
|
m.managedPeersMu.Lock()
|
||||||
defer m.managedPeersMu.Unlock()
|
defer m.managedPeersMu.Unlock()
|
||||||
|
|
||||||
mp, ok := m.managedPeersByConnID[peerConnID]
|
mp, ok := m.managedPeersByConnID[ev.PeerConnID]
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Errorf("peer not found by conn id: %v", peerConnID)
|
log.Errorf("peer not found by conn id: %v", ev.PeerConnID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +536,7 @@ func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) {
|
|||||||
|
|
||||||
m.activateHAGroupPeers(mp.peerCfg)
|
m.activateHAGroupPeers(mp.peerCfg)
|
||||||
|
|
||||||
m.peerStore.PeerConnOpen(m.engineCtx, mp.peerCfg.PublicKey)
|
m.peerStore.PeerConnOpenWithFirstPacket(m.engineCtx, mp.peerCfg.PublicKey, ev.FirstPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
|
func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ type WGIface interface {
|
|||||||
IsUserspaceBind() bool
|
IsUserspaceBind() bool
|
||||||
Address() wgaddr.Address
|
Address() wgaddr.Address
|
||||||
LastActivities() map[string]monotime.Time
|
LastActivities() map[string]monotime.Time
|
||||||
|
MTU() uint16
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ func getMetricsInterval() time.Duration {
|
|||||||
return interval
|
return interval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isMetricsPushEnvSet returns true if NB_METRICS_PUSH_ENABLED is explicitly set (to any value).
|
||||||
|
// When set, the env var takes full precedence over management server configuration.
|
||||||
|
func isMetricsPushEnvSet() bool {
|
||||||
|
_, set := os.LookupEnv(EnvMetricsPushEnabled)
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
func isForceSending() bool {
|
func isForceSending() bool {
|
||||||
force, _ := strconv.ParseBool(os.Getenv(EnvMetricsForceSending))
|
force, _ := strconv.ParseBool(os.Getenv(EnvMetricsForceSending))
|
||||||
return force
|
return force
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -75,7 +76,7 @@ type ClientMetrics struct {
|
|||||||
agentInfo AgentInfo
|
agentInfo AgentInfo
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
push *Push
|
push atomic.Pointer[Push]
|
||||||
pushMu sync.Mutex
|
pushMu sync.Mutex
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
pushCancel context.CancelFunc
|
pushCancel context.CancelFunc
|
||||||
@@ -167,10 +168,7 @@ func (c *ClientMetrics) UpdateAgentInfo(agentInfo AgentInfo, publicKey string) {
|
|||||||
c.agentInfo = agentInfo
|
c.agentInfo = agentInfo
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
c.pushMu.Lock()
|
if push := c.push.Load(); push != nil {
|
||||||
push := c.push
|
|
||||||
c.pushMu.Unlock()
|
|
||||||
if push != nil {
|
|
||||||
push.SetPeerID(agentInfo.peerID)
|
push.SetPeerID(agentInfo.peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +182,7 @@ func (c *ClientMetrics) Export(w io.Writer) error {
|
|||||||
return c.impl.Export(w)
|
return c.impl.Export(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartPush starts periodic pushing of metrics with the given configuration
|
// StartPush starts periodic pushing of metrics with the given configuration.
|
||||||
// Precedence: PushConfig.ServerAddress > remote config server_url
|
// Precedence: PushConfig.ServerAddress > remote config server_url
|
||||||
func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) {
|
func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
@@ -194,11 +192,58 @@ func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) {
|
|||||||
c.pushMu.Lock()
|
c.pushMu.Lock()
|
||||||
defer c.pushMu.Unlock()
|
defer c.pushMu.Unlock()
|
||||||
|
|
||||||
if c.push != nil {
|
if c.push.Load() != nil {
|
||||||
log.Warnf("metrics push already running")
|
log.Warnf("metrics push already running")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.startPushLocked(ctx, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPush stops the periodic metrics push.
|
||||||
|
func (c *ClientMetrics) StopPush() {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.pushMu.Lock()
|
||||||
|
defer c.pushMu.Unlock()
|
||||||
|
|
||||||
|
c.stopPushLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePushFromMgm updates metrics push based on management server configuration.
|
||||||
|
// If NB_METRICS_PUSH_ENABLED is explicitly set (true or false), management config is ignored.
|
||||||
|
// When unset, management controls whether push is enabled.
|
||||||
|
func (c *ClientMetrics) UpdatePushFromMgm(ctx context.Context, enabled bool) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isMetricsPushEnvSet() {
|
||||||
|
log.Debugf("ignoring management config, env var is explicitly set: %s", EnvMetricsPushEnabled)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.pushMu.Lock()
|
||||||
|
defer c.pushMu.Unlock()
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
if c.push.Load() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("enabled metrics push by management")
|
||||||
|
c.startPushLocked(ctx, PushConfigFromEnv())
|
||||||
|
} else {
|
||||||
|
if c.push.Load() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("disabled metrics push by management")
|
||||||
|
c.stopPushLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPushLocked starts push. Caller must hold pushMu.
|
||||||
|
func (c *ClientMetrics) startPushLocked(ctx context.Context, config PushConfig) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
agentVersion := c.agentInfo.Version
|
agentVersion := c.agentInfo.Version
|
||||||
peerID := c.agentInfo.peerID
|
peerID := c.agentInfo.peerID
|
||||||
@@ -214,26 +259,23 @@ func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
c.pushCancel = cancel
|
c.pushCancel = cancel
|
||||||
|
c.push.Store(push)
|
||||||
|
|
||||||
c.wg.Add(1)
|
c.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer c.wg.Done()
|
defer c.wg.Done()
|
||||||
push.Start(ctx)
|
push.Start(ctx)
|
||||||
|
c.push.CompareAndSwap(push, nil)
|
||||||
}()
|
}()
|
||||||
c.push = push
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ClientMetrics) StopPush() {
|
// stopPushLocked stops push. Caller must hold pushMu.
|
||||||
if c == nil {
|
func (c *ClientMetrics) stopPushLocked() {
|
||||||
return
|
if c.push.Load() == nil {
|
||||||
}
|
|
||||||
c.pushMu.Lock()
|
|
||||||
defer c.pushMu.Unlock()
|
|
||||||
if c.push == nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.pushCancel()
|
c.pushCancel()
|
||||||
c.wg.Wait()
|
c.wg.Wait()
|
||||||
c.push = nil
|
c.push.Store(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Store
|
Store types.AggregatingStore
|
||||||
}
|
}
|
||||||
|
|
||||||
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.NewMemoryStore(),
|
Store: store.NewAggregatingMemoryStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +125,10 @@ 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,12 +9,14 @@ 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"
|
||||||
@@ -23,14 +25,16 @@ 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
|
||||||
publicKey []byte
|
eventsWithoutAcks nftypes.Store
|
||||||
cancel context.CancelFunc
|
publicKey []byte
|
||||||
|
cancel context.CancelFunc
|
||||||
|
retryInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new netflow manager
|
// NewManager creates a new netflow manager
|
||||||
@@ -48,9 +52,11 @@ 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +72,7 @@ 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) {
|
||||||
@@ -85,6 +92,7 @@ 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 {
|
||||||
@@ -107,14 +115,19 @@ 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(2)
|
m.shutdownWg.Add(3)
|
||||||
|
flowConfigInterval := m.flowConfig.Interval
|
||||||
go func() {
|
go func() {
|
||||||
defer m.shutdownWg.Done()
|
defer m.shutdownWg.Done()
|
||||||
m.receiveACKs(ctx, flowClient)
|
m.receiveACKs(ctx, flowClient, flowConfigInterval)
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer m.shutdownWg.Done()
|
defer m.shutdownWg.Done()
|
||||||
m.startSender(ctx)
|
m.startSender(ctx, flowConfigInterval)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer m.shutdownWg.Done()
|
||||||
|
m.startRetries(ctx, flowConfigInterval)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -198,8 +211,8 @@ func (m *Manager) GetLogger() nftypes.FlowLogger {
|
|||||||
return m.logger
|
return m.logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) startSender(ctx context.Context) {
|
func (m *Manager) startSender(ctx context.Context, flowConfigInterval time.Duration) {
|
||||||
ticker := time.NewTicker(m.flowConfig.Interval)
|
ticker := time.NewTicker(flowConfigInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -207,27 +220,29 @@ func (m *Manager) startSender(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
events := m.logger.GetEvents()
|
collectedEvents := m.logger.ResetAggregationWindow()
|
||||||
|
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)
|
||||||
continue
|
} else {
|
||||||
|
log.Tracef("sent flow event: %s", event.ID)
|
||||||
}
|
}
|
||||||
log.Tracef("sent flow event: %s", event.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) {
|
func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient, flowConfigInterval time.Duration) {
|
||||||
err := client.Receive(ctx, m.flowConfig.Interval, func(ack *proto.FlowEventAck) error {
|
err := client.Receive(ctx, flowConfigInterval, 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.logger.DeleteEvents([]uuid.UUID{id})
|
m.eventsWithoutAcks.DeleteEvents([]uuid.UUID{id})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -236,6 +251,51 @@ func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We effectively never drop events (see MaxInterval), which makes eventsWithoutAcks unbounded.
|
||||||
|
// We may want to limit the max size of the store, and start dropping oldest events when the threshold is reached.
|
||||||
|
func (m *Manager) startRetries(ctx context.Context, flowConfigInterval time.Duration) {
|
||||||
|
timer := time.NewTimer(m.retryInterval)
|
||||||
|
retryBackoff := backoff.WithContext(&backoff.ExponentialBackOff{
|
||||||
|
InitialInterval: 1 * time.Second,
|
||||||
|
RandomizationFactor: 0.5,
|
||||||
|
Multiplier: 1.7,
|
||||||
|
MaxInterval: flowConfigInterval / 2,
|
||||||
|
MaxElapsedTime: 3 * 30 * 24 * time.Hour, // 3 months
|
||||||
|
Stop: backoff.Stop,
|
||||||
|
Clock: backoff.SystemClock,
|
||||||
|
}, ctx)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
resetBackoff := true
|
||||||
|
for _, e := range m.eventsWithoutAcks.GetEvents() {
|
||||||
|
if e.Timestamp.Add(time.Second).After(time.Now()) {
|
||||||
|
// grace period on retries to avoid early retries
|
||||||
|
// do not retry if the event is less than 1 sec old
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.send(e); err != nil {
|
||||||
|
if nextBackoff := retryBackoff.NextBackOff(); nextBackoff != backoff.Stop {
|
||||||
|
timer = time.NewTimer(nextBackoff)
|
||||||
|
resetBackoff = false
|
||||||
|
} else {
|
||||||
|
resetBackoff = true // we exhausted retries, reset retry loop
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resetBackoff { // use regular retry interval in absence of network errors
|
||||||
|
retryBackoff.Reset()
|
||||||
|
timer = time.NewTimer(m.retryInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) send(event *nftypes.Event) error {
|
func (m *Manager) send(event *nftypes.Event) error {
|
||||||
m.mux.Lock()
|
m.mux.Lock()
|
||||||
client := m.receiverClient
|
client := m.receiverClient
|
||||||
@@ -250,9 +310,11 @@ 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,
|
||||||
@@ -267,6 +329,9 @@ 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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
291
client/internal/netflow/manager_integration_test.go
Normal file
291
client/internal/netflow/manager_integration_test.go
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
package netflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
|
"github.com/netbirdio/netbird/flow/proto"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testServer struct {
|
||||||
|
proto.UnimplementedFlowServiceServer
|
||||||
|
events chan *proto.FlowEvent
|
||||||
|
acks chan *proto.FlowEventAck
|
||||||
|
grpcSrv *grpc.Server
|
||||||
|
addr string
|
||||||
|
handlerDone chan struct{} // signaled each time Events() exits
|
||||||
|
handlerStarted chan struct{} // signaled each time Events() begins
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) *testServer {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s := &testServer{
|
||||||
|
events: make(chan *proto.FlowEvent, 100),
|
||||||
|
acks: make(chan *proto.FlowEventAck, 100),
|
||||||
|
grpcSrv: grpc.NewServer(),
|
||||||
|
addr: listener.Addr().String(),
|
||||||
|
handlerDone: make(chan struct{}, 10),
|
||||||
|
handlerStarted: make(chan struct{}, 10),
|
||||||
|
}
|
||||||
|
|
||||||
|
proto.RegisterFlowServiceServer(s.grpcSrv, s)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := s.grpcSrv.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||||
|
t.Logf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
s.grpcSrv.Stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testServer) Events(stream proto.FlowService_EventsServer) error {
|
||||||
|
defer func() {
|
||||||
|
select {
|
||||||
|
case s.handlerDone <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := stream.Send(&proto.FlowEventAck{IsInitiator: true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case s.handlerStarted <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(stream.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer cancel()
|
||||||
|
for {
|
||||||
|
event, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !event.IsInitiator {
|
||||||
|
select {
|
||||||
|
case s.events <- event:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ack := <-s.acks:
|
||||||
|
if err := stream.Send(ack); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendEventReceiveAck(t *testing.T) {
|
||||||
|
_, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
server := newTestServer(t)
|
||||||
|
manager := createManager(t, server.addr, 60*time.Second) // set high to prevent retries in this test
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
assert.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case <-server.handlerStarted:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, 3*time.Second, 100*time.Millisecond)
|
||||||
|
|
||||||
|
event1 := types.EventFields{
|
||||||
|
FlowID: uuid.New(),
|
||||||
|
Type: types.TypeStart,
|
||||||
|
Direction: types.Ingress,
|
||||||
|
DestIP: ipAddr("172.16.1.2"),
|
||||||
|
DestPort: 2345,
|
||||||
|
Protocol: 6,
|
||||||
|
}
|
||||||
|
manager.logger.StoreEvent(event1)
|
||||||
|
event2 := types.EventFields{
|
||||||
|
FlowID: uuid.New(),
|
||||||
|
Type: types.TypeStart,
|
||||||
|
Direction: types.Ingress,
|
||||||
|
DestIP: ipAddr("172.16.1.1"),
|
||||||
|
DestPort: 1234,
|
||||||
|
Protocol: 6,
|
||||||
|
}
|
||||||
|
manager.logger.StoreEvent(event2)
|
||||||
|
|
||||||
|
// verify the server received logged events
|
||||||
|
serverSideEvents := make([]*proto.FlowEvent, 0)
|
||||||
|
assert.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case event := <-server.events:
|
||||||
|
serverSideEvents = append(serverSideEvents, event)
|
||||||
|
if len(serverSideEvents) == 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if len(serverSideEvents) == 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, 5*time.Second, 100*time.Millisecond)
|
||||||
|
|
||||||
|
serverSideFlowIds := make([]uuid.UUID, 0, 2)
|
||||||
|
slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool {
|
||||||
|
id, err := uuid.FromBytes(e.FlowFields.FlowId)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
serverSideFlowIds = append(serverSideFlowIds, id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.ElementsMatch(t, []uuid.UUID{event1.FlowID, event2.FlowID}, serverSideFlowIds)
|
||||||
|
|
||||||
|
// verify the manager tracks un-acked events
|
||||||
|
unackedEvents := manager.eventsWithoutAcks.GetEvents()
|
||||||
|
assert.Len(t, unackedEvents, 2)
|
||||||
|
flowIds := make([]uuid.UUID, 0)
|
||||||
|
slices.Values(unackedEvents)(func(e *types.Event) bool {
|
||||||
|
flowIds = append(flowIds, e.FlowID)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.ElementsMatch(t, flowIds, []uuid.UUID{event1.FlowID, event2.FlowID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify handling of retries:
|
||||||
|
// - unacked events are retried
|
||||||
|
// - when acks arrive, events are removed from the un-acked event tracker
|
||||||
|
func TestRetryEvents(t *testing.T) {
|
||||||
|
_, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
server := newTestServer(t)
|
||||||
|
manager := createManager(t, server.addr, time.Second) // set low to start retries sooner
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
assert.Eventually(t, func() bool {
|
||||||
|
select {
|
||||||
|
case <-server.handlerStarted:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, 3*time.Second, 100*time.Millisecond)
|
||||||
|
|
||||||
|
event1 := types.EventFields{
|
||||||
|
FlowID: uuid.New(),
|
||||||
|
Type: types.TypeStart,
|
||||||
|
Direction: types.Ingress,
|
||||||
|
DestIP: ipAddr("172.16.1.2"),
|
||||||
|
DestPort: 2345,
|
||||||
|
Protocol: 6,
|
||||||
|
}
|
||||||
|
manager.logger.StoreEvent(event1)
|
||||||
|
event2 := types.EventFields{
|
||||||
|
FlowID: uuid.New(),
|
||||||
|
Type: types.TypeStart,
|
||||||
|
Direction: types.Ingress,
|
||||||
|
DestIP: ipAddr("172.16.1.1"),
|
||||||
|
DestPort: 1234,
|
||||||
|
Protocol: 6,
|
||||||
|
}
|
||||||
|
manager.logger.StoreEvent(event2)
|
||||||
|
|
||||||
|
// verify the server received retries of logged events
|
||||||
|
serverSideEvents := make([]*proto.FlowEvent, 0)
|
||||||
|
func() {
|
||||||
|
c := time.After(2500 * time.Millisecond)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event := <-server.events:
|
||||||
|
serverSideEvents = append(serverSideEvents, event)
|
||||||
|
case <-c:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
assert.True(t, len(serverSideEvents) > 2) // must see retries
|
||||||
|
|
||||||
|
uniqueServerSideEvents := make(map[uuid.UUID]*proto.FlowEvent)
|
||||||
|
slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool {
|
||||||
|
id, err := uuid.FromBytes(e.FlowFields.FlowId)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
uniqueServerSideEvents[id] = e
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.Contains(t, uniqueServerSideEvents, event1.FlowID)
|
||||||
|
assert.Contains(t, uniqueServerSideEvents, event2.FlowID)
|
||||||
|
|
||||||
|
// ack events
|
||||||
|
server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event1.FlowID].EventId}
|
||||||
|
server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event2.FlowID].EventId}
|
||||||
|
|
||||||
|
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||||
|
unackedEvents := manager.eventsWithoutAcks.GetEvents()
|
||||||
|
assert.Empty(c, unackedEvents)
|
||||||
|
|
||||||
|
}, 3*time.Second, 100*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createManager(t *testing.T, serverAddr string, retryInterval time.Duration) *Manager {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mockIFace := &mockIFaceMapper{
|
||||||
|
address: wgaddr.Address{
|
||||||
|
Network: netip.MustParsePrefix("192.168.1.1/32"),
|
||||||
|
},
|
||||||
|
isUserspaceBind: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey := []byte("test-public-key")
|
||||||
|
manager := NewManager(mockIFace, publicKey, nil)
|
||||||
|
manager.retryInterval = retryInterval
|
||||||
|
|
||||||
|
initialConfig := &types.FlowConfig{
|
||||||
|
Enabled: true,
|
||||||
|
URL: fmt.Sprintf("http://%s", serverAddr),
|
||||||
|
TokenPayload: "initial-payload",
|
||||||
|
TokenSignature: "initial-signature",
|
||||||
|
Interval: 500 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := manager.Update(initialConfig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipAddr(a string) netip.Addr {
|
||||||
|
addr, _ := netip.ParseAddr(a)
|
||||||
|
return addr
|
||||||
|
}
|
||||||
362
client/internal/netflow/store/event_aggregation_test.go
Normal file
362
client/internal/netflow/store/event_aggregation_test.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
var random = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
|
||||||
|
func TestFlowAggregation(t *testing.T) {
|
||||||
|
var protocols = []types.Protocol{types.ICMP, types.ICMPv6, types.TCP, types.UDP}
|
||||||
|
var tests = []struct {
|
||||||
|
description string
|
||||||
|
addresses [][]netip.Addr
|
||||||
|
dstPort uint16
|
||||||
|
eventTypes []types.Type
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "start and stop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start and drop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "drop only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeDrop},
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, protocol := range protocols {
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description+" "+protocol.String(), func(t *testing.T) {
|
||||||
|
store := NewAggregatingMemoryStore()
|
||||||
|
store.WindowEnd = time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
allExpected := make([]*types.Event, 0)
|
||||||
|
|
||||||
|
for _, srcAndDst := range tt.addresses {
|
||||||
|
inEvents, expected := generateEvents(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, protocol, types.Ingress, 0, store.WindowStart, store.WindowEnd)
|
||||||
|
for _, e := range inEvents {
|
||||||
|
store.StoreEvent(e)
|
||||||
|
}
|
||||||
|
allExpected = append(allExpected, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := store.GetAggregatedEvents()
|
||||||
|
assert.ElementsMatch(t, events, allExpected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIcmpEventAggregation(t *testing.T) {
|
||||||
|
var protocols = []types.Protocol{types.ICMP, types.ICMPv6}
|
||||||
|
var icmpTypes = []uint8{1, 2, 3}
|
||||||
|
|
||||||
|
var tests = []struct {
|
||||||
|
description string
|
||||||
|
addresses [][]netip.Addr
|
||||||
|
eventTypes []types.Type
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "start and stop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start and drop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
eventTypes: []types.Type{types.TypeStart},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "drop only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
eventTypes: []types.Type{types.TypeDrop},
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, protocol := range protocols {
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description+" "+protocol.String(), func(t *testing.T) {
|
||||||
|
store := NewAggregatingMemoryStore()
|
||||||
|
store.WindowEnd = time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
allExpected := make([]*types.Event, 0)
|
||||||
|
for _, icmpType := range icmpTypes {
|
||||||
|
events, expected := generateEvents(tt.addresses[0][0], tt.addresses[0][1], 0, tt.eventTypes, protocol, types.Ingress, icmpType, store.WindowStart, store.WindowEnd)
|
||||||
|
for _, e := range events {
|
||||||
|
store.StoreEvent(e)
|
||||||
|
}
|
||||||
|
allExpected = append(allExpected, expected)
|
||||||
|
}
|
||||||
|
aggregatedEvents := store.GetAggregatedEvents()
|
||||||
|
assert.Len(t, aggregatedEvents, len(allExpected))
|
||||||
|
assert.ElementsMatch(t, aggregatedEvents, allExpected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlowAggregationOfUnknownProtocols(t *testing.T) {
|
||||||
|
var tests = []struct {
|
||||||
|
description string
|
||||||
|
addresses [][]netip.Addr
|
||||||
|
dstPort uint16
|
||||||
|
eventTypes []types.Type
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "start and stop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start and drop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "drop only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeDrop},
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description+" "+types.ProtocolUnknown.String(), func(t *testing.T) {
|
||||||
|
store := NewAggregatingMemoryStore()
|
||||||
|
store.WindowEnd = time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
allExpected := make([]*types.Event, 0)
|
||||||
|
|
||||||
|
for _, srcAndDst := range tt.addresses {
|
||||||
|
inEvents, expected := generateEventsForUnknownProtocol(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, types.ProtocolUnknown, types.Ingress, store.WindowStart, store.WindowEnd)
|
||||||
|
for _, e := range inEvents {
|
||||||
|
store.StoreEvent(e)
|
||||||
|
}
|
||||||
|
allExpected = append(allExpected, expected...)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := store.GetAggregatedEvents()
|
||||||
|
assert.ElementsMatch(t, events, allExpected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetAggregationWindow(t *testing.T) {
|
||||||
|
store := NewAggregatingMemoryStore()
|
||||||
|
store.StoreEvent(&types.Event{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
EventFields: types.EventFields{
|
||||||
|
FlowID: uuid.New(),
|
||||||
|
Type: types.TypeStart,
|
||||||
|
Protocol: types.TCP,
|
||||||
|
RuleID: []byte("rule-id-1"),
|
||||||
|
Direction: types.Ingress,
|
||||||
|
SourceIP: netip.MustParseAddr("1.1.1.1"),
|
||||||
|
SourcePort: 1234,
|
||||||
|
DestIP: netip.MustParseAddr("2.2.2.2"),
|
||||||
|
DestPort: 5678,
|
||||||
|
SourceResourceID: []byte("source-resource-id"),
|
||||||
|
DestResourceID: []byte("dest-resource-id"),
|
||||||
|
RxPackets: random.Uint64(),
|
||||||
|
TxPackets: random.Uint64(),
|
||||||
|
RxBytes: random.Uint64(),
|
||||||
|
TxBytes: random.Uint64(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
reset := store.ResetAggregationWindow()
|
||||||
|
previousEvents, ok := reset.(*AggregatingMemory)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.NotEqual(t, previousEvents.WindowStart, store.WindowStart)
|
||||||
|
assert.Equal(t, previousEvents.WindowEnd, store.WindowStart)
|
||||||
|
assert.NotEmpty(t, previousEvents.events)
|
||||||
|
assert.Empty(t, store.events)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateEvents(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol,
|
||||||
|
direction types.Direction, icmpType uint8, windowStart, windowEnd time.Time) ([]*types.Event, *types.Event) {
|
||||||
|
var rxPackets, txPackets, rxBytes, txBytes uint64
|
||||||
|
inEvents := make([]*types.Event, 0)
|
||||||
|
ts := time.Now()
|
||||||
|
flowId := uuid.New()
|
||||||
|
srcPort := uint16(random.Uint32() >> 16)
|
||||||
|
|
||||||
|
for idx, eventType := range eventTypes {
|
||||||
|
e := &types.Event{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Timestamp: ts.Add(time.Duration(idx) * time.Second),
|
||||||
|
EventFields: types.EventFields{
|
||||||
|
FlowID: flowId,
|
||||||
|
Type: eventType,
|
||||||
|
Protocol: protocol,
|
||||||
|
RuleID: []byte("rule-id-1"),
|
||||||
|
Direction: direction,
|
||||||
|
SourceIP: srcIp,
|
||||||
|
SourcePort: srcPort,
|
||||||
|
DestIP: dstIp,
|
||||||
|
DestPort: dstPort,
|
||||||
|
SourceResourceID: []byte("source-resource-id"),
|
||||||
|
DestResourceID: []byte("dest-resource-id"),
|
||||||
|
RxPackets: random.Uint64(),
|
||||||
|
TxPackets: random.Uint64(),
|
||||||
|
RxBytes: random.Uint64(),
|
||||||
|
TxBytes: random.Uint64(),
|
||||||
|
}}
|
||||||
|
rxBytes += e.RxBytes
|
||||||
|
txBytes += e.TxBytes
|
||||||
|
rxPackets += e.RxPackets
|
||||||
|
txPackets += e.TxPackets
|
||||||
|
inEvents = append(inEvents, e)
|
||||||
|
if protocol == types.ICMP || protocol == types.ICMPv6 {
|
||||||
|
e.ICMPType = icmpType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var start, end, drop uint64
|
||||||
|
for _, eventType := range eventTypes {
|
||||||
|
switch eventType {
|
||||||
|
case types.TypeStart:
|
||||||
|
start += 1
|
||||||
|
case types.TypeDrop:
|
||||||
|
drop += 1
|
||||||
|
case types.TypeEnd:
|
||||||
|
end += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aggregatedEvent := &types.Event{
|
||||||
|
ID: inEvents[0].ID,
|
||||||
|
Timestamp: inEvents[0].Timestamp,
|
||||||
|
WindowStart: windowStart,
|
||||||
|
WindowEnd: windowEnd,
|
||||||
|
EventFields: types.EventFields{
|
||||||
|
FlowID: flowId,
|
||||||
|
Type: types.TypeUnknown,
|
||||||
|
Protocol: inEvents[0].Protocol,
|
||||||
|
RuleID: []byte("rule-id-1"),
|
||||||
|
Direction: inEvents[0].Direction,
|
||||||
|
SourceIP: srcIp,
|
||||||
|
SourcePort: srcPort,
|
||||||
|
DestIP: dstIp,
|
||||||
|
DestPort: dstPort,
|
||||||
|
SourceResourceID: []byte("source-resource-id"),
|
||||||
|
DestResourceID: []byte("dest-resource-id"),
|
||||||
|
RxPackets: rxPackets,
|
||||||
|
TxPackets: txPackets,
|
||||||
|
RxBytes: rxBytes,
|
||||||
|
TxBytes: txBytes,
|
||||||
|
NumOfStarts: start,
|
||||||
|
NumOfEnds: end,
|
||||||
|
NumOfDrops: drop,
|
||||||
|
}}
|
||||||
|
if protocol == types.ICMP || protocol == types.ICMPv6 {
|
||||||
|
aggregatedEvent.ICMPType = icmpType
|
||||||
|
}
|
||||||
|
|
||||||
|
return inEvents, aggregatedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateEventsForUnknownProtocol(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol,
|
||||||
|
direction types.Direction, windowStart, windowEnd time.Time) ([]*types.Event, []*types.Event) {
|
||||||
|
inEvents := make([]*types.Event, 0)
|
||||||
|
expectedEvents := make([]*types.Event, 0)
|
||||||
|
|
||||||
|
ts := time.Now()
|
||||||
|
flowId := uuid.New()
|
||||||
|
srcPort := uint16(random.Uint32() >> 16)
|
||||||
|
|
||||||
|
for idx, eventType := range eventTypes {
|
||||||
|
e := &types.Event{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Timestamp: ts.Add(time.Duration(idx) * time.Second),
|
||||||
|
EventFields: types.EventFields{
|
||||||
|
FlowID: flowId,
|
||||||
|
Type: eventType,
|
||||||
|
Protocol: protocol,
|
||||||
|
RuleID: []byte("rule-id-1"),
|
||||||
|
Direction: direction,
|
||||||
|
SourceIP: srcIp,
|
||||||
|
SourcePort: srcPort,
|
||||||
|
DestIP: dstIp,
|
||||||
|
DestPort: dstPort,
|
||||||
|
SourceResourceID: []byte("source-resource-id"),
|
||||||
|
DestResourceID: []byte("dest-resource-id"),
|
||||||
|
RxPackets: random.Uint64(),
|
||||||
|
TxPackets: random.Uint64(),
|
||||||
|
RxBytes: random.Uint64(),
|
||||||
|
TxBytes: random.Uint64(),
|
||||||
|
}}
|
||||||
|
inEvents = append(inEvents, e)
|
||||||
|
|
||||||
|
var start, end, drop uint64
|
||||||
|
switch eventType {
|
||||||
|
case types.TypeStart:
|
||||||
|
start = 1
|
||||||
|
case types.TypeDrop:
|
||||||
|
drop = 1
|
||||||
|
case types.TypeEnd:
|
||||||
|
end = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedEvents = append(expectedEvents, &types.Event{
|
||||||
|
ID: e.ID,
|
||||||
|
Timestamp: e.Timestamp,
|
||||||
|
WindowStart: windowStart,
|
||||||
|
WindowEnd: windowEnd,
|
||||||
|
EventFields: types.EventFields{
|
||||||
|
FlowID: flowId,
|
||||||
|
Type: types.TypeUnknown,
|
||||||
|
Protocol: e.Protocol,
|
||||||
|
RuleID: []byte("rule-id-1"),
|
||||||
|
Direction: e.Direction,
|
||||||
|
SourceIP: srcIp,
|
||||||
|
SourcePort: srcPort,
|
||||||
|
DestIP: dstIp,
|
||||||
|
DestPort: dstPort,
|
||||||
|
SourceResourceID: []byte("source-resource-id"),
|
||||||
|
DestResourceID: []byte("dest-resource-id"),
|
||||||
|
RxPackets: e.RxPackets,
|
||||||
|
TxPackets: e.TxPackets,
|
||||||
|
RxBytes: e.RxBytes,
|
||||||
|
TxBytes: e.TxBytes,
|
||||||
|
NumOfStarts: start,
|
||||||
|
NumOfEnds: end,
|
||||||
|
NumOfDrops: drop,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
return inEvents, expectedEvents
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +24,13 @@ 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()
|
||||||
@@ -48,3 +60,95 @@ 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,6 +2,7 @@ package types
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -69,8 +70,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +95,17 @@ 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 {
|
||||||
@@ -114,13 +128,15 @@ 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
|
||||||
@@ -140,6 +156,11 @@ 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.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -136,6 +137,39 @@ type Conn struct {
|
|||||||
// Connection stage timestamps for metrics
|
// Connection stage timestamps for metrics
|
||||||
metricsRecorder MetricsRecorder
|
metricsRecorder MetricsRecorder
|
||||||
metricsStages *MetricsStages
|
metricsStages *MetricsStages
|
||||||
|
|
||||||
|
// pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real
|
||||||
|
// transport is up.
|
||||||
|
pendingFirstPacket []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectPendingFirstPacket replays the captured handshake through the proxy if present, else
|
||||||
|
// directly through the ICE conn. The packet is cleared only after a successful write, so a failed
|
||||||
|
// or transport-less attempt leaves it available for a later reinjection. Caller must hold conn.mu.
|
||||||
|
func (conn *Conn) injectPendingFirstPacket(proxy wgproxy.Proxy, directConn net.Conn) {
|
||||||
|
pkt := conn.pendingFirstPacket
|
||||||
|
if len(pkt) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case proxy != nil:
|
||||||
|
if err := proxy.InjectPacket(pkt); err != nil {
|
||||||
|
conn.Log.Debugf("failed to reinject captured first packet via proxy: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case directConn != nil:
|
||||||
|
if _, err := directConn.Write(pkt); err != nil {
|
||||||
|
conn.Log.Debugf("failed to reinject captured first packet via direct conn: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
conn.Log.Debugf("no transport available to reinject captured first packet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.pendingFirstPacket = nil
|
||||||
|
conn.Log.Debugf("reinjected captured first packet (%d bytes)", len(pkt))
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConn creates a new not opened Conn to the remote peer.
|
// NewConn creates a new not opened Conn to the remote peer.
|
||||||
@@ -172,6 +206,16 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
|
|||||||
// It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will
|
// It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will
|
||||||
// be used.
|
// be used.
|
||||||
func (conn *Conn) Open(engineCtx context.Context) error {
|
func (conn *Conn) Open(engineCtx context.Context) error {
|
||||||
|
return conn.open(engineCtx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenWithFirstPacket opens the connection like Open and stashes firstPacket to be replayed once
|
||||||
|
// the real transport is established. The packet is retained only on a successful open.
|
||||||
|
func (conn *Conn) OpenWithFirstPacket(engineCtx context.Context, firstPacket []byte) error {
|
||||||
|
return conn.open(engineCtx, firstPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
|
||||||
conn.mu.Lock()
|
conn.mu.Lock()
|
||||||
defer conn.mu.Unlock()
|
defer conn.mu.Unlock()
|
||||||
|
|
||||||
@@ -227,6 +271,9 @@ func (conn *Conn) Open(engineCtx context.Context) error {
|
|||||||
defer conn.wg.Done()
|
defer conn.wg.Done()
|
||||||
conn.guard.Start(conn.ctx, conn.onGuardEvent)
|
conn.guard.Start(conn.ctx, conn.onGuardEvent)
|
||||||
}()
|
}()
|
||||||
|
if len(firstPacket) > 0 {
|
||||||
|
conn.pendingFirstPacket = slices.Clone(firstPacket)
|
||||||
|
}
|
||||||
conn.opened = true
|
conn.opened = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -423,6 +470,8 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
|
|||||||
conn.wgProxyRelay.RedirectAs(ep)
|
conn.wgProxyRelay.RedirectAs(ep)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conn.injectPendingFirstPacket(wgProxy, iceConnInfo.RemoteConn)
|
||||||
|
|
||||||
conn.currentConnPriority = priority
|
conn.currentConnPriority = priority
|
||||||
conn.statusICE.SetConnected()
|
conn.statusICE.SetConnected()
|
||||||
conn.updateIceState(iceConnInfo, updateTime)
|
conn.updateIceState(iceConnInfo, updateTime)
|
||||||
@@ -546,6 +595,8 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
|
|||||||
|
|
||||||
wgConfigWorkaround()
|
wgConfigWorkaround()
|
||||||
|
|
||||||
|
conn.injectPendingFirstPacket(wgProxy, nil)
|
||||||
|
|
||||||
conn.rosenpassRemoteKey = rci.rosenpassPubKey
|
conn.rosenpassRemoteKey = rci.rosenpassPubKey
|
||||||
conn.currentConnPriority = conntype.Relay
|
conn.currentConnPriority = conntype.Relay
|
||||||
conn.statusRelay.SetConnected()
|
conn.statusRelay.SetConnected()
|
||||||
@@ -752,15 +803,17 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
|
func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
|
||||||
if !conn.wgWatcher.IsEnabled() {
|
if !conn.wgWatcher.PrepareInitialHandshake() {
|
||||||
wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
|
return
|
||||||
conn.wgWatcherCancel = wgWatcherCancel
|
|
||||||
conn.wgWatcherWg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer conn.wgWatcherWg.Done()
|
|
||||||
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
|
||||||
|
conn.wgWatcherCancel = wgWatcherCancel
|
||||||
|
conn.wgWatcherWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer conn.wgWatcherWg.Done()
|
||||||
|
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (conn *Conn) disableWgWatcherIfNeeded() {
|
func (conn *Conn) disableWgWatcherIfNeeded() {
|
||||||
|
|||||||
@@ -85,7 +85,11 @@ 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 ticker.Stop()
|
defer func() {
|
||||||
|
// If backoff.Ticker.send is blocked, context.Done will not close the Ticker goroutine.
|
||||||
|
// We have to explicitly call Stop, even if we use backoff.WithContext.
|
||||||
|
ticker.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
tickerChannel := ticker.C
|
tickerChannel := ticker.C
|
||||||
|
|
||||||
|
|||||||
92
client/internal/peer/guard/guard_leak_test.go
Normal file
92
client/internal/peer/guard/guard_leak_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package guard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestGuard(status connStatusFunc) *Guard {
|
||||||
|
srw := NewSRWatcher(nil, nil, nil, ice.Config{})
|
||||||
|
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countBackoffTickerGoroutines returns how many goroutines are currently sitting
|
||||||
|
// in backoff/v4.(*Ticker).run (a ticker goroutine that has not exited).
|
||||||
|
func countBackoffTickerGoroutines() int {
|
||||||
|
buf := make([]byte, 1<<25) // 32MB
|
||||||
|
n := runtime.Stack(buf, true)
|
||||||
|
return strings.Count(string(buf[:n]), "backoff/v4.(*Ticker).run")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown reproduces a observed
|
||||||
|
// leak: after a shutdown burst, ticker run/send goroutines stay parked
|
||||||
|
// forever even though every reconnect loop has exited.
|
||||||
|
func TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown(t *testing.T) {
|
||||||
|
before := countBackoffTickerGoroutines()
|
||||||
|
|
||||||
|
const peers = 6000
|
||||||
|
cancels := make([]context.CancelFunc, 0, peers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// A status check slower than the tick cadence. This models the real
|
||||||
|
// isConnectedOnAllWay/callback doing work: while the loop is busy in the
|
||||||
|
// handler, the ticker fires the next tick and parks in send(), because
|
||||||
|
// send() never selects on ctx.
|
||||||
|
slowStatus := func() ConnStatus {
|
||||||
|
time.Sleep(70 * time.Millisecond)
|
||||||
|
return ConnStatusConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
for range peers {
|
||||||
|
g := newTestGuard(slowStatus)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancels = append(cancels, cancel)
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
g.Start(ctx, func() {})
|
||||||
|
}()
|
||||||
|
// Force the live ticker to be a newReconnectTicker.
|
||||||
|
g.SetRelayedConnDisconnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the replacement tickers get past their 800ms initial interval, so
|
||||||
|
// many are parked in send() waiting on the (slow) consumer when we tear
|
||||||
|
// everything down.
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Shutdown burst: cancel every peer at once, like engine teardown.
|
||||||
|
for _, c := range cancels {
|
||||||
|
c()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every reconnect loop must return
|
||||||
|
waitCh := make(chan struct{})
|
||||||
|
go func() { wg.Wait(); close(waitCh) }()
|
||||||
|
select {
|
||||||
|
case <-waitCh:
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
t.Fatal("not all reconnect loops returned after ctx cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give any correctly-stopped ticker goroutines time to unwind.
|
||||||
|
for range 50 {
|
||||||
|
runtime.Gosched()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
leaked := countBackoffTickerGoroutines() - before
|
||||||
|
t.Logf("backoff Ticker.run goroutines still parked after teardown of %d peers: %d", peers, leaked)
|
||||||
|
if leaked > 0 {
|
||||||
|
t.Errorf("LEAK: %d backoff ticker goroutines parked after all reconnect loops exited "+
|
||||||
|
"(defer ticker.Stop() stops the initial ticker, not the live replacement)", leaked)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -191,23 +192,30 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
// every private-service request) don't contend against each other.
|
// 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
|
||||||
@@ -223,6 +231,21 @@ 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
|
||||||
@@ -237,6 +260,7 @@ 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,
|
||||||
@@ -401,6 +425,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +451,7 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R
|
|||||||
|
|
||||||
// todo: consider to make sense of this notification or not
|
// todo: consider to make sense of this notification or not
|
||||||
d.notifier.peerListChanged(numPeers)
|
d.notifier.peerListChanged(numPeers)
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +477,7 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error {
|
|||||||
|
|
||||||
// todo: consider to make sense of this notification or not
|
// todo: consider to make sense of this notification or not
|
||||||
d.notifier.peerListChanged(numPeers)
|
d.notifier.peerListChanged(numPeers)
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +527,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +564,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,6 +600,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,6 +639,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,6 +733,7 @@ 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 {
|
||||||
@@ -760,6 +792,41 @@ 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
|
||||||
@@ -828,11 +895,19 @@ 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
|
||||||
@@ -840,11 +915,16 @@ 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
|
||||||
@@ -852,6 +932,7 @@ 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
|
||||||
@@ -885,6 +966,10 @@ 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
|
||||||
@@ -892,11 +977,16 @@ 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
|
||||||
@@ -904,6 +994,7 @@ 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) {
|
||||||
@@ -1110,16 +1201,19 @@ 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
|
||||||
@@ -1261,6 +1355,79 @@ 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,3 +314,39 @@ 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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ type WGWatcher struct {
|
|||||||
stateDump *stateDump
|
stateDump *stateDump
|
||||||
|
|
||||||
enabled bool
|
enabled bool
|
||||||
muEnabled sync.RWMutex
|
muEnabled sync.Mutex
|
||||||
|
// initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
|
||||||
|
initialHandshake time.Time
|
||||||
|
|
||||||
resetCh chan struct{}
|
resetCh chan struct{}
|
||||||
}
|
}
|
||||||
@@ -46,38 +48,38 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing.
|
// PrepareInitialHandshake reserves the watcher and reads the peer's current WireGuard
|
||||||
// The watcher runs until ctx is cancelled. Caller is responsible for context lifecycle management.
|
// handshake time. It must be called before the peer is (re)configured on the WireGuard
|
||||||
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
|
// interface, so the captured baseline reflects the state prior to this connection attempt
|
||||||
|
// instead of racing with that configuration. Returns ok=false if the watcher is already
|
||||||
|
// running, in which case EnableWgWatcher must not be called.
|
||||||
|
func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
|
||||||
w.muEnabled.Lock()
|
w.muEnabled.Lock()
|
||||||
if w.enabled {
|
if w.enabled {
|
||||||
w.muEnabled.Unlock()
|
w.muEnabled.Unlock()
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
w.log.Debugf("enable WireGuard watcher")
|
w.log.Debugf("enable WireGuard watcher")
|
||||||
w.enabled = true
|
w.enabled = true
|
||||||
w.muEnabled.Unlock()
|
w.muEnabled.Unlock()
|
||||||
|
|
||||||
initialHandshake, err := w.wgState()
|
handshake, _ := w.wgState()
|
||||||
if err != nil {
|
w.initialHandshake = handshake
|
||||||
w.log.Warnf("failed to read initial wg stats: %v", err)
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, initialHandshake)
|
// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by
|
||||||
|
// PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible
|
||||||
|
// for context lifecycle management.
|
||||||
|
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
|
||||||
|
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake)
|
||||||
|
|
||||||
w.muEnabled.Lock()
|
w.muEnabled.Lock()
|
||||||
w.enabled = false
|
w.enabled = false
|
||||||
w.muEnabled.Unlock()
|
w.muEnabled.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEnabled returns true if the WireGuard watcher is currently enabled
|
|
||||||
func (w *WGWatcher) IsEnabled() bool {
|
|
||||||
w.muEnabled.RLock()
|
|
||||||
defer w.muEnabled.RUnlock()
|
|
||||||
return w.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset signals the watcher that the WireGuard peer has been reset and a new
|
// Reset signals the watcher that the WireGuard peer has been reset and a new
|
||||||
// handshake is expected. This restarts the handshake timeout from scratch.
|
// handshake is expected. This restarts the handshake timeout from scratch.
|
||||||
func (w *WGWatcher) Reset() {
|
func (w *WGWatcher) Reset() {
|
||||||
@@ -101,13 +103,16 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
|
|||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
handshake, ok := w.handshakeCheck(lastHandshake)
|
handshake, ok := w.handshakeCheck(lastHandshake)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
onDisconnectedFn()
|
onDisconnectedFn()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if lastHandshake.IsZero() {
|
if lastHandshake.IsZero() {
|
||||||
elapsed := calcElapsed(enabledTime, *handshake)
|
elapsed := calcElapsed(enabledTime, *handshake)
|
||||||
w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake)
|
w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake)
|
||||||
if onHandshakeSuccessFn != nil {
|
if onHandshakeSuccessFn != nil && ctx.Err() == nil {
|
||||||
onHandshakeSuccessFn(*handshake)
|
onHandshakeSuccessFn(*handshake)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/configurer"
|
"github.com/netbirdio/netbird/client/iface/configurer"
|
||||||
)
|
)
|
||||||
@@ -34,6 +35,9 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
ok := watcher.PrepareInitialHandshake()
|
||||||
|
require.True(t, ok, "watcher should not be enabled yet")
|
||||||
|
|
||||||
onDisconnected := make(chan struct{}, 1)
|
onDisconnected := make(chan struct{}, 1)
|
||||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
||||||
mlog.Infof("onDisconnectedFn")
|
mlog.Infof("onDisconnectedFn")
|
||||||
@@ -62,6 +66,9 @@ func TestWGWatcher_ReEnable(t *testing.T) {
|
|||||||
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
|
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
ok := watcher.PrepareInitialHandshake()
|
||||||
|
require.True(t, ok, "watcher should not be enabled yet")
|
||||||
|
|
||||||
wg := &sync.WaitGroup{}
|
wg := &sync.WaitGroup{}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -76,6 +83,9 @@ func TestWGWatcher_ReEnable(t *testing.T) {
|
|||||||
ctx, cancel = context.WithCancel(context.Background())
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
ok = watcher.PrepareInitialHandshake()
|
||||||
|
require.True(t, ok, "watcher should be re-enabled after the previous run stopped")
|
||||||
|
|
||||||
onDisconnected := make(chan struct{}, 1)
|
onDisconnected := make(chan struct{}, 1)
|
||||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
||||||
onDisconnected <- struct{}{}
|
onDisconnected <- struct{}{}
|
||||||
|
|||||||
@@ -88,11 +88,24 @@ func (s *Store) PeerConnOpen(ctx context.Context, pubKey string) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// this can be blocked because of the connect open limiter semaphore
|
|
||||||
if err := p.Open(ctx); err != nil {
|
if err := p.Open(ctx); err != nil {
|
||||||
p.Log.Errorf("failed to open peer connection: %v", err)
|
p.Log.Errorf("failed to open peer connection: %v", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerConnOpenWithFirstPacket opens the peer connection and stashes a first packet to be
|
||||||
|
// reinjected once the real transport is established.
|
||||||
|
func (s *Store) PeerConnOpenWithFirstPacket(ctx context.Context, pubKey string, firstPacket []byte) {
|
||||||
|
s.peerConnsMu.RLock()
|
||||||
|
defer s.peerConnsMu.RUnlock()
|
||||||
|
|
||||||
|
p, ok := s.peerConns[pubKey]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := p.OpenWithFirstPacket(ctx, firstPacket); err != nil {
|
||||||
|
p.Log.Errorf("failed to open peer connection: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) PeerConnIdle(pubKey string) {
|
func (s *Store) PeerConnIdle(pubKey string) {
|
||||||
|
|||||||
@@ -101,8 +101,6 @@ type ConfigInput struct {
|
|||||||
|
|
||||||
DNSLabels domain.List
|
DNSLabels domain.List
|
||||||
|
|
||||||
LazyConnectionEnabled *bool
|
|
||||||
|
|
||||||
MTU *uint16
|
MTU *uint16
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +178,9 @@ type Config struct {
|
|||||||
|
|
||||||
ClientCertKeyPair *tls.Certificate `json:"-"`
|
ClientCertKeyPair *tls.Certificate `json:"-"`
|
||||||
|
|
||||||
LazyConnectionEnabled bool
|
// LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/"").
|
||||||
|
// Runtime-only: re-derived from MDM policy on each load, never persisted.
|
||||||
|
LazyConnection string `json:"-"`
|
||||||
|
|
||||||
MTU uint16
|
MTU uint16
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.NetworkMonitor != nil && input.NetworkMonitor != config.NetworkMonitor {
|
if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) {
|
||||||
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
|
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
|
||||||
config.NetworkMonitor = input.NetworkMonitor
|
config.NetworkMonitor = input.NetworkMonitor
|
||||||
updated = true
|
updated = true
|
||||||
@@ -454,7 +454,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot {
|
if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) {
|
||||||
if *input.EnableSSHRoot {
|
if *input.EnableSSHRoot {
|
||||||
log.Infof("enabling SSH root login")
|
log.Infof("enabling SSH root login")
|
||||||
} else {
|
} else {
|
||||||
@@ -464,7 +464,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.EnableSSHSFTP != nil && input.EnableSSHSFTP != config.EnableSSHSFTP {
|
if input.EnableSSHSFTP != nil && (config.EnableSSHSFTP == nil || *input.EnableSSHSFTP != *config.EnableSSHSFTP) {
|
||||||
if *input.EnableSSHSFTP {
|
if *input.EnableSSHSFTP {
|
||||||
log.Infof("enabling SSH SFTP subsystem")
|
log.Infof("enabling SSH SFTP subsystem")
|
||||||
} else {
|
} else {
|
||||||
@@ -474,7 +474,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.EnableSSHLocalPortForwarding != nil && input.EnableSSHLocalPortForwarding != config.EnableSSHLocalPortForwarding {
|
if input.EnableSSHLocalPortForwarding != nil && (config.EnableSSHLocalPortForwarding == nil || *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding) {
|
||||||
if *input.EnableSSHLocalPortForwarding {
|
if *input.EnableSSHLocalPortForwarding {
|
||||||
log.Infof("enabling SSH local port forwarding")
|
log.Infof("enabling SSH local port forwarding")
|
||||||
} else {
|
} else {
|
||||||
@@ -484,7 +484,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.EnableSSHRemotePortForwarding != nil && input.EnableSSHRemotePortForwarding != config.EnableSSHRemotePortForwarding {
|
if input.EnableSSHRemotePortForwarding != nil && (config.EnableSSHRemotePortForwarding == nil || *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding) {
|
||||||
if *input.EnableSSHRemotePortForwarding {
|
if *input.EnableSSHRemotePortForwarding {
|
||||||
log.Infof("enabling SSH remote port forwarding")
|
log.Infof("enabling SSH remote port forwarding")
|
||||||
} else {
|
} else {
|
||||||
@@ -494,7 +494,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.DisableSSHAuth != nil && input.DisableSSHAuth != config.DisableSSHAuth {
|
if input.DisableSSHAuth != nil && (config.DisableSSHAuth == nil || *input.DisableSSHAuth != *config.DisableSSHAuth) {
|
||||||
if *input.DisableSSHAuth {
|
if *input.DisableSSHAuth {
|
||||||
log.Infof("disabling SSH authentication")
|
log.Infof("disabling SSH authentication")
|
||||||
} else {
|
} else {
|
||||||
@@ -504,7 +504,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.SSHJWTCacheTTL != nil && input.SSHJWTCacheTTL != config.SSHJWTCacheTTL {
|
if input.SSHJWTCacheTTL != nil && (config.SSHJWTCacheTTL == nil || *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL) {
|
||||||
log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL)
|
log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL)
|
||||||
config.SSHJWTCacheTTL = input.SSHJWTCacheTTL
|
config.SSHJWTCacheTTL = input.SSHJWTCacheTTL
|
||||||
updated = true
|
updated = true
|
||||||
@@ -587,7 +587,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.DisableNotifications != nil && input.DisableNotifications != config.DisableNotifications {
|
if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) {
|
||||||
if *input.DisableNotifications {
|
if *input.DisableNotifications {
|
||||||
log.Infof("disabling notifications")
|
log.Infof("disabling notifications")
|
||||||
} else {
|
} else {
|
||||||
@@ -632,12 +632,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.LazyConnectionEnabled != nil && *input.LazyConnectionEnabled != config.LazyConnectionEnabled {
|
|
||||||
log.Infof("switching lazy connection to %t", *input.LazyConnectionEnabled)
|
|
||||||
config.LazyConnectionEnabled = *input.LazyConnectionEnabled
|
|
||||||
updated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.MTU != nil && *input.MTU != config.MTU {
|
if input.MTU != nil && *input.MTU != config.MTU {
|
||||||
log.Infof("updating MTU to %d (old value %d)", *input.MTU, config.MTU)
|
log.Infof("updating MTU to %d (old value %d)", *input.MTU, config.MTU)
|
||||||
config.MTU = *input.MTU
|
config.MTU = *input.MTU
|
||||||
@@ -728,6 +722,15 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
|||||||
log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v)
|
log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v, ok := policy.GetBool(mdm.KeyLazyConnection); ok {
|
||||||
|
state := "off"
|
||||||
|
if v {
|
||||||
|
state = "on"
|
||||||
|
}
|
||||||
|
config.LazyConnection = state
|
||||||
|
logApplied(mdm.KeyLazyConnection, state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseURL parses and validates the URL for the named service. The URL
|
// parseURL parses and validates the URL for the named service. The URL
|
||||||
|
|||||||
@@ -130,6 +130,37 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
|||||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApply_MDMLazyConnection(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"native true", true, "on"},
|
||||||
|
{"native false", false, "off"},
|
||||||
|
{"string on", "on", "on"},
|
||||||
|
{"string off", "off", "off"},
|
||||||
|
{"string yes", "yes", "on"},
|
||||||
|
{"string no", "no", "off"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||||
|
mdm.KeyLazyConnection: c.raw,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||||
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
assert.Equal(t, c.want, cfg.LazyConnection)
|
||||||
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
||||||
const maskSentinel = "**********"
|
const maskSentinel = "**********"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
@@ -71,3 +72,22 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
191
client/internal/routemanager/exit_node_selection_test.go
Normal file
191
client/internal/routemanager/exit_node_selection_test.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package routemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||||
|
"github.com/netbirdio/netbird/route"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newExitNodeTestManager() *DefaultManager {
|
||||||
|
return &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitRoute(netID, peer string, skipAutoApply bool) *route.Route {
|
||||||
|
return &route.Route{
|
||||||
|
NetID: route.NetID(netID),
|
||||||
|
Network: netip.MustParsePrefix("0.0.0.0/0"),
|
||||||
|
Peer: peer,
|
||||||
|
SkipAutoApply: skipAutoApply,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickPreferredExitNode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
info exitNodeInfo
|
||||||
|
want route.NetID
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "persisted user selection wins over management",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"a", "b", "c"},
|
||||||
|
userSelected: []route.NetID{"b"},
|
||||||
|
selectedByManagement: []route.NetID{"a"},
|
||||||
|
},
|
||||||
|
want: "b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple user-selected self-heal to deterministic min",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"a", "b", "c"},
|
||||||
|
userSelected: []route.NetID{"c", "a"},
|
||||||
|
},
|
||||||
|
want: "a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit opt-out keeps none",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"a", "b"},
|
||||||
|
userDeselected: []route.NetID{"a", "b"},
|
||||||
|
},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fresh defaults to management auto-apply pick",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"a", "b", "c"},
|
||||||
|
selectedByManagement: []route.NetID{"b"},
|
||||||
|
},
|
||||||
|
want: "b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no user pick and no management auto-apply selects none",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"c", "a", "b"},
|
||||||
|
},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user-deselect does not block a management auto-apply sibling",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"a", "b"},
|
||||||
|
userDeselected: []route.NetID{"a"},
|
||||||
|
selectedByManagement: []route.NetID{"b"},
|
||||||
|
},
|
||||||
|
want: "b",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, pickPreferredExitNode(tt.info), "preferred exit node")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnforceSingleExitNode(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
all := []route.NetID{"a", "b", "c"}
|
||||||
|
|
||||||
|
m.enforceSingleExitNode("b", all)
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("a"), "a should be deselected")
|
||||||
|
assert.True(t, m.routeSelector.IsSelected("b"), "b should be the only selected exit node")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("c"), "c should be deselected")
|
||||||
|
|
||||||
|
// Switching the preferred node moves the single selection.
|
||||||
|
m.enforceSingleExitNode("c", all)
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("a"), "a stays deselected")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("b"), "b should now be deselected")
|
||||||
|
assert.True(t, m.routeSelector.IsSelected("c"), "c should now be selected")
|
||||||
|
|
||||||
|
// Empty preferred turns every exit node off.
|
||||||
|
m.enforceSingleExitNode("", all)
|
||||||
|
for _, id := range all {
|
||||||
|
assert.False(t, m.routeSelector.IsSelected(id), "no exit node should be selected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnforceSingleExitNode_RespectsDeselectAll(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
m.routeSelector.DeselectAllRoutes()
|
||||||
|
|
||||||
|
m.enforceSingleExitNode("b", []route.NetID{"a", "b"})
|
||||||
|
|
||||||
|
assert.True(t, m.routeSelector.IsDeselectAll(), "global deselect-all must stay in effect")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("b"), "no exit node should be forced on while deselect-all is set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRouteSelectorFromManagement_FreshSelectsOne(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
routes := route.HAMap{
|
||||||
|
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
||||||
|
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
||||||
|
"lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
|
||||||
|
"exitC|0.0.0.0/0": {exitRoute("exitC", "p4", false)},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
// Exactly one exit node (the deterministic first) is selected.
|
||||||
|
assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA is the deterministic default")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB must not also be selected")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitC"), "exitC must not also be selected")
|
||||||
|
// Non-exit routes are left at their default-on state.
|
||||||
|
assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRouteSelectorFromManagement_HonorsPersistedPick(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
routes := route.HAMap{
|
||||||
|
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
||||||
|
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
||||||
|
}
|
||||||
|
all := []route.NetID{"exitA", "exitB"}
|
||||||
|
|
||||||
|
// Simulate the state the runtime select path leaves behind: exactly one
|
||||||
|
// exit node explicitly selected, its sibling deselected.
|
||||||
|
require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exitB"}, true, all))
|
||||||
|
require.NoError(t, m.routeSelector.DeselectRoutes([]route.NetID{"exitA"}, all))
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
assert.True(t, m.routeSelector.IsSelected("exitB"), "persisted pick must stay selected")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitA"), "the other exit node stays deselected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRouteSelectorFromManagement_OptOutKeepsNone(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
routes := route.HAMap{
|
||||||
|
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
||||||
|
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
||||||
|
}
|
||||||
|
all := []route.NetID{"exitA", "exitB"}
|
||||||
|
|
||||||
|
// User deselected exit nodes and selected none.
|
||||||
|
require.NoError(t, m.routeSelector.DeselectRoutes(all, all))
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitA"), "opt-out keeps exitA off")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitB"), "opt-out keeps exitB off")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRouteSelectorFromManagement_NoAutoApplySelectsNone(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
// SkipAutoApply=true: management offers the exit nodes but doesn't request
|
||||||
|
// auto-activation, so none should be selected until the user picks one.
|
||||||
|
routes := route.HAMap{
|
||||||
|
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)},
|
||||||
|
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitA"), "no auto-apply keeps exitA off")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitB"), "no auto-apply keeps exitB off")
|
||||||
|
}
|
||||||
@@ -442,6 +442,11 @@ 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
|
||||||
|
|
||||||
@@ -582,6 +587,10 @@ 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
|
||||||
@@ -701,7 +710,13 @@ func resolveURLsToIPs(urls []string) []net.IP {
|
|||||||
return ips
|
return ips
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
|
// updateRouteSelectorFromManagement reconciles exit-node selection on every
|
||||||
|
// network map: it keeps at most one exit node selected — the user's persisted
|
||||||
|
// pick, else whatever management marks for auto-apply (SkipAutoApply=false),
|
||||||
|
// else none. We never auto-activate an exit node the map doesn't request; it
|
||||||
|
// stays off until the user picks it. Exit nodes are mutually exclusive, but the
|
||||||
|
// RouteSelector stores routes with default-on semantics, so without this every
|
||||||
|
// available exit node would report selected at once.
|
||||||
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
|
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
|
||||||
m.mirrorV6ExitPairSelections(clientRoutes)
|
m.mirrorV6ExitPairSelections(clientRoutes)
|
||||||
|
|
||||||
@@ -712,13 +727,14 @@ func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HA
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
|
info := m.collectExitNodeInfo(clientRoutes)
|
||||||
if len(exitNodeInfo.allIDs) == 0 {
|
if len(info.allIDs) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.updateExitNodeSelections(exitNodeInfo)
|
preferred := pickPreferredExitNode(info)
|
||||||
m.logExitNodeUpdate(exitNodeInfo)
|
m.enforceSingleExitNode(preferred, info.allIDs)
|
||||||
|
m.logExitNodeUpdate(info, preferred)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection
|
// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection
|
||||||
@@ -746,6 +762,10 @@ 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
|
||||||
|
|
||||||
@@ -755,6 +775,9 @@ 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) {
|
||||||
@@ -791,45 +814,52 @@ func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) {
|
// pickPreferredExitNode chooses the single exit node to keep selected. In order:
|
||||||
routesToDeselect := m.getRoutesToDeselect(info.allIDs)
|
// - a persisted user selection wins (deterministic if several survive from
|
||||||
m.deselectExitNodes(routesToDeselect)
|
// legacy state, so the set self-heals down to one);
|
||||||
m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs)
|
// - otherwise activate only what management marks for auto-apply
|
||||||
|
// (SkipAutoApply=false); the lexicographically first if it marks several.
|
||||||
|
//
|
||||||
|
// Returns "" when neither holds — we never force an arbitrary exit node on. A
|
||||||
|
// route the map doesn't auto-apply stays off until the user selects it.
|
||||||
|
// info.userDeselected is informational only: an explicit deselect simply keeps
|
||||||
|
// that route out of both lists above, so it can't be picked.
|
||||||
|
func pickPreferredExitNode(info exitNodeInfo) route.NetID {
|
||||||
|
if len(info.userSelected) > 0 {
|
||||||
|
return minNetID(info.userSelected)
|
||||||
|
}
|
||||||
|
if len(info.selectedByManagement) > 0 {
|
||||||
|
return minNetID(info.selectedByManagement)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *DefaultManager) getRoutesToDeselect(allIDs []route.NetID) []route.NetID {
|
// enforceSingleExitNode makes preferred the only selected exit node: every other
|
||||||
var routesToDeselect []route.NetID
|
// available exit node is deselected and preferred (if any) is selected, without
|
||||||
for _, netID := range allIDs {
|
// disturbing non-exit route selections. The whole reconciliation runs under a
|
||||||
if !m.routeSelector.HasUserSelectionForRoute(netID) {
|
// single RouteSelector lock (SetExclusiveExitNode) so a concurrent deselect-all
|
||||||
routesToDeselect = append(routesToDeselect, netID)
|
// cannot interleave and get undone; a global deselect-all is left untouched so
|
||||||
|
// the user's "all off" stays in effect.
|
||||||
|
func (m *DefaultManager) enforceSingleExitNode(preferred route.NetID, allIDs []route.NetID) {
|
||||||
|
m.routeSelector.SetExclusiveExitNode(preferred, allIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.NetID) {
|
||||||
|
log.Debugf("Exit node selection: %d available, preferred=%q (%d user-selected, %d user-deselected, %d management-selected)",
|
||||||
|
len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement))
|
||||||
|
}
|
||||||
|
|
||||||
|
// minNetID returns the lexicographically smallest NetID, for a deterministic
|
||||||
|
// default pick that stays stable across restarts.
|
||||||
|
func minNetID(ids []route.NetID) route.NetID {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
best := ids[0]
|
||||||
|
for _, id := range ids[1:] {
|
||||||
|
if id < best {
|
||||||
|
best = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return routesToDeselect
|
return best
|
||||||
}
|
|
||||||
|
|
||||||
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,7 +115,38 @@ func (rs *RouteSelector) DeselectAllRoutes() {
|
|||||||
clear(rs.selectedRoutes)
|
clear(rs.selectedRoutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDeselectAll reports whether the user has explicitly deselected all routes.
|
// SetExclusiveExitNode atomically makes preferred the only selected exit node
|
||||||
|
// among exitIDs: every other ID in exitIDs is deselected and preferred (when
|
||||||
|
// non-empty) is selected, all under a single lock. Holding the lock across the
|
||||||
|
// whole reconciliation prevents a concurrent DeselectAllRoutes from interleaving
|
||||||
|
// between the deselect and select steps and being silently undone. A global
|
||||||
|
// deselect-all is left untouched so the user's "all off" stays in effect;
|
||||||
|
// non-exit routes are never referenced, so their selection is preserved.
|
||||||
|
func (rs *RouteSelector) SetExclusiveExitNode(preferred route.NetID, exitIDs []route.NetID) {
|
||||||
|
rs.mu.Lock()
|
||||||
|
defer rs.mu.Unlock()
|
||||||
|
|
||||||
|
if rs.deselectAll {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range exitIDs {
|
||||||
|
if id == preferred {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rs.deselectedRoutes[id] = struct{}{}
|
||||||
|
delete(rs.selectedRoutes, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if preferred != "" {
|
||||||
|
delete(rs.deselectedRoutes, preferred)
|
||||||
|
rs.selectedRoutes[preferred] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDeselectAll reports whether the global "deselect all" flag is set, i.e. the
|
||||||
|
// user explicitly disabled every route. Callers enforcing per-route invariants
|
||||||
|
// (e.g. single exit node) should leave the selection untouched when it is.
|
||||||
func (rs *RouteSelector) IsDeselectAll() bool {
|
func (rs *RouteSelector) IsDeselectAll() bool {
|
||||||
rs.mu.RLock()
|
rs.mu.RLock()
|
||||||
defer rs.mu.RUnlock()
|
defer rs.mu.RUnlock()
|
||||||
|
|||||||
@@ -859,3 +859,31 @@ func TestRouteSelector_ComplexScenarios(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRouteSelector_EnableExitNodeKeepsOtherRoutes is a regression test for the
|
||||||
|
// tray exit-node toggle disabling every non-exit routed network. The tray used
|
||||||
|
// to Select an exit node with append=false, which the RouteSelector treats as
|
||||||
|
// "drop the whole current selection" (default-on semantics) — so enabling an
|
||||||
|
// exit node also turned off every LAN/route the user had on. The fix sends
|
||||||
|
// append=true and lets the daemon's SelectNetworks handler deselect only the
|
||||||
|
// sibling exit nodes. This test models that handler sequence against the
|
||||||
|
// selector: SelectRoutes(exit, append=true) followed by DeselectRoutes(other
|
||||||
|
// exit nodes) must leave non-exit routes untouched.
|
||||||
|
func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) {
|
||||||
|
rs := routeselector.NewRouteSelector()
|
||||||
|
all := []route.NetID{"exitA", "exitB", "lan1", "lan2"}
|
||||||
|
|
||||||
|
// User has two LAN routes on (default-on: nothing deselected => all selected).
|
||||||
|
require.True(t, rs.IsSelected("lan1"))
|
||||||
|
require.True(t, rs.IsSelected("lan2"))
|
||||||
|
|
||||||
|
// Tray enables exitA: SelectNetworks handler does SelectRoutes(append=true)
|
||||||
|
// then deselects sibling exit nodes (exitB), never the LAN routes.
|
||||||
|
require.NoError(t, rs.SelectRoutes([]route.NetID{"exitA"}, true, all))
|
||||||
|
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exitB"}, all))
|
||||||
|
|
||||||
|
assert.True(t, rs.IsSelected("exitA"), "selected exit node stays on")
|
||||||
|
assert.False(t, rs.IsSelected("exitB"), "sibling exit node is deselected")
|
||||||
|
assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected")
|
||||||
|
assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected")
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,17 +33,34 @@ 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) {
|
||||||
@@ -57,6 +74,17 @@ 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()
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func GetEnvKeyNBForceRelay() string {
|
|||||||
|
|
||||||
// GetEnvKeyNBLazyConn Exports the environment variable for the iOS client
|
// GetEnvKeyNBLazyConn Exports the environment variable for the iOS client
|
||||||
func GetEnvKeyNBLazyConn() string {
|
func GetEnvKeyNBLazyConn() string {
|
||||||
return lazyconn.EnvEnableLazyConn
|
return lazyconn.EnvLazyConn
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEnvKeyNBInactivityThreshold Exports the environment variable for the iOS client
|
// GetEnvKeyNBInactivityThreshold Exports the environment variable for the iOS client
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ var allKeys = []string{
|
|||||||
KeyDisableUpdateSettings,
|
KeyDisableUpdateSettings,
|
||||||
KeyDisableProfiles,
|
KeyDisableProfiles,
|
||||||
KeyDisableNetworks,
|
KeyDisableNetworks,
|
||||||
|
KeyDisableAdvancedView,
|
||||||
KeyDisableClientRoutes,
|
KeyDisableClientRoutes,
|
||||||
KeyDisableServerRoutes,
|
KeyDisableServerRoutes,
|
||||||
KeyBlockInbound,
|
KeyBlockInbound,
|
||||||
@@ -27,6 +28,7 @@ var allKeys = []string{
|
|||||||
KeyWireguardPort,
|
KeyWireguardPort,
|
||||||
KeySplitTunnelMode,
|
KeySplitTunnelMode,
|
||||||
KeySplitTunnelApps,
|
KeySplitTunnelApps,
|
||||||
|
KeyLazyConnection,
|
||||||
}
|
}
|
||||||
|
|
||||||
// canonicalKey maps the lowercase form of a managed-config value name to
|
// canonicalKey maps the lowercase form of a managed-config value name to
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ package mdm
|
|||||||
import (
|
import (
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,13 @@ 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"
|
||||||
@@ -41,6 +49,11 @@ const (
|
|||||||
// construction — only one mode can be set at a time.
|
// construction — only one mode can be set at a time.
|
||||||
KeySplitTunnelMode = "splitTunnelMode"
|
KeySplitTunnelMode = "splitTunnelMode"
|
||||||
KeySplitTunnelApps = "splitTunnelApps"
|
KeySplitTunnelApps = "splitTunnelApps"
|
||||||
|
|
||||||
|
// KeyLazyConnection forces the lazy-connection feature on or off, overriding
|
||||||
|
// the management feature flag. Read as a bool (native bool, or on/off,
|
||||||
|
// true/false, 1/0, yes/no); absent = defer to management.
|
||||||
|
KeyLazyConnection = "lazyConnection"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Split-tunnel mode literals (KeySplitTunnelMode values).
|
// Split-tunnel mode literals (KeySplitTunnelMode values).
|
||||||
@@ -62,12 +75,13 @@ var boolStringLiterals = map[string]bool{
|
|||||||
"true": true,
|
"true": true,
|
||||||
"1": true,
|
"1": true,
|
||||||
"yes": true,
|
"yes": true,
|
||||||
|
"on": true,
|
||||||
"false": false,
|
"false": false,
|
||||||
"0": false,
|
"0": false,
|
||||||
"no": false,
|
"no": false,
|
||||||
|
"off": false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Policy holds MDM-managed settings read from the platform source. A nil or
|
// Policy holds MDM-managed settings read from the platform source. A nil or
|
||||||
// empty Policy means no enforcement is active.
|
// empty Policy means no enforcement is active.
|
||||||
type Policy struct {
|
type Policy struct {
|
||||||
@@ -150,7 +164,8 @@ func (p *Policy) GetString(key string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetBool returns the managed value for key coerced to bool, and whether the
|
// GetBool returns the managed value for key coerced to bool, and whether the
|
||||||
// key was set. Accepts native bool and string literals "true"/"false"/"1"/"0".
|
// key was set. Accepts native bool and string literals (true/false, 1/0,
|
||||||
|
// yes/no, on/off), case-insensitively and trimmed of surrounding whitespace.
|
||||||
func (p *Policy) GetBool(key string) (bool, bool) {
|
func (p *Policy) GetBool(key string) (bool, bool) {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return false, false
|
return false, false
|
||||||
@@ -163,7 +178,7 @@ func (p *Policy) GetBool(key string) (bool, bool) {
|
|||||||
case bool:
|
case bool:
|
||||||
return t, true
|
return t, true
|
||||||
case string:
|
case string:
|
||||||
b, known := boolStringLiterals[t]
|
b, known := boolStringLiterals[strings.ToLower(strings.TrimSpace(t))]
|
||||||
return b, known
|
return b, known
|
||||||
case int:
|
case int:
|
||||||
return t != 0, true
|
return t != 0, true
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ func TestPolicy_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestPolicy_HasKey(t *testing.T) {
|
func TestPolicy_HasKey(t *testing.T) {
|
||||||
p := NewPolicy(map[string]any{
|
p := NewPolicy(map[string]any{
|
||||||
KeyManagementURL: "https://corp.example.com",
|
KeyManagementURL: "https://corp.example.com",
|
||||||
KeyDisableProfiles: true,
|
KeyDisableProfiles: true,
|
||||||
})
|
})
|
||||||
assert.False(t, p.IsEmpty())
|
assert.False(t, p.IsEmpty())
|
||||||
assert.True(t, p.HasKey(KeyManagementURL))
|
assert.True(t, p.HasKey(KeyManagementURL))
|
||||||
@@ -53,8 +53,8 @@ func TestPolicy_ManagedKeysSorted(t *testing.T) {
|
|||||||
func TestPolicy_GetString(t *testing.T) {
|
func TestPolicy_GetString(t *testing.T) {
|
||||||
p := NewPolicy(map[string]any{
|
p := NewPolicy(map[string]any{
|
||||||
KeyManagementURL: "https://corp.example.com",
|
KeyManagementURL: "https://corp.example.com",
|
||||||
KeyDisableProfiles: true, // wrong type for GetString
|
KeyDisableProfiles: true, // wrong type for GetString
|
||||||
KeyPreSharedKey: "", // empty rejected
|
KeyPreSharedKey: "", // empty rejected
|
||||||
})
|
})
|
||||||
v, ok := p.GetString(KeyManagementURL)
|
v, ok := p.GetString(KeyManagementURL)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
@@ -85,6 +85,11 @@ func TestPolicy_GetBool(t *testing.T) {
|
|||||||
{"string 0", "0", false, true},
|
{"string 0", "0", false, true},
|
||||||
{"string yes", "yes", true, true},
|
{"string yes", "yes", true, true},
|
||||||
{"string no", "no", false, true},
|
{"string no", "no", false, true},
|
||||||
|
{"string on", "on", true, true},
|
||||||
|
{"string off", "off", false, true},
|
||||||
|
{"mixed case On", "On", true, true},
|
||||||
|
{"upper TRUE", "TRUE", true, true},
|
||||||
|
{"padded yes", " yes ", true, true},
|
||||||
{"int nonzero", 1, true, true},
|
{"int nonzero", 1, true, true},
|
||||||
{"int zero", 0, false, true},
|
{"int zero", 0, false, true},
|
||||||
{"int64 nonzero", int64(2), true, true},
|
{"int64 nonzero", int64(2), true, true},
|
||||||
|
|||||||
@@ -13,9 +13,6 @@
|
|||||||
|
|
||||||
<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">
|
||||||
@@ -32,9 +29,6 @@
|
|||||||
</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"
|
||||||
@@ -62,33 +56,60 @@
|
|||||||
<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\assets\netbird.ico" />
|
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\build\windows\icon.ico" />
|
||||||
<Property Id="ARPPRODUCTICON" Value="NetbirdIcon" />
|
<Property Id="ARPPRODUCTICON" Value="NetbirdIcon" />
|
||||||
|
|
||||||
</Package>
|
</Package>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2560
client/proto/daemon.pb.gw.go
Normal file
2560
client/proto/daemon.pb.gw.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,12 @@ 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) {}
|
||||||
|
|
||||||
@@ -79,6 +85,11 @@ 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) {}
|
||||||
@@ -111,6 +122,25 @@ 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) {}
|
||||||
|
|
||||||
@@ -121,6 +151,11 @@ 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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -229,6 +264,12 @@ 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 {}
|
||||||
@@ -246,6 +287,10 @@ 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 {}
|
||||||
@@ -421,6 +466,12 @@ 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
|
||||||
@@ -518,6 +569,13 @@ 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;
|
||||||
@@ -771,12 +829,22 @@ 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
|
||||||
@@ -855,6 +923,55 @@ 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 {}
|
||||||
|
|
||||||
|
|||||||
80
client/proto/daemon_gateway_test.go
Normal file
80
client/proto/daemon_gateway_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package proto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
gatewayruntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGatewayServerRoutesCoverDaemonRPCs(t *testing.T) {
|
||||||
|
mux := gatewayruntime.NewServeMux()
|
||||||
|
if err := RegisterDaemonServiceHandlerServer(context.Background(), mux, UnimplementedDaemonServiceServer{}); err != nil {
|
||||||
|
t.Fatalf("register daemon gateway server handlers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertAllDaemonGatewayRoutesRegistered(t, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayClientRoutesCoverDaemonRPCs(t *testing.T) {
|
||||||
|
listener := bufconn.Listen(1024 * 1024)
|
||||||
|
server := grpc.NewServer()
|
||||||
|
RegisterDaemonServiceServer(server, UnimplementedDaemonServiceServer{})
|
||||||
|
go func() {
|
||||||
|
if err := server.Serve(listener); err != nil && err != grpc.ErrServerStopped {
|
||||||
|
t.Errorf("serve bufconn gRPC server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
server.Stop()
|
||||||
|
_ = listener.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
mux := gatewayruntime.NewServeMux()
|
||||||
|
opts := []grpc.DialOption{
|
||||||
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
|
return listener.Dial()
|
||||||
|
}),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
}
|
||||||
|
if err := RegisterDaemonServiceHandlerFromEndpoint(ctx, mux, "passthrough:///bufnet", opts); err != nil {
|
||||||
|
t.Fatalf("register daemon gateway client handlers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertAllDaemonGatewayRoutesRegistered(t, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAllDaemonGatewayRoutesRegistered(t *testing.T, mux http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
for _, method := range DaemonService_ServiceDesc.Methods {
|
||||||
|
assertGatewayRouteRegistered(t, mux, method.MethodName)
|
||||||
|
}
|
||||||
|
for _, stream := range DaemonService_ServiceDesc.Streams {
|
||||||
|
assertGatewayRouteRegistered(t, mux, stream.StreamName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertGatewayRouteRegistered(t *testing.T, mux http.Handler, methodName string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := "/daemon.DaemonService/" + methodName
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("{}"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
mux.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
if res.Code == http.StatusNotFound {
|
||||||
|
t.Fatalf("gateway route for %s is not registered", methodName)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user