mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-01 13:21:29 +02:00
Compare commits
90 Commits
netmap_pro
...
install-sc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b32ca3924 | ||
|
|
6175888bce | ||
|
|
1816a020c4 | ||
|
|
aa13928b76 | ||
|
|
d681670a9d | ||
|
|
4f6247b5c3 | ||
|
|
1e5b0a5c89 | ||
|
|
b65ec8b68a | ||
|
|
e13bcdbd44 | ||
|
|
46568f7af8 | ||
|
|
3358138ccc | ||
|
|
6d15d0729a | ||
|
|
0936918d24 | ||
|
|
d4a4418969 | ||
|
|
31ed241a1a | ||
|
|
178e6a8530 | ||
|
|
96963b6751 | ||
|
|
9770814f39 | ||
|
|
b0c1ed31b8 | ||
|
|
8435682ac8 | ||
|
|
ed682fad87 | ||
|
|
a12a3e4603 | ||
|
|
dc89b471fa | ||
|
|
0e520ee9f5 | ||
|
|
9620890b65 | ||
|
|
69c35e31b4 | ||
|
|
b6cd8944b1 | ||
|
|
6fc05efa6c | ||
|
|
3cda14d7f2 | ||
|
|
d9392fdbb8 | ||
|
|
82fdfa84b8 | ||
|
|
ca80e49aa0 | ||
|
|
51f17bf919 | ||
|
|
724c6a06e6 | ||
|
|
d64e9542eb | ||
|
|
3fb26d458e | ||
|
|
a411fd300c | ||
|
|
92a5ed19d3 | ||
|
|
be6777427d | ||
|
|
a1c9427d80 | ||
|
|
a59d7fba95 | ||
|
|
41d7bf4bbd | ||
|
|
b7b0d5796e | ||
|
|
21fc5b81f6 | ||
|
|
9906b9b1a1 | ||
|
|
3f8c447378 | ||
|
|
6e3f4d8722 | ||
|
|
877e889250 | ||
|
|
099ae4bc6c | ||
|
|
63d60ba490 | ||
|
|
d15830a2d0 | ||
|
|
141f3d0390 | ||
|
|
e1a24376ab | ||
|
|
62fc8d254e | ||
|
|
3a2f773d65 | ||
|
|
8f901f8899 | ||
|
|
c6bf5fbbfb | ||
|
|
f0eed7564f | ||
|
|
277d8e4c53 | ||
|
|
e70a69bbcf | ||
|
|
a48618c074 | ||
|
|
39193396f5 | ||
|
|
5343402385 | ||
|
|
62703ca23e | ||
|
|
cc64a93953 | ||
|
|
831325d6e2 | ||
|
|
8f64173574 | ||
|
|
76877e83c4 | ||
|
|
ecd398d895 | ||
|
|
aa92ad3fb1 | ||
|
|
fd94fdb42b | ||
|
|
30d15ecc3d | ||
|
|
3d87547d95 | ||
|
|
4d4cc551fd | ||
|
|
8e02154bf5 | ||
|
|
08e46aa62f | ||
|
|
e0c25ba4ba | ||
|
|
2560c6bd6c | ||
|
|
96ac15d292 | ||
|
|
488bbcb22b | ||
|
|
b7bbb44286 | ||
|
|
58318481e6 | ||
|
|
7cd5c1732b | ||
|
|
816d80602f | ||
|
|
d0d6dd4b0c | ||
|
|
47352e6e45 | ||
|
|
91acb8147c | ||
|
|
c9d387bd0d | ||
|
|
3aa6c02b93 | ||
|
|
f6900fb07c |
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
|
||||||
|
|||||||
12
.github/workflows/agent-network-e2e.yml
vendored
12
.github/workflows/agent-network-e2e.yml
vendored
@@ -5,6 +5,13 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "0 3 * * *"
|
- cron: "0 3 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bedrock_model:
|
||||||
|
description: >-
|
||||||
|
Bedrock inference-profile id to drive the matrix with, exactly as
|
||||||
|
AWS issues it. Leave empty for the Sonnet 4.6 default.
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -51,6 +58,9 @@ jobs:
|
|||||||
# token (and URL, for gateways) is unset, so partial coverage is fine.
|
# token (and URL, for gateways) is unset, so partial coverage is fine.
|
||||||
OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }}
|
OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }}
|
||||||
ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }}
|
ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }}
|
||||||
|
# Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire
|
||||||
|
# shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api.
|
||||||
|
KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }}
|
||||||
VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }}
|
VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }}
|
||||||
VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }}
|
VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }}
|
||||||
OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }}
|
OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }}
|
||||||
@@ -59,6 +69,8 @@ jobs:
|
|||||||
CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }}
|
CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }}
|
||||||
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }}
|
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }}
|
||||||
AWS_REGION: ${{ secrets.E2E_AWS_REGION }}
|
AWS_REGION: ${{ secrets.E2E_AWS_REGION }}
|
||||||
|
# Bedrock model override: dispatch input wins, then the repo variable, else the test default.
|
||||||
|
AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }}
|
||||||
# Vertex (Anthropic-on-Vertex): SA + project required; region defaults
|
# Vertex (Anthropic-on-Vertex): SA + project required; region defaults
|
||||||
# to "global", model to a pinned claude snapshot.
|
# to "global", model to a pinned claude snapshot.
|
||||||
GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }}
|
GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }}
|
||||||
|
|||||||
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 --ignore-scripts
|
||||||
|
|
||||||
|
- 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 privileged' -exec 'sudo --preserve-env=CI,CGO_ENABLED' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged)
|
# 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
|
||||||
|
|||||||
25
.github/workflows/golangci-lint.yml
vendored
25
.github/workflows/golangci-lint.yml
vendored
@@ -22,7 +22,15 @@ jobs:
|
|||||||
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals,flate,recordin,unparseable
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals,flate,recordin,unparseable
|
||||||
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
|
||||||
@@ -37,7 +45,7 @@ jobs:
|
|||||||
display_name: Linux
|
display_name: Linux
|
||||||
name: ${{ matrix.display_name }}
|
name: ${{ matrix.display_name }}
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
@@ -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:
|
||||||
@@ -62,4 +79,4 @@ jobs:
|
|||||||
skip-cache: true
|
skip-cache: true
|
||||||
skip-save-cache: true
|
skip-save-cache: true
|
||||||
cache-invalidation-interval: 0
|
cache-invalidation-interval: 0
|
||||||
args: --timeout=12m
|
args: --timeout=20m
|
||||||
|
|||||||
62
.github/workflows/install-script-test.yml
vendored
62
.github/workflows/install-script-test.yml
vendored
@@ -7,6 +7,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "release_files/install.sh"
|
- "release_files/install.sh"
|
||||||
|
- ".github/workflows/install-script-test.yml"
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -37,3 +38,64 @@ jobs:
|
|||||||
|
|
||||||
- name: check cli binary
|
- name: check cli binary
|
||||||
run: command -v netbird
|
run: command -v netbird
|
||||||
|
|
||||||
|
test-install-script-distros:
|
||||||
|
name: UI gating on ${{ matrix.image }}${{ matrix.install_epel && ' (EPEL pre-confirmed)' || '' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
max-parallel: 3
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# GTK 4 below 4.10: desktop app must be skipped, CLI installed
|
||||||
|
- image: "ubuntu:22.04"
|
||||||
|
expect_ui: false
|
||||||
|
- image: "debian:12"
|
||||||
|
expect_ui: false
|
||||||
|
# GTK 4.10+ and WebKitGTK 6.0 available: desktop app installed
|
||||||
|
- image: "debian:13"
|
||||||
|
expect_ui: true
|
||||||
|
- image: "fedora:43"
|
||||||
|
expect_ui: true
|
||||||
|
# webkitgtk6.0 needs EPEL: skipped without confirmation,
|
||||||
|
# installed when NETBIRD_INSTALL_EPEL=true
|
||||||
|
- image: "almalinux:10"
|
||||||
|
expect_ui: false
|
||||||
|
- image: "almalinux:10"
|
||||||
|
expect_ui: true
|
||||||
|
install_epel: true
|
||||||
|
container:
|
||||||
|
image: ${{ matrix.image }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: prepare container
|
||||||
|
# The netbird package postinstall registers a SysV service when
|
||||||
|
# systemd is not running. The trixie and fedora images ship without
|
||||||
|
# the SysV directories and tools it needs, unlike the older images.
|
||||||
|
run: |
|
||||||
|
mkdir -p /etc/init.d
|
||||||
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf -y install initscripts-service || dnf -y install initscripts
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: run install script
|
||||||
|
env:
|
||||||
|
XDG_CURRENT_DESKTOP: GNOME
|
||||||
|
NETBIRD_INSTALL_EPEL: ${{ matrix.install_epel && 'true' || '' }}
|
||||||
|
run: sh -x release_files/install.sh
|
||||||
|
|
||||||
|
- name: check binaries
|
||||||
|
run: |
|
||||||
|
command -v netbird
|
||||||
|
if [ "${{ matrix.expect_ui }}" = "true" ]; then
|
||||||
|
command -v netbird-ui
|
||||||
|
else
|
||||||
|
if command -v netbird-ui; then
|
||||||
|
echo "netbird-ui should not have been installed on ${{ matrix.image }}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
81
.github/workflows/release.yml
vendored
81
.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
|
||||||
@@ -397,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
|
||||||
@@ -417,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
|
||||||
@@ -489,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
|
||||||
@@ -576,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:
|
||||||
@@ -615,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:
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -271,8 +273,8 @@ dockers_v2:
|
|||||||
- netbirdio/netbird
|
- netbirdio/netbird
|
||||||
- ghcr.io/netbirdio/netbird
|
- ghcr.io/netbirdio/netbird
|
||||||
tags:
|
tags:
|
||||||
- "v{{ .Version }}-rootless"
|
- "{{ .Version }}-rootless"
|
||||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}"
|
||||||
dockerfile: client/Dockerfile-rootless
|
dockerfile: client/Dockerfile-rootless
|
||||||
extra_files:
|
extra_files:
|
||||||
- client/netbird-entrypoint.sh
|
- client/netbird-entrypoint.sh
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,16 +1,47 @@
|
|||||||
# NetBird Agent Network
|
# NetBird Agent Network
|
||||||
|
|
||||||
Agent Network is NetBird's access control layer for AI agents and the people who run
|
Agent Network is NetBird's access control layer for AI agents and the people who run them.
|
||||||
them. It gives every agent a real identity, tied to your identity provider (IdP), and
|
It gives every agent a real identity, tied to an identity provider (IdP), and governs what it can reach: LLM APIs and
|
||||||
governs what it can reach — the LLM APIs and AI gateways it can call, and the internal
|
AI gateways it can call, and the internal resources it can access. Traffic flows only over the encrypted NetBird tunnel,
|
||||||
resources it can access. Traffic flows only over the encrypted NetBird tunnel, scoped by
|
scoped by policy, with no API keys or other credentials to leak. It also gives you control over cost and token usage.
|
||||||
policy, with no API keys to leak.
|
|
||||||
|
|
||||||
> **Beta.** Agent Network is open source and can be self-hosted on your own
|
Because every LLM request passes through an
|
||||||
> infrastructure.
|
identity-aware proxy, you can:
|
||||||
|
|
||||||
|
- **Set spending and rate limits** per agent, per user, or per team — with hard caps
|
||||||
|
that stop requests once a budget is reached.
|
||||||
|
- **Restrict models and providers** so agents can only call approved (and cost-appropriate)
|
||||||
|
endpoints, keeping expensive models off-limits unless explicitly allowed.
|
||||||
|
- **Attribute usage** by tracking token consumption and cost per identity, group, or cost center so every
|
||||||
|
request is tied back to the agent and person responsible.
|
||||||
|
- **Reuse your existing AI gateway** — point the proxy at a gateway you already run,
|
||||||
|
keeping its routing and config in place while it adds identity on top, so you skip
|
||||||
|
API key distribution.
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/44d18286-d8ab-49f8-a457-98ccd66f3268
|
||||||
|
|
||||||
|
> **Beta.** Agent Network is in beta, but it's stable and already running in
|
||||||
|
> production environments. It's fully open source and can be self-hosted on your own
|
||||||
|
> infrastructure, with no vendor lock-in and no data leaving your environment.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
|
Say you have a simple use case: your Engineering or IT team needs access to Claude Code or Codex, and you want visibility into usage plus the ability to enforce budgets.
|
||||||
|
How can you do that without creating a dedicated API key for every team?
|
||||||
|
|
||||||
|
With Agent Network you get a private endpoint inside your network, for example: https://mirror.netbird.ai
|
||||||
|
Teams configure their agents to point to that endpoint instead of using individual API keys directly.
|
||||||
|
|
||||||
|
This endpoint is only reachable when users are connected to your NetBird network and authenticated through your IdP. Otherwise, it is not accessible from the public internet.
|
||||||
|
You can then use this private endpoint to configure your AI agents, whether that is Claude Code, Codex, or another tool.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Full step-by-step setup:
|
||||||
|
**https://docs.netbird.io/agent-network/quickstart**
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
Agent Network is built on two existing NetBird capabilities:
|
Agent Network is built on two existing NetBird capabilities:
|
||||||
|
|
||||||
- **Overlay network** — the encrypted WireGuard mesh between peers.
|
- **Overlay network** — the encrypted WireGuard mesh between peers.
|
||||||
@@ -22,6 +53,9 @@ LLM traffic is routed through the proxy's identity-aware pipeline, while interna
|
|||||||
resources (databases, internal APIs, self-hosted models) are reached directly over
|
resources (databases, internal APIs, self-hosted models) are reached directly over
|
||||||
peer-to-peer WireGuard tunnels, governed by the same identities and access policies.
|
peer-to-peer WireGuard tunnels, governed by the same identities and access policies.
|
||||||
|
|
||||||
|
<img width="4720" height="2218" alt="image" src="https://github.com/user-attachments/assets/1afa5da1-4b82-4f8a-a7a8-f417efadf1eb" />
|
||||||
|
|
||||||
|
|
||||||
## Where the code lives
|
## Where the code lives
|
||||||
|
|
||||||
There is no separate "agent-network" service — it reuses the reverse-proxy and management
|
There is no separate "agent-network" service — it reuses the reverse-proxy and management
|
||||||
|
|||||||
@@ -247,6 +247,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
|||||||
deps.SyncResponse = resp
|
deps.SyncResponse = resp
|
||||||
|
|
||||||
if e := cc.Engine(); e != nil {
|
if e := cc.Engine(); e != nil {
|
||||||
|
deps.RefreshStatus = func() {
|
||||||
|
e.RunHealthProbes(context.Background(), true)
|
||||||
|
}
|
||||||
if cm := e.GetClientMetrics(); cm != nil {
|
if cm := e.GetClientMetrics(); cm != nil {
|
||||||
deps.ClientMetrics = cm
|
deps.ClientMetrics = cm
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,26 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
nbnet "github.com/netbirdio/netbird/client/net"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
"github.com/netbirdio/netbird/client/server"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
"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 +71,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 +172,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
|
||||||
|
|
||||||
@@ -254,6 +333,14 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
|||||||
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror runInForegroundMode: recover residual state (DNS, firewall,
|
||||||
|
// ssh config, legacy routing) from a previous unclean shutdown and
|
||||||
|
// enable advanced routing before dialing management.
|
||||||
|
if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configFilePath).GetStatePath()); err != nil {
|
||||||
|
log.Warnf("failed to restore residual state: %v", err)
|
||||||
|
}
|
||||||
|
nbnet.Init()
|
||||||
|
|
||||||
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID)
|
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("foreground login failed: %v", err)
|
return fmt.Errorf("foreground login failed: %v", err)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
nbnet "github.com/netbirdio/netbird/client/net"
|
||||||
|
"github.com/netbirdio/netbird/client/server"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
@@ -229,6 +231,24 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
|||||||
|
|
||||||
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
||||||
|
|
||||||
|
// Restore residual state left by a previous run that did not shut down
|
||||||
|
// cleanly, mirroring what the daemon does before connecting: it recovers
|
||||||
|
// DNS config (a stale resolv.conf takeover can make the management
|
||||||
|
// hostname unresolvable), firewall rules, ssh config and legacy routing.
|
||||||
|
// Route cleanup itself happens at engine start; nbnet.Init() below lets
|
||||||
|
// the management dial bypass a leftover fwmark rule until then.
|
||||||
|
// Foreground mode is particularly exposed in containers: a crashed
|
||||||
|
// container restarts inside the same (pod) network namespace, so stale
|
||||||
|
// state survives while the process does not.
|
||||||
|
if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configPath).GetStatePath()); err != nil {
|
||||||
|
log.Warnf("failed to restore residual state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable advanced routing (as the daemon does on startup) so the
|
||||||
|
// management dial bypasses a leftover fwmark rule instead of being
|
||||||
|
// shunted into a stale routing table.
|
||||||
|
nbnet.Init()
|
||||||
|
|
||||||
err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID)
|
err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("foreground login failed: %v", err)
|
return fmt.Errorf("foreground login failed: %v", err)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ type Manager struct {
|
|||||||
udpTracker *conntrack.UDPTracker
|
udpTracker *conntrack.UDPTracker
|
||||||
icmpTracker *conntrack.ICMPTracker
|
icmpTracker *conntrack.ICMPTracker
|
||||||
tcpTracker *conntrack.TCPTracker
|
tcpTracker *conntrack.TCPTracker
|
||||||
|
fragments *fragmentTracker
|
||||||
forwarder atomic.Pointer[forwarder.Forwarder]
|
forwarder atomic.Pointer[forwarder.Forwarder]
|
||||||
pendingCapture atomic.Pointer[forwarder.PacketCapture]
|
pendingCapture atomic.Pointer[forwarder.PacketCapture]
|
||||||
logger *nblog.Logger
|
logger *nblog.Logger
|
||||||
@@ -183,6 +184,41 @@ func (d *decoder) decodePacket(data []byte) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeTransport decodes the transport header of a first fragment (which
|
||||||
|
// gopacket leaves undecoded) into the decoder and appends its layer type to
|
||||||
|
// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns
|
||||||
|
// false if the protocol is unsupported or the header is truncated.
|
||||||
|
func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool {
|
||||||
|
var l4 gopacket.DecodingLayer
|
||||||
|
var layerType gopacket.LayerType
|
||||||
|
var minLen int
|
||||||
|
switch proto {
|
||||||
|
case layers.IPProtocolTCP:
|
||||||
|
l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20
|
||||||
|
case layers.IPProtocolUDP:
|
||||||
|
l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8
|
||||||
|
case layers.IPProtocolICMPv4:
|
||||||
|
l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8
|
||||||
|
case layers.IPProtocolICMPv6:
|
||||||
|
l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject a fragment too small to hold the full transport header before
|
||||||
|
// decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping
|
||||||
|
// the decode avoids gopacket allocating an error on the drop path.
|
||||||
|
if len(payload) < minLen {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
d.decoded = append(d.decoded, layerType)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Create userspace firewall manager constructor
|
// Create userspace firewall manager constructor
|
||||||
func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) {
|
func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) {
|
||||||
return create(iface, nil, disableServerRoutes, flowLogger, mtu)
|
return create(iface, nil, disableServerRoutes, flowLogger, mtu)
|
||||||
@@ -286,6 +322,8 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
|
|||||||
if err := m.localipmanager.UpdateLocalIPs(iface); err != nil {
|
if err := m.localipmanager.UpdateLocalIPs(iface); err != nil {
|
||||||
return nil, fmt.Errorf("update local IPs: %w", err)
|
return nil, fmt.Errorf("update local IPs: %w", err)
|
||||||
}
|
}
|
||||||
|
m.fragments = newFragmentTracker(m.logger)
|
||||||
|
|
||||||
if disableConntrack {
|
if disableConntrack {
|
||||||
log.Info("conntrack is disabled")
|
log.Info("conntrack is disabled")
|
||||||
} else {
|
} else {
|
||||||
@@ -299,6 +337,7 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := iface.SetFilter(m); err != nil {
|
if err := iface.SetFilter(m); err != nil {
|
||||||
|
m.fragments.Close()
|
||||||
return nil, fmt.Errorf("set filter: %w", err)
|
return nil, fmt.Errorf("set filter: %w", err)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -694,6 +733,10 @@ func (m *Manager) resetState() {
|
|||||||
m.tcpTracker.Close()
|
m.tcpTracker.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.fragments != nil {
|
||||||
|
m.fragments.Close()
|
||||||
|
}
|
||||||
|
|
||||||
if fwder := m.forwarder.Load(); fwder != nil {
|
if fwder := m.forwarder.Load(); fwder != nil {
|
||||||
fwder.SetCapture(nil)
|
fwder.SetCapture(nil)
|
||||||
fwder.Stop()
|
fwder.Stop()
|
||||||
@@ -1046,19 +1089,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: pass fragments of routed packets to forwarder
|
// gopacket does not decode the transport header of any IP fragment, so
|
||||||
|
// fragments take a dedicated path: the first fragment's header is decoded
|
||||||
|
// and ACL-evaluated here, and the remaining fragments inherit its verdict.
|
||||||
if fragment {
|
if fragment {
|
||||||
if m.logger.Enabled(nblog.LevelTrace) {
|
return m.filterInboundFragment(d, srcIP, dstIP, size)
|
||||||
if d.decoded[0] == layers.LayerTypeIPv4 {
|
|
||||||
m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
|
|
||||||
srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
|
|
||||||
} else {
|
|
||||||
m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully
|
||||||
|
// decoded (non-fragment) inbound packet. It returns true if the packet should
|
||||||
|
// be dropped.
|
||||||
|
func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
||||||
// TODO: optimize port DNAT by caching matched rules in conntrack
|
// TODO: optimize port DNAT by caching matched rules in conntrack
|
||||||
if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated {
|
if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated {
|
||||||
// Re-decode after port DNAT translation to update port information
|
// Re-decode after port DNAT translation to update port information
|
||||||
@@ -1089,33 +1133,226 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
|
|||||||
return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size)
|
return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fragmentMeta holds the reassembly identity and layout of an IP fragment,
|
||||||
|
// extracted uniformly for IPv4 and IPv6.
|
||||||
|
type fragmentMeta struct {
|
||||||
|
key fragmentKey
|
||||||
|
// offset is the fragment offset in 8-byte units (zero for the first
|
||||||
|
// fragment).
|
||||||
|
offset uint16
|
||||||
|
// moreFragments is the More Fragments bit. A first fragment with it unset is
|
||||||
|
// an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing
|
||||||
|
// fragments to inherit a verdict, so it must not be recorded.
|
||||||
|
moreFragments bool
|
||||||
|
proto layers.IPProtocol
|
||||||
|
// l4payload is the fragmentable payload of this fragment. For the first
|
||||||
|
// fragment it starts with the transport header.
|
||||||
|
l4payload []byte
|
||||||
|
// headerEndOctets is the first fragment's payload length in 8-byte units:
|
||||||
|
// the smallest offset a trailing fragment may start at without overlapping
|
||||||
|
// the inspected transport header.
|
||||||
|
headerEndOctets uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// fragmentMetadata extracts the fragment identity and layout from a decoded IP
|
||||||
|
// fragment. It returns false for fragments it can't interpret (e.g. an IPv6
|
||||||
|
// fragment header shorter than 8 bytes), which are then dropped.
|
||||||
|
func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) {
|
||||||
|
switch d.decoded[0] {
|
||||||
|
case layers.LayerTypeIPv4:
|
||||||
|
payload := d.ip4.Payload
|
||||||
|
return fragmentMeta{
|
||||||
|
key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)},
|
||||||
|
offset: d.ip4.FragOffset,
|
||||||
|
moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0,
|
||||||
|
proto: d.ip4.Protocol,
|
||||||
|
l4payload: payload,
|
||||||
|
headerEndOctets: octets(len(payload)),
|
||||||
|
}, true
|
||||||
|
|
||||||
|
case layers.LayerTypeIPv6:
|
||||||
|
// IPv6 fragment extension header: 8 bytes, followed by the fragmentable
|
||||||
|
// payload. Layout: next header (1), reserved (1), offset+flags (2), id (4).
|
||||||
|
payload := d.ip6.Payload
|
||||||
|
if len(payload) < 8 {
|
||||||
|
return fragmentMeta{}, false
|
||||||
|
}
|
||||||
|
nextHeader := layers.IPProtocol(payload[0])
|
||||||
|
offsetFlags := binary.BigEndian.Uint16(payload[2:4])
|
||||||
|
id := binary.BigEndian.Uint32(payload[4:8])
|
||||||
|
l4 := payload[8:]
|
||||||
|
return fragmentMeta{
|
||||||
|
key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)},
|
||||||
|
offset: offsetFlags >> 3,
|
||||||
|
moreFragments: offsetFlags&1 != 0,
|
||||||
|
proto: nextHeader,
|
||||||
|
l4payload: l4,
|
||||||
|
headerEndOctets: octets(len(l4)),
|
||||||
|
}, true
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fragmentMeta{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// octets rounds a byte length up to whole 8-byte units, the granularity of the
|
||||||
|
// IP fragment offset field.
|
||||||
|
func octets(nbytes int) uint16 {
|
||||||
|
return uint16((nbytes + 7) / 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterInboundFragment decides the fate of an IP fragment. gopacket stops
|
||||||
|
// decoding at the network layer for every fragment, so the first fragment's
|
||||||
|
// transport header is decoded and ACL-evaluated here and its verdict recorded;
|
||||||
|
// the remaining (headerless) fragments inherit that verdict. Anything that
|
||||||
|
// cannot be tied to an allowed, non-overlapping first fragment is dropped.
|
||||||
|
func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool {
|
||||||
|
meta, ok := fragmentMetadata(d, srcIP, dstIP)
|
||||||
|
if !ok {
|
||||||
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.offset != 0 {
|
||||||
|
return m.filterTrailingFragment(meta, srcIP, dstIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new first fragment supersedes any recorded verdict for this datagram, so
|
||||||
|
// a re-sent or overlapping offset-zero fragment can't inherit the old one.
|
||||||
|
m.fragments.poison(meta.key)
|
||||||
|
|
||||||
|
// First fragment: decode its transport header so the ACL can evaluate it. A
|
||||||
|
// decode failure means the fragment is too small to hold the full transport
|
||||||
|
// header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it.
|
||||||
|
if !d.decodeTransport(meta.proto, meta.l4payload) {
|
||||||
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v",
|
||||||
|
srcIP, dstIP, meta.key.id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.filterFirstFragment(d, meta, srcIP, dstIP, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterTrailingFragment applies a recorded first-fragment verdict to a
|
||||||
|
// non-first fragment.
|
||||||
|
func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool {
|
||||||
|
switch m.fragments.verdict(meta.key, meta.offset) {
|
||||||
|
case fragmentAllow:
|
||||||
|
return false
|
||||||
|
case fragmentOverlap:
|
||||||
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v",
|
||||||
|
srcIP, dstIP, meta.key.id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v",
|
||||||
|
srcIP, dstIP, meta.key.id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterFirstFragment runs the verdict part of the inbound pipeline on a first
|
||||||
|
// fragment with its transport header decoded. It mirrors filterInboundDecoded
|
||||||
|
// but skips DNAT (port rewriting on fragments is unsupported) and forwarder
|
||||||
|
// injection (fragments are left to the stack to reassemble, not forwarded).
|
||||||
|
// Allowed fragments have their verdict recorded so the datagram's trailing
|
||||||
|
// fragments inherit it.
|
||||||
|
func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool {
|
||||||
|
if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) {
|
||||||
|
m.recordFirstFragment(meta)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.localipmanager.IsLocalIP(dstIP) {
|
||||||
|
ruleID, blocked := m.peerACLsBlock(srcIP, d, nil)
|
||||||
|
if blocked {
|
||||||
|
m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||||
|
d, srcIP, dstIP, ruleID, size)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
m.trackInbound(d, srcIP, dstIP, ruleID, size)
|
||||||
|
m.recordFirstFragment(meta)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.routingEnabled.Load() {
|
||||||
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if m.nativeRouter.Load() {
|
||||||
|
m.trackInbound(d, srcIP, dstIP, nil, size)
|
||||||
|
m.recordFirstFragment(meta)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: pass fragments of routed packets to the forwarder; until then
|
||||||
|
// allowed routed fragments go to the native stack.
|
||||||
|
srcPort, dstPort := getPortsFromPacket(d)
|
||||||
|
ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort)
|
||||||
|
if !pass {
|
||||||
|
m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||||
|
d, srcIP, dstIP, ruleID, size)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
m.recordFirstFragment(meta)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordFirstFragment caches an allowed first fragment's verdict for its
|
||||||
|
// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are
|
||||||
|
// complete datagrams with no trailing fragments, so they are not cached and
|
||||||
|
// cannot exhaust the verdict table.
|
||||||
|
func (m *Manager) recordFirstFragment(meta fragmentMeta) {
|
||||||
|
if !meta.moreFragments {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.fragments.recordAllowed(meta.key, meta.headerEndOctets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeDropFlow logs and records a netflow drop event for an inbound packet
|
||||||
|
// denied by the ACLs. msg is the trace format taking rule id, protocol, source
|
||||||
|
// and destination.
|
||||||
|
func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) {
|
||||||
|
pnum := getProtocolFromPacket(d)
|
||||||
|
srcPort, dstPort := getPortsFromPacket(d)
|
||||||
|
|
||||||
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.flowLogger.StoreEvent(nftypes.EventFields{
|
||||||
|
FlowID: uuid.New(),
|
||||||
|
Type: nftypes.TypeDrop,
|
||||||
|
RuleID: ruleID,
|
||||||
|
Direction: nftypes.Ingress,
|
||||||
|
Protocol: pnum,
|
||||||
|
SourceIP: srcIP,
|
||||||
|
DestIP: dstIP,
|
||||||
|
SourcePort: srcPort,
|
||||||
|
DestPort: dstPort,
|
||||||
|
// TODO: icmp type/code
|
||||||
|
RxPackets: 1,
|
||||||
|
RxBytes: uint64(size),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleLocalTraffic handles local traffic.
|
// handleLocalTraffic handles local traffic.
|
||||||
// If it returns true, the packet should be dropped.
|
// If it returns true, the packet should be dropped.
|
||||||
func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
||||||
ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData)
|
ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData)
|
||||||
if blocked {
|
if blocked {
|
||||||
pnum := getProtocolFromPacket(d)
|
m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||||
srcPort, dstPort := getPortsFromPacket(d)
|
d, srcIP, dstIP, ruleID, size)
|
||||||
|
|
||||||
if m.logger.Enabled(nblog.LevelTrace) {
|
|
||||||
m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
|
||||||
ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
|
||||||
FlowID: uuid.New(),
|
|
||||||
Type: nftypes.TypeDrop,
|
|
||||||
RuleID: ruleID,
|
|
||||||
Direction: nftypes.Ingress,
|
|
||||||
Protocol: pnum,
|
|
||||||
SourceIP: srcIP,
|
|
||||||
DestIP: dstIP,
|
|
||||||
SourcePort: srcPort,
|
|
||||||
DestPort: dstPort,
|
|
||||||
// TODO: icmp type/code
|
|
||||||
RxPackets: 1,
|
|
||||||
RxBytes: uint64(size),
|
|
||||||
})
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1168,27 +1405,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe
|
|||||||
|
|
||||||
ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort)
|
ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort)
|
||||||
if !pass {
|
if !pass {
|
||||||
proto := getProtocolFromPacket(d)
|
m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||||
|
d, srcIP, dstIP, ruleID, size)
|
||||||
if m.logger.Enabled(nblog.LevelTrace) {
|
|
||||||
m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
|
||||||
ruleID, proto, srcIP, srcPort, dstIP, dstPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
|
||||||
FlowID: uuid.New(),
|
|
||||||
Type: nftypes.TypeDrop,
|
|
||||||
RuleID: ruleID,
|
|
||||||
Direction: nftypes.Ingress,
|
|
||||||
Protocol: proto,
|
|
||||||
SourceIP: srcIP,
|
|
||||||
DestIP: dstIP,
|
|
||||||
SourcePort: srcPort,
|
|
||||||
DestPort: dstPort,
|
|
||||||
// TODO: icmp type/code
|
|
||||||
RxPackets: 1,
|
|
||||||
RxBytes: uint64(size),
|
|
||||||
})
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,6 +33,11 @@ const (
|
|||||||
defaultMaxInFlight = 1024
|
defaultMaxInFlight = 1024
|
||||||
iosReceiveWindow = 16384
|
iosReceiveWindow = 16384
|
||||||
iosMaxInFlight = 256
|
iosMaxInFlight = 256
|
||||||
|
|
||||||
|
// envForceTCPRACK overrides the platform default for gVisor's RACK loss
|
||||||
|
// detection. Set to a truthy value to force RACK on, or a falsy value to
|
||||||
|
// force it off, on any platform.
|
||||||
|
envForceTCPRACK = "NB_FORCE_TCP_RACK"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Forwarder struct {
|
type Forwarder struct {
|
||||||
@@ -152,6 +159,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
|
|||||||
maxInFlight = iosMaxInFlight
|
maxInFlight = iosMaxInFlight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureTCPRecovery(s)
|
||||||
|
|
||||||
tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP)
|
tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP)
|
||||||
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
|
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
|
||||||
|
|
||||||
@@ -466,3 +475,31 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool {
|
|||||||
logger.Debug1("forwarder: raw %s socket access available", network)
|
logger.Debug1("forwarder: raw %s socket access available", network)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where
|
||||||
|
// it interacts poorly with the host and collapses throughput on routed TCP
|
||||||
|
// connections (gVisor issue #9778). Other platforms keep the default. The
|
||||||
|
// EnvForceTCPRACK environment variable overrides the platform default.
|
||||||
|
func configureTCPRecovery(s *stack.Stack) {
|
||||||
|
disableRACK := runtime.GOOS == "windows"
|
||||||
|
|
||||||
|
if val := os.Getenv(envForceTCPRACK); val != "" {
|
||||||
|
force, err := strconv.ParseBool(val)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("parse %s: %v", envForceTCPRACK, err)
|
||||||
|
} else {
|
||||||
|
disableRACK = !force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !disableRACK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opt := tcpip.TCPRecovery(0)
|
||||||
|
if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil {
|
||||||
|
log.Warnf("disable TCP RACK loss detection: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("forwarder: TCP RACK loss detection disabled")
|
||||||
|
}
|
||||||
|
|||||||
204
client/firewall/uspfilter/fragment.go
Normal file
204
client/firewall/uspfilter/fragment.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package uspfilter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// defaultFragmentTimeout bounds how long a first-fragment verdict is kept
|
||||||
|
// while the remaining fragments arrive. It mirrors the Linux IP reassembly
|
||||||
|
// timeout (net.ipv4.ipfrag_time).
|
||||||
|
defaultFragmentTimeout = 30 * time.Second
|
||||||
|
// fragmentCleanupInterval is how often expired verdicts are purged.
|
||||||
|
fragmentCleanupInterval = 10 * time.Second
|
||||||
|
// defaultMaxFragmentEntries caps the number of concurrently tracked
|
||||||
|
// fragmented datagrams. The table stays bounded because each datagram is a
|
||||||
|
// single small entry regardless of how many fragments it is split into, and
|
||||||
|
// the 13-bit IPv4 fragment-offset field limits any datagram to 64 KiB.
|
||||||
|
defaultMaxFragmentEntries = 16384
|
||||||
|
|
||||||
|
// EnvFragmentMaxEntries overrides defaultMaxFragmentEntries.
|
||||||
|
EnvFragmentMaxEntries = "NB_FRAGMENT_MAX_ENTRIES"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fragmentVerdict is the decision for a trailing (headerless) fragment.
|
||||||
|
type fragmentVerdict int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// fragmentDeny drops the fragment: no allowed first fragment is on record.
|
||||||
|
fragmentDeny fragmentVerdict = iota
|
||||||
|
// fragmentAllow passes the fragment: it belongs to an allowed datagram and
|
||||||
|
// does not overlap the already-inspected transport header.
|
||||||
|
fragmentAllow
|
||||||
|
// fragmentOverlap drops the fragment and poisons its datagram: it overlaps
|
||||||
|
// the transport header the ACL inspected (RFC 1858 §4, RFC 3128; RFC 5722
|
||||||
|
// requires discarding the whole datagram on overlap for IPv6).
|
||||||
|
fragmentOverlap
|
||||||
|
)
|
||||||
|
|
||||||
|
// fragmentKey identifies a fragmented datagram. It matches the RFC 791 / RFC
|
||||||
|
// 8200 reassembly key: source, destination, protocol and identification. The id
|
||||||
|
// is 32-bit to hold both the IPv4 (16-bit) and IPv6 (32-bit) identification.
|
||||||
|
type fragmentKey struct {
|
||||||
|
srcIP netip.Addr
|
||||||
|
dstIP netip.Addr
|
||||||
|
id uint32
|
||||||
|
proto uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
// fragmentEntry records the verdict of an allowed first fragment.
|
||||||
|
type fragmentEntry struct {
|
||||||
|
// headerEndOctets is the offset, in 8-byte units, at which the first
|
||||||
|
// fragment's payload ended. A trailing fragment starting before this
|
||||||
|
// overlaps bytes the ACL already inspected and is rejected.
|
||||||
|
headerEndOctets uint16
|
||||||
|
// recordedAt is when the first fragment was accepted. The verdict expires a
|
||||||
|
// fixed timeout later and is not refreshed, mirroring the kernel reassembly
|
||||||
|
// timer so a trailing-fragment flood can't keep a datagram alive.
|
||||||
|
recordedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// fragmentTracker records the ACL verdict of a datagram's first fragment so the
|
||||||
|
// remaining fragments, which carry no L4 header, can inherit the decision
|
||||||
|
// without reassembling the datagram. Only allowed first fragments are stored;
|
||||||
|
// anything that cannot be tied to an allowed, non-overlapping first fragment is
|
||||||
|
// dropped (fail closed).
|
||||||
|
type fragmentTracker struct {
|
||||||
|
logger *nblog.Logger
|
||||||
|
mutex sync.Mutex
|
||||||
|
entries map[fragmentKey]fragmentEntry
|
||||||
|
timeout time.Duration
|
||||||
|
// maxEntries caps the table; atCapacity dedups the capacity warning until
|
||||||
|
// the table drains below the cap again.
|
||||||
|
maxEntries int
|
||||||
|
atCapacity bool
|
||||||
|
cleanupTicker *time.Ticker
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFragmentTracker(logger *nblog.Logger) *fragmentTracker {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t := &fragmentTracker{
|
||||||
|
logger: logger,
|
||||||
|
entries: make(map[fragmentKey]fragmentEntry),
|
||||||
|
timeout: defaultFragmentTimeout,
|
||||||
|
maxEntries: fragmentMaxEntries(logger),
|
||||||
|
cleanupTicker: time.NewTicker(fragmentCleanupInterval),
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
go t.cleanupRoutine(ctx)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func fragmentMaxEntries(logger *nblog.Logger) int {
|
||||||
|
v := os.Getenv(EnvFragmentMaxEntries)
|
||||||
|
if v == "" {
|
||||||
|
return defaultMaxFragmentEntries
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
logger.Warn2("invalid %s=%q, using default", EnvFragmentMaxEntries, v)
|
||||||
|
return defaultMaxFragmentEntries
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAllowed stores the verdict of an allowed first fragment. headerEndOctets
|
||||||
|
// is the first fragment's payload length in 8-byte units. When the table is full
|
||||||
|
// the record is dropped, which fails closed: the datagram's trailing fragments
|
||||||
|
// will be denied.
|
||||||
|
func (t *fragmentTracker) recordAllowed(key fragmentKey, headerEndOctets uint16) {
|
||||||
|
t.mutex.Lock()
|
||||||
|
defer t.mutex.Unlock()
|
||||||
|
|
||||||
|
if t.entries == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := t.entries[key]; !ok && len(t.entries) >= t.maxEntries {
|
||||||
|
if !t.atCapacity {
|
||||||
|
t.atCapacity = true
|
||||||
|
t.logger.Warn2("fragment verdict table at capacity (%d/%d): trailing fragments of new datagrams will be dropped",
|
||||||
|
len(t.entries), t.maxEntries)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.entries[key] = fragmentEntry{
|
||||||
|
headerEndOctets: headerEndOctets,
|
||||||
|
recordedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// poison drops any recorded verdict for a datagram, so its later fragments are
|
||||||
|
// denied until a new allowed first fragment is recorded. Called on every
|
||||||
|
// offset-zero fragment to defeat offset-zero overlap rewrites (RFC 3128).
|
||||||
|
func (t *fragmentTracker) poison(key fragmentKey) {
|
||||||
|
t.mutex.Lock()
|
||||||
|
defer t.mutex.Unlock()
|
||||||
|
delete(t.entries, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verdict decides the fate of a trailing fragment at fragOffsetOctets (the IPv4
|
||||||
|
// fragment offset, in 8-byte units). A fragment overlapping the inspected
|
||||||
|
// header poisons the datagram: the entry is removed so all further fragments of
|
||||||
|
// that datagram are denied too.
|
||||||
|
func (t *fragmentTracker) verdict(key fragmentKey, fragOffsetOctets uint16) fragmentVerdict {
|
||||||
|
t.mutex.Lock()
|
||||||
|
defer t.mutex.Unlock()
|
||||||
|
|
||||||
|
entry, ok := t.entries[key]
|
||||||
|
if !ok {
|
||||||
|
return fragmentDeny
|
||||||
|
}
|
||||||
|
if time.Since(entry.recordedAt) > t.timeout {
|
||||||
|
delete(t.entries, key)
|
||||||
|
return fragmentDeny
|
||||||
|
}
|
||||||
|
if fragOffsetOctets < entry.headerEndOctets {
|
||||||
|
delete(t.entries, key)
|
||||||
|
return fragmentOverlap
|
||||||
|
}
|
||||||
|
return fragmentAllow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *fragmentTracker) cleanupRoutine(ctx context.Context) {
|
||||||
|
defer t.cleanupTicker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.cleanupTicker.C:
|
||||||
|
t.cleanup()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *fragmentTracker) cleanup() {
|
||||||
|
t.mutex.Lock()
|
||||||
|
defer t.mutex.Unlock()
|
||||||
|
|
||||||
|
for key, entry := range t.entries {
|
||||||
|
if time.Since(entry.recordedAt) > t.timeout {
|
||||||
|
delete(t.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(t.entries) < t.maxEntries {
|
||||||
|
t.atCapacity = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the cleanup routine and releases resources.
|
||||||
|
func (t *fragmentTracker) Close() {
|
||||||
|
t.cancel()
|
||||||
|
|
||||||
|
t.mutex.Lock()
|
||||||
|
t.entries = nil
|
||||||
|
t.mutex.Unlock()
|
||||||
|
}
|
||||||
115
client/firewall/uspfilter/fragment_bench_test.go
Normal file
115
client/firewall/uspfilter/fragment_bench_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package uspfilter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// benchFilterInbound drives filterInbound over a fixed packet in a tight loop.
|
||||||
|
// Packets are built once, outside the timed region, so the benchmark measures
|
||||||
|
// only pipeline cost, which is what an attacker can amplify.
|
||||||
|
func benchFilterInbound(b *testing.B, pkt []byte) {
|
||||||
|
b.Helper()
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.SetBytes(int64(len(pkt)))
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
m := benchManager
|
||||||
|
m.filterInbound(pkt, len(pkt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// benchManager is a package-level manager reused across fragment benchmarks so
|
||||||
|
// setup cost stays out of the timed region.
|
||||||
|
var benchManager *Manager
|
||||||
|
|
||||||
|
func setupBenchManager(b *testing.B) *Manager {
|
||||||
|
b.Helper()
|
||||||
|
m := newFragmentTestManager(b)
|
||||||
|
allowUDP(b, m, 8080)
|
||||||
|
// Disable conntrack so the allowed-first-fragment path measures transport
|
||||||
|
// decode + ACL every iteration instead of matching the connection tracked
|
||||||
|
// on the first iteration.
|
||||||
|
m.stateful = false
|
||||||
|
benchManager = m
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkInbound_NormalPacket is the baseline: a full, non-fragmented UDP
|
||||||
|
// packet that passes the ACL. Fragment paths should stay comparable to this.
|
||||||
|
func BenchmarkInbound_NormalPacket(b *testing.B) {
|
||||||
|
setupBenchManager(b)
|
||||||
|
pkt := normalUDPPacket(b, 8080, 32)
|
||||||
|
benchFilterInbound(b, pkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkInbound_FirstFragmentAllowed measures the first-fragment path:
|
||||||
|
// transport decode + ACL evaluation + verdict record.
|
||||||
|
func BenchmarkInbound_FirstFragmentAllowed(b *testing.B) {
|
||||||
|
setupBenchManager(b)
|
||||||
|
pkt := firstFragmentUDP(b, 0x2000, 8080, 32)
|
||||||
|
benchFilterInbound(b, pkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkInbound_TrailingFragmentAllowed measures the common trailing-fragment
|
||||||
|
// path: a single map lookup after the first fragment is on record.
|
||||||
|
func BenchmarkInbound_TrailingFragmentAllowed(b *testing.B) {
|
||||||
|
m := setupBenchManager(b)
|
||||||
|
first := firstFragmentUDP(b, 0x3000, 8080, 32)
|
||||||
|
m.filterInbound(first, len(first))
|
||||||
|
pkt := trailingFragment(b, 0x3000, 5, false, 24)
|
||||||
|
benchFilterInbound(b, pkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkInbound_TrailingFragmentNoFirst is the primary DoS vector: an
|
||||||
|
// attacker floods trailing fragments with no first fragment on record. Each is
|
||||||
|
// a map miss and must be cheap.
|
||||||
|
func BenchmarkInbound_TrailingFragmentNoFirst(b *testing.B) {
|
||||||
|
setupBenchManager(b)
|
||||||
|
pkt := trailingFragment(b, 0x4000, 185, false, 40)
|
||||||
|
benchFilterInbound(b, pkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkInbound_TinyFirstFragment measures the tiny-fragment drop path: a
|
||||||
|
// first fragment too small to decode a transport header.
|
||||||
|
func BenchmarkInbound_TinyFirstFragment(b *testing.B) {
|
||||||
|
setupBenchManager(b)
|
||||||
|
pkt := trailingFragment(b, 0x5000, 0, true, 4)
|
||||||
|
benchFilterInbound(b, pkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkInbound_TrailingFragmentDistinctIDs is the worst case for the
|
||||||
|
// verdict table: an attacker varies the datagram id on every packet so no first
|
||||||
|
// fragment ever matches. Verdict lookups always miss and nothing is recorded,
|
||||||
|
// so the table cannot grow. Each iteration rewrites the id field in place.
|
||||||
|
func BenchmarkInbound_TrailingFragmentDistinctIDs(b *testing.B) {
|
||||||
|
setupBenchManager(b)
|
||||||
|
pkt := trailingFragment(b, 0x6000, 185, false, 40)
|
||||||
|
m := benchManager
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.SetBytes(int64(len(pkt)))
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
// IPv4 identification field is at bytes 4:6.
|
||||||
|
binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
|
||||||
|
m.filterInbound(pkt, len(pkt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkInbound_FirstFragmentDistinctIDs measures sustained first-fragment
|
||||||
|
// pressure with distinct ids: transport decode + ACL + verdict insert until the
|
||||||
|
// table caps, exercising the map growth and capacity guard.
|
||||||
|
func BenchmarkInbound_FirstFragmentDistinctIDs(b *testing.B) {
|
||||||
|
setupBenchManager(b)
|
||||||
|
pkt := firstFragmentUDP(b, 0x7000, 8080, 32)
|
||||||
|
m := benchManager
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.SetBytes(int64(len(pkt)))
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
|
||||||
|
m.filterInbound(pkt, len(pkt))
|
||||||
|
}
|
||||||
|
}
|
||||||
554
client/firewall/uspfilter/fragment_test.go
Normal file
554
client/firewall/uspfilter/fragment_test.go
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
package uspfilter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/gopacket"
|
||||||
|
"github.com/google/gopacket/layers"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
|
nbiface "github.com/netbirdio/netbird/client/iface"
|
||||||
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fragTestSrc = "100.10.0.1"
|
||||||
|
fragTestDst = "100.10.0.100"
|
||||||
|
fragTestSrcV6 = "fd00::1"
|
||||||
|
fragTestDstV6 = "fd00::100"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newFragmentTestManager(tb testing.TB) *Manager {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
ifaceMock := &IFaceMock{
|
||||||
|
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||||
|
AddressFunc: func() wgaddr.Address {
|
||||||
|
return wgaddr.Address{
|
||||||
|
IP: netip.MustParseAddr(fragTestDst),
|
||||||
|
Network: netip.MustParsePrefix("100.10.0.0/16"),
|
||||||
|
IPv6: netip.MustParseAddr(fragTestDstV6),
|
||||||
|
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU)
|
||||||
|
require.NoError(tb, err)
|
||||||
|
require.NoError(tb, m.UpdateLocalIPs())
|
||||||
|
tb.Cleanup(func() { require.NoError(tb, m.Close(nil)) })
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstFragmentUDPTo builds the first fragment of a fragmented UDP datagram to
|
||||||
|
// the given destination: it carries the full UDP header plus payloadLen bytes
|
||||||
|
// of data, with the More Fragments flag set and offset zero.
|
||||||
|
func firstFragmentUDPTo(tb testing.TB, dst string, id uint16, dstPort uint16, payloadLen int) []byte {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
ip := &layers.IPv4{
|
||||||
|
Version: 4,
|
||||||
|
TTL: 64,
|
||||||
|
Id: id,
|
||||||
|
Protocol: layers.IPProtocolUDP,
|
||||||
|
SrcIP: net.ParseIP(fragTestSrc),
|
||||||
|
DstIP: net.ParseIP(dst),
|
||||||
|
Flags: layers.IPv4MoreFragments,
|
||||||
|
}
|
||||||
|
udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
|
||||||
|
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
|
||||||
|
|
||||||
|
buf := gopacket.NewSerializeBuffer()
|
||||||
|
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||||
|
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstFragmentUDP(tb testing.TB, id uint16, dstPort uint16, payloadLen int) []byte {
|
||||||
|
tb.Helper()
|
||||||
|
return firstFragmentUDPTo(tb, fragTestDst, id, dstPort, payloadLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstFragmentTCP builds the first fragment of a fragmented TCP datagram: the
|
||||||
|
// full 20-byte TCP header plus 12 bytes of data, with the More Fragments flag
|
||||||
|
// set and offset zero.
|
||||||
|
func firstFragmentTCP(tb testing.TB, id uint16, dstPort uint16) []byte {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
ip := &layers.IPv4{
|
||||||
|
Version: 4,
|
||||||
|
TTL: 64,
|
||||||
|
Id: id,
|
||||||
|
Protocol: layers.IPProtocolTCP,
|
||||||
|
SrcIP: net.ParseIP(fragTestSrc),
|
||||||
|
DstIP: net.ParseIP(fragTestDst),
|
||||||
|
Flags: layers.IPv4MoreFragments,
|
||||||
|
}
|
||||||
|
tcp := &layers.TCP{SrcPort: 40000, DstPort: layers.TCPPort(dstPort), SYN: true, Window: 64240}
|
||||||
|
require.NoError(tb, tcp.SetNetworkLayerForChecksum(ip))
|
||||||
|
|
||||||
|
buf := gopacket.NewSerializeBuffer()
|
||||||
|
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||||
|
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(make([]byte, 12))))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// trailingFragmentTo builds a non-first fragment to the given destination: an
|
||||||
|
// IPv4 header at the given fragment offset (in 8-byte units) carrying raw
|
||||||
|
// payload and no L4 header.
|
||||||
|
func trailingFragmentTo(tb testing.TB, dst string, proto layers.IPProtocol, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
ip := &layers.IPv4{
|
||||||
|
Version: 4,
|
||||||
|
TTL: 64,
|
||||||
|
Id: id,
|
||||||
|
Protocol: proto,
|
||||||
|
SrcIP: net.ParseIP(fragTestSrc),
|
||||||
|
DstIP: net.ParseIP(dst),
|
||||||
|
FragOffset: fragOffsetOctets,
|
||||||
|
}
|
||||||
|
if moreFragments {
|
||||||
|
ip.Flags = layers.IPv4MoreFragments
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := gopacket.NewSerializeBuffer()
|
||||||
|
opts := gopacket.SerializeOptions{FixLengths: true}
|
||||||
|
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, gopacket.Payload(make([]byte, payloadLen))))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func trailingFragment(tb testing.TB, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
|
||||||
|
tb.Helper()
|
||||||
|
return trailingFragmentTo(tb, fragTestDst, layers.IPProtocolUDP, id, fragOffsetOctets, moreFragments, payloadLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// outboundUDPPacket builds a complete outbound UDP packet from the local
|
||||||
|
// address, used to establish conntrack state for reply-direction tests.
|
||||||
|
func outboundUDPPacket(tb testing.TB, srcPort, dstPort uint16) []byte {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
ip := &layers.IPv4{
|
||||||
|
Version: 4,
|
||||||
|
TTL: 64,
|
||||||
|
Id: 1,
|
||||||
|
Protocol: layers.IPProtocolUDP,
|
||||||
|
SrcIP: net.ParseIP(fragTestDst),
|
||||||
|
DstIP: net.ParseIP(fragTestSrc),
|
||||||
|
}
|
||||||
|
udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)}
|
||||||
|
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
|
||||||
|
|
||||||
|
buf := gopacket.NewSerializeBuffer()
|
||||||
|
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||||
|
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, 16))))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalUDPPacket builds a complete, non-fragmented UDP packet for baseline
|
||||||
|
// comparisons against the fragment paths.
|
||||||
|
func normalUDPPacket(tb testing.TB, dstPort uint16, payloadLen int) []byte {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
ip := &layers.IPv4{
|
||||||
|
Version: 4,
|
||||||
|
TTL: 64,
|
||||||
|
Id: 1,
|
||||||
|
Protocol: layers.IPProtocolUDP,
|
||||||
|
SrcIP: net.ParseIP(fragTestSrc),
|
||||||
|
DstIP: net.ParseIP(fragTestDst),
|
||||||
|
}
|
||||||
|
udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
|
||||||
|
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
|
||||||
|
|
||||||
|
buf := gopacket.NewSerializeBuffer()
|
||||||
|
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||||
|
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowUDP(tb testing.TB, m *Manager, dstPort uint16) {
|
||||||
|
tb.Helper()
|
||||||
|
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolUDP, nil,
|
||||||
|
&fw.Port{Values: []uint16{dstPort}}, fw.ActionAccept, "")
|
||||||
|
require.NoError(tb, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_TrailingWithoutFirstDropped is the core bypass repro: a trailing
|
||||||
|
// fragment with no allowed first fragment on record must be dropped. Before the
|
||||||
|
// fix, filterInbound returned false (allow) for any fragment.
|
||||||
|
func TestFragment_TrailingWithoutFirstDropped(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
|
||||||
|
frag := trailingFragment(t, 0x1234, 185, false, 40)
|
||||||
|
require.True(t, m.filterInbound(frag, len(frag)),
|
||||||
|
"trailing fragment without an allowed first fragment must be dropped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_AllowedFirstPassesTrailing verifies that once a first fragment
|
||||||
|
// passes the ACL, its trailing fragments inherit the allow verdict.
|
||||||
|
func TestFragment_AllowedFirstPassesTrailing(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
allowUDP(t, m, 8080)
|
||||||
|
|
||||||
|
// First fragment: UDP header (8) + 32 payload = 40 octets -> headerEnd = 5.
|
||||||
|
first := firstFragmentUDP(t, 0x2222, 8080, 32)
|
||||||
|
require.False(t, m.filterInbound(first, len(first)),
|
||||||
|
"allowed first fragment should pass and be recorded")
|
||||||
|
|
||||||
|
trailing := trailingFragment(t, 0x2222, 5, false, 24)
|
||||||
|
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||||
|
"trailing fragment of an allowed datagram should pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_DeniedFirstDropsTrailing verifies that a first fragment blocked
|
||||||
|
// by the ACL leaves no verdict, so its trailing fragments are dropped.
|
||||||
|
func TestFragment_DeniedFirstDropsTrailing(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
// No accept rule: local traffic defaults to deny.
|
||||||
|
|
||||||
|
first := firstFragmentUDP(t, 0x3333, 9999, 32)
|
||||||
|
require.True(t, m.filterInbound(first, len(first)),
|
||||||
|
"first fragment to a blocked port should be dropped by the ACL")
|
||||||
|
|
||||||
|
trailing := trailingFragment(t, 0x3333, 5, false, 24)
|
||||||
|
require.True(t, m.filterInbound(trailing, len(trailing)),
|
||||||
|
"trailing fragment of a denied datagram must be dropped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_OverlappingHeaderDropped covers the RFC 1858 §4 / RFC 3128
|
||||||
|
// overlapping-fragment rewrite: a trailing fragment starting inside the range
|
||||||
|
// the ACL already inspected is dropped and poisons the datagram. TCP is used so
|
||||||
|
// the overlap lands on real header bytes (the flags at byte 13).
|
||||||
|
func TestFragment_OverlappingHeaderDropped(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
|
||||||
|
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// First fragment: TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
|
||||||
|
first := firstFragmentTCP(t, 0x4444, 8080)
|
||||||
|
require.False(t, m.filterInbound(first, len(first)))
|
||||||
|
|
||||||
|
// Overlapping fragment at offset 1 (byte 8) falls inside the inspected TCP
|
||||||
|
// header, so it could rewrite the flags or port on reassembly.
|
||||||
|
overlap := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 1, true, 32)
|
||||||
|
require.True(t, m.filterInbound(overlap, len(overlap)),
|
||||||
|
"fragment overlapping the inspected header must be dropped")
|
||||||
|
|
||||||
|
// The datagram is now poisoned: a later, non-overlapping fragment is also
|
||||||
|
// dropped because the verdict was removed.
|
||||||
|
later := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 4, false, 24)
|
||||||
|
require.True(t, m.filterInbound(later, len(later)),
|
||||||
|
"fragments after an overlap must be dropped (datagram poisoned)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_OffsetZeroOverlapPoisons covers the RFC 3128 offset-zero rewrite:
|
||||||
|
// an allowed first fragment followed by a denied offset-zero fragment for the
|
||||||
|
// same datagram must not leave the earlier allow verdict in place.
|
||||||
|
func TestFragment_OffsetZeroOverlapPoisons(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
allowUDP(t, m, 8080)
|
||||||
|
|
||||||
|
allowed := firstFragmentUDP(t, 0x5A5A, 8080, 32)
|
||||||
|
require.False(t, m.filterInbound(allowed, len(allowed)),
|
||||||
|
"allowed first fragment should pass and be recorded")
|
||||||
|
|
||||||
|
// A second offset-zero fragment to a denied port supersedes the datagram's
|
||||||
|
// verdict; it is dropped and must not leave the allow in place.
|
||||||
|
denied := firstFragmentUDP(t, 0x5A5A, 9999, 32)
|
||||||
|
require.True(t, m.filterInbound(denied, len(denied)),
|
||||||
|
"denied offset-zero fragment must be dropped")
|
||||||
|
|
||||||
|
trailing := trailingFragment(t, 0x5A5A, 5, false, 24)
|
||||||
|
require.True(t, m.filterInbound(trailing, len(trailing)),
|
||||||
|
"trailing fragment must be denied after the datagram was poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_TinyFirstDropped covers the tiny-fragment attack: a first
|
||||||
|
// fragment too small to contain the full transport header can't be
|
||||||
|
// ACL-evaluated and must be dropped.
|
||||||
|
func TestFragment_TinyFirstDropped(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
allowUDP(t, m, 8080)
|
||||||
|
|
||||||
|
// IPv4 header + 4 raw bytes, MF set, offset 0: too small for the 8-byte UDP
|
||||||
|
// header, so it decodes to L3 only.
|
||||||
|
tiny := trailingFragment(t, 0x5555, 0, true, 4)
|
||||||
|
require.True(t, m.filterInbound(tiny, len(tiny)),
|
||||||
|
"tiny first fragment without a full L4 header must be dropped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_TCPFirstFragment verifies the TCP arm of the transport decode: a
|
||||||
|
// first fragment carrying the full 20-byte TCP header is ACL-evaluated and its
|
||||||
|
// trailing fragments inherit the verdict.
|
||||||
|
func TestFragment_TCPFirstFragment(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
|
||||||
|
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
|
||||||
|
first := firstFragmentTCP(t, 0x6666, 8080)
|
||||||
|
require.False(t, m.filterInbound(first, len(first)),
|
||||||
|
"allowed TCP first fragment should pass and be recorded")
|
||||||
|
|
||||||
|
trailing := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x6666, 4, false, 24)
|
||||||
|
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||||
|
"trailing fragment of an allowed TCP datagram should pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_TCPTinyFirstDropped verifies the TCP minimum header length: 12
|
||||||
|
// bytes would satisfy a UDP header but falls short of the 20-byte TCP header.
|
||||||
|
func TestFragment_TCPTinyFirstDropped(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
|
||||||
|
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tiny := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x7777, 0, true, 12)
|
||||||
|
require.True(t, m.filterInbound(tiny, len(tiny)),
|
||||||
|
"first fragment shorter than the TCP header must be dropped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_ConntrackAllowsFirstFragment verifies the conntrack branch: reply
|
||||||
|
// fragments of an outbound-established UDP flow pass without any inbound rule.
|
||||||
|
func TestFragment_ConntrackAllowsFirstFragment(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
|
||||||
|
out := outboundUDPPacket(t, 12345, 40000)
|
||||||
|
require.False(t, m.filterOutbound(out, len(out)))
|
||||||
|
|
||||||
|
first := firstFragmentUDP(t, 0x8888, 12345, 32)
|
||||||
|
require.False(t, m.filterInbound(first, len(first)),
|
||||||
|
"reply first fragment should pass via conntrack")
|
||||||
|
|
||||||
|
trailing := trailingFragment(t, 0x8888, 5, false, 24)
|
||||||
|
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||||
|
"trailing fragment of a tracked flow should pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_RoutingDisabledDropsFragment verifies routed first fragments are
|
||||||
|
// dropped when routing is disabled.
|
||||||
|
func TestFragment_RoutingDisabledDropsFragment(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
m.routingEnabled.Store(false)
|
||||||
|
|
||||||
|
first := firstFragmentUDPTo(t, "198.51.100.10", 0x9999, 8080, 32)
|
||||||
|
require.True(t, m.filterInbound(first, len(first)),
|
||||||
|
"routed first fragment must be dropped when routing is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_RouteACL verifies the route-ACL branch: fragments to a non-local
|
||||||
|
// destination follow the route rules, allowed datagrams pass their trailing
|
||||||
|
// fragments and denied ones don't.
|
||||||
|
func TestFragment_RouteACL(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
m.routingEnabled.Store(true)
|
||||||
|
m.nativeRouter.Store(false)
|
||||||
|
|
||||||
|
_, err := m.AddRouteFiltering(
|
||||||
|
[]byte("rt-1"),
|
||||||
|
[]netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")},
|
||||||
|
fw.Network{Prefix: netip.MustParsePrefix("198.51.100.0/24")},
|
||||||
|
fw.ProtocolUDP,
|
||||||
|
nil,
|
||||||
|
&fw.Port{Values: []uint16{8080}},
|
||||||
|
fw.ActionAccept,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
first := firstFragmentUDPTo(t, "198.51.100.10", 0xAAAA, 8080, 32)
|
||||||
|
require.False(t, m.filterInbound(first, len(first)),
|
||||||
|
"route-ACL-allowed first fragment should pass")
|
||||||
|
trailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xAAAA, 5, false, 24)
|
||||||
|
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||||
|
"trailing fragment of an allowed routed datagram should pass")
|
||||||
|
|
||||||
|
denied := firstFragmentUDPTo(t, "198.51.100.10", 0xBBBB, 9999, 32)
|
||||||
|
require.True(t, m.filterInbound(denied, len(denied)),
|
||||||
|
"route-ACL-denied first fragment must be dropped")
|
||||||
|
deniedTrailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xBBBB, 5, false, 24)
|
||||||
|
require.True(t, m.filterInbound(deniedTrailing, len(deniedTrailing)),
|
||||||
|
"trailing fragment of a denied routed datagram must be dropped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_ExpiredVerdictDropsTrailing verifies a verdict older than the
|
||||||
|
// tracker timeout no longer admits trailing fragments.
|
||||||
|
func TestFragment_ExpiredVerdictDropsTrailing(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
allowUDP(t, m, 8080)
|
||||||
|
|
||||||
|
first := firstFragmentUDP(t, 0xCCCC, 8080, 32)
|
||||||
|
require.False(t, m.filterInbound(first, len(first)))
|
||||||
|
|
||||||
|
m.fragments.mutex.Lock()
|
||||||
|
for key, entry := range m.fragments.entries {
|
||||||
|
entry.recordedAt = time.Now().Add(-defaultFragmentTimeout - time.Second)
|
||||||
|
m.fragments.entries[key] = entry
|
||||||
|
}
|
||||||
|
m.fragments.mutex.Unlock()
|
||||||
|
|
||||||
|
trailing := trailingFragment(t, 0xCCCC, 5, false, 24)
|
||||||
|
require.True(t, m.filterInbound(trailing, len(trailing)),
|
||||||
|
"trailing fragment after verdict expiry must be dropped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragment_CapacityFailsClosed verifies the table cap: at capacity, new
|
||||||
|
// datagram verdicts are not recorded (their trailing fragments are dropped)
|
||||||
|
// while already-recorded datagrams keep working.
|
||||||
|
func TestFragment_CapacityFailsClosed(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
allowUDP(t, m, 8080)
|
||||||
|
|
||||||
|
m.fragments.mutex.Lock()
|
||||||
|
m.fragments.maxEntries = 1
|
||||||
|
m.fragments.mutex.Unlock()
|
||||||
|
|
||||||
|
first1 := firstFragmentUDP(t, 0x0101, 8080, 32)
|
||||||
|
require.False(t, m.filterInbound(first1, len(first1)))
|
||||||
|
|
||||||
|
first2 := firstFragmentUDP(t, 0x0202, 8080, 32)
|
||||||
|
require.False(t, m.filterInbound(first2, len(first2)),
|
||||||
|
"first fragment itself still passes at capacity")
|
||||||
|
|
||||||
|
trailing2 := trailingFragment(t, 0x0202, 5, false, 24)
|
||||||
|
require.True(t, m.filterInbound(trailing2, len(trailing2)),
|
||||||
|
"trailing fragment of an unrecorded datagram must be dropped at capacity")
|
||||||
|
|
||||||
|
trailing1 := trailingFragment(t, 0x0101, 5, false, 24)
|
||||||
|
require.False(t, m.filterInbound(trailing1, len(trailing1)),
|
||||||
|
"already-recorded datagram should keep passing at capacity")
|
||||||
|
}
|
||||||
|
|
||||||
|
// v6FragmentHeader builds the 8-byte IPv6 fragment extension header for the
|
||||||
|
// given inner protocol, offset (8-byte units), More Fragments bit and id.
|
||||||
|
func v6FragmentHeader(proto layers.IPProtocol, offsetOctets uint16, moreFragments bool, id uint32) []byte {
|
||||||
|
offsetFlags := offsetOctets << 3
|
||||||
|
if moreFragments {
|
||||||
|
offsetFlags |= 1
|
||||||
|
}
|
||||||
|
hdr := make([]byte, 8)
|
||||||
|
hdr[0] = uint8(proto)
|
||||||
|
binary.BigEndian.PutUint16(hdr[2:4], offsetFlags)
|
||||||
|
binary.BigEndian.PutUint32(hdr[4:8], id)
|
||||||
|
return hdr
|
||||||
|
}
|
||||||
|
|
||||||
|
func v6UDPHeader(dstPort uint16, dataLen int) []byte {
|
||||||
|
hdr := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint16(hdr[0:2], 40000)
|
||||||
|
binary.BigEndian.PutUint16(hdr[2:4], dstPort)
|
||||||
|
binary.BigEndian.PutUint16(hdr[4:6], uint16(8+dataLen))
|
||||||
|
return hdr
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstFragmentUDPv6 builds the first fragment of a fragmented IPv6 UDP
|
||||||
|
// datagram: fragment header (offset 0, More Fragments set) + full UDP header +
|
||||||
|
// data.
|
||||||
|
func firstFragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int) []byte {
|
||||||
|
tb.Helper()
|
||||||
|
return fragmentUDPv6(tb, id, dstPort, dataLen, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fragmentUDPv6 builds an offset-zero IPv6 UDP fragment. With moreFragments
|
||||||
|
// false it is an atomic fragment (a complete datagram, RFC 6946).
|
||||||
|
func fragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int, moreFragments bool) []byte {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
ip := &layers.IPv6{
|
||||||
|
Version: 6,
|
||||||
|
NextHeader: layers.IPProtocolIPv6Fragment,
|
||||||
|
HopLimit: 64,
|
||||||
|
SrcIP: net.ParseIP(fragTestSrcV6),
|
||||||
|
DstIP: net.ParseIP(fragTestDstV6),
|
||||||
|
}
|
||||||
|
payload := append(v6FragmentHeader(layers.IPProtocolUDP, 0, moreFragments, id), v6UDPHeader(dstPort, dataLen)...)
|
||||||
|
payload = append(payload, make([]byte, dataLen)...)
|
||||||
|
|
||||||
|
buf := gopacket.NewSerializeBuffer()
|
||||||
|
require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// trailingFragmentV6 builds a non-first IPv6 fragment: fragment header at the
|
||||||
|
// given offset carrying raw data and no transport header.
|
||||||
|
func trailingFragmentV6(tb testing.TB, id uint32, offsetOctets uint16, moreFragments bool, dataLen int) []byte {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
ip := &layers.IPv6{
|
||||||
|
Version: 6,
|
||||||
|
NextHeader: layers.IPProtocolIPv6Fragment,
|
||||||
|
HopLimit: 64,
|
||||||
|
SrcIP: net.ParseIP(fragTestSrcV6),
|
||||||
|
DstIP: net.ParseIP(fragTestDstV6),
|
||||||
|
}
|
||||||
|
payload := append(v6FragmentHeader(layers.IPProtocolUDP, offsetOctets, moreFragments, id), make([]byte, dataLen)...)
|
||||||
|
|
||||||
|
buf := gopacket.NewSerializeBuffer()
|
||||||
|
require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragmentV6_TrailingWithoutFirstDropped verifies the IPv6 bypass is closed:
|
||||||
|
// a trailing fragment with no allowed first fragment is dropped.
|
||||||
|
func TestFragmentV6_TrailingWithoutFirstDropped(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
|
||||||
|
frag := trailingFragmentV6(t, 0xAABBCCDD, 100, false, 40)
|
||||||
|
require.True(t, m.filterInbound(frag, len(frag)),
|
||||||
|
"IPv6 trailing fragment without an allowed first fragment must be dropped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragmentV6_AllowedFirstPassesTrailing verifies IPv6 fragments are
|
||||||
|
// evaluated like IPv4: an allowed first fragment lets its trailing fragments
|
||||||
|
// through.
|
||||||
|
func TestFragmentV6_AllowedFirstPassesTrailing(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
|
||||||
|
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// First fragment: UDP header (8) + 32 data = 40 octets -> headerEnd = 5.
|
||||||
|
first := firstFragmentUDPv6(t, 0xAABBCCDD, 8080, 32)
|
||||||
|
require.False(t, m.filterInbound(first, len(first)),
|
||||||
|
"allowed IPv6 first fragment should pass and be recorded")
|
||||||
|
|
||||||
|
trailing := trailingFragmentV6(t, 0xAABBCCDD, 5, false, 24)
|
||||||
|
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||||
|
"trailing fragment of an allowed IPv6 datagram should pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragmentV6_AtomicNotCached verifies an IPv6 atomic fragment (fragment
|
||||||
|
// header with offset 0 and no More Fragments, a complete datagram per RFC 6946)
|
||||||
|
// is evaluated but not recorded, so a flood of allowed atomic fragments can't
|
||||||
|
// exhaust the verdict table.
|
||||||
|
func TestFragmentV6_AtomicNotCached(t *testing.T) {
|
||||||
|
m := newFragmentTestManager(t)
|
||||||
|
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
|
||||||
|
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
atomic := fragmentUDPv6(t, 0xA70301C, 8080, 16, false)
|
||||||
|
require.False(t, m.filterInbound(atomic, len(atomic)),
|
||||||
|
"allowed IPv6 atomic fragment should pass")
|
||||||
|
|
||||||
|
m.fragments.mutex.Lock()
|
||||||
|
n := len(m.fragments.entries)
|
||||||
|
m.fragments.mutex.Unlock()
|
||||||
|
require.Zero(t, n, "atomic fragment must not create a verdict entry")
|
||||||
|
|
||||||
|
// A genuine fragmented datagram (More Fragments set) is still recorded.
|
||||||
|
first := fragmentUDPv6(t, 0xBEEF, 8080, 32, true)
|
||||||
|
require.False(t, m.filterInbound(first, len(first)))
|
||||||
|
m.fragments.mutex.Lock()
|
||||||
|
n = len(m.fragments.entries)
|
||||||
|
m.fragments.mutex.Unlock()
|
||||||
|
require.Equal(t, 1, n, "genuine first fragment must record a verdict")
|
||||||
|
}
|
||||||
@@ -464,6 +464,8 @@ func Test_RemovePeer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_ConnectPeers(t *testing.T) {
|
func Test_ConnectPeers(t *testing.T) {
|
||||||
|
t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true")
|
||||||
|
|
||||||
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
|
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
|
||||||
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
|
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
|
||||||
peer1Key, _ := wgtypes.GeneratePrivateKey()
|
peer1Key, _ := wgtypes.GeneratePrivateKey()
|
||||||
@@ -505,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
localIP, err := getLocalIP()
|
localIP1 := "127.0.0.1"
|
||||||
if err != nil {
|
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -546,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
|
localIP2 := "127.0.0.1"
|
||||||
|
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -569,17 +568,17 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// todo: investigate why in some tests execution we need 30s
|
// The peers use userspace WireGuard (stdnet transport). A tight busy-loop
|
||||||
|
// here starves the wireguard-go goroutines that process the handshake, so
|
||||||
|
// poll on a ticker instead and yield the CPU between checks. WireGuard also
|
||||||
|
// only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which
|
||||||
|
// is why the overall wait can occasionally stretch to tens of seconds.
|
||||||
timeout := 30 * time.Second
|
timeout := 30 * time.Second
|
||||||
timeoutChannel := time.After(timeout)
|
timeoutChannel := time.After(timeout)
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
|
||||||
case <-timeoutChannel:
|
|
||||||
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String())
|
peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String())
|
||||||
if gpErr != nil {
|
if gpErr != nil {
|
||||||
t.Fatal(gpErr)
|
t.Fatal(gpErr)
|
||||||
@@ -588,6 +587,12 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
t.Log("peers successfully handshake")
|
t.Log("peers successfully handshake")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timeoutChannel:
|
||||||
|
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -615,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) {
|
|||||||
}
|
}
|
||||||
return wgtypes.Peer{}, fmt.Errorf("peer not found")
|
return wgtypes.Peer{}, fmt.Errorf("peer not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLocalIP() (string, error) {
|
|
||||||
// Get all interfaces
|
|
||||||
addrs, err := net.InterfaceAddrs()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, addr := range addrs {
|
|
||||||
ipNet, ok := addr.(*net.IPNet)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ipNet.IP.IsLoopback() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ipNet.IP.To4() == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return ipNet.IP.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("no local IP found")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,14 +3,31 @@
|
|||||||
package netstack
|
package netstack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
|
const (
|
||||||
|
EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
|
||||||
|
|
||||||
|
// EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on.
|
||||||
|
EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT"
|
||||||
|
|
||||||
|
// EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to.
|
||||||
|
// The proxy is a bridge for local host applications into the userspace
|
||||||
|
// WireGuard netstack, so it binds to loopback by default. Override this only
|
||||||
|
// when the proxy must be reachable from other hosts (e.g. a container
|
||||||
|
// gateway); doing so exposes an unauthenticated SOCKS5 proxy on that
|
||||||
|
// address.
|
||||||
|
EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS"
|
||||||
|
|
||||||
|
// defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless
|
||||||
|
// overridden via EnvSocks5ListenerAddress.
|
||||||
|
defaultSocks5Host = "127.0.0.1"
|
||||||
|
)
|
||||||
|
|
||||||
// IsEnabled todo: move these function to cmd layer
|
// IsEnabled todo: move these function to cmd layer
|
||||||
func IsEnabled() bool {
|
func IsEnabled() bool {
|
||||||
@@ -18,24 +35,40 @@ func IsEnabled() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListenAddr() string {
|
func ListenAddr() string {
|
||||||
sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT")
|
return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to
|
||||||
|
// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP.
|
||||||
|
func listenHost() string {
|
||||||
|
addr := os.Getenv(EnvSocks5ListenerAddress)
|
||||||
|
if addr == "" {
|
||||||
|
return defaultSocks5Host
|
||||||
|
}
|
||||||
|
if net.ParseIP(addr) == nil {
|
||||||
|
log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host)
|
||||||
|
return defaultSocks5Host
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenPort returns the port the SOCKS5 proxy binds to, defaulting to
|
||||||
|
// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid.
|
||||||
|
func listenPort() int {
|
||||||
|
sPort := os.Getenv(EnvSocks5ListenerPort)
|
||||||
if sPort == "" {
|
if sPort == "" {
|
||||||
return listenAddr(DefaultSocks5Port)
|
return DefaultSocks5Port
|
||||||
}
|
}
|
||||||
|
|
||||||
port, err := strconv.Atoi(sPort)
|
port, err := strconv.Atoi(sPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port)
|
log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port)
|
||||||
return listenAddr(DefaultSocks5Port)
|
return DefaultSocks5Port
|
||||||
}
|
}
|
||||||
if port < 1 || port > 65535 {
|
if port < 1 || port > 65535 {
|
||||||
log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port)
|
log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port)
|
||||||
return listenAddr(DefaultSocks5Port)
|
return DefaultSocks5Port
|
||||||
}
|
}
|
||||||
|
|
||||||
return listenAddr(port)
|
return port
|
||||||
}
|
|
||||||
|
|
||||||
func listenAddr(port int) string {
|
|
||||||
return fmt.Sprintf("0.0.0.0:%d", port)
|
|
||||||
}
|
}
|
||||||
|
|||||||
63
client/iface/netstack/env_test.go
Normal file
63
client/iface/netstack/env_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//go:build !js
|
||||||
|
|
||||||
|
package netstack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListenAddr_DefaultsToLoopback(t *testing.T) {
|
||||||
|
// No env overrides: must bind loopback, never all interfaces.
|
||||||
|
got := ListenAddr()
|
||||||
|
want := net.JoinHostPort("127.0.0.1", strconv.Itoa(DefaultSocks5Port))
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ListenAddr() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListenAddr_AddressOverride(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "valid override honored", env: "0.0.0.0", want: "0.0.0.0"},
|
||||||
|
{name: "valid specific ip honored", env: "10.0.0.5", want: "10.0.0.5"},
|
||||||
|
{name: "ipv6 loopback bracketed", env: "::1", want: "::1"},
|
||||||
|
{name: "invalid falls back to loopback", env: "not-an-ip", want: "127.0.0.1"},
|
||||||
|
{name: "empty falls back to loopback", env: "", want: "127.0.0.1"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv(EnvSocks5ListenerAddress, tc.env)
|
||||||
|
want := net.JoinHostPort(tc.want, strconv.Itoa(DefaultSocks5Port))
|
||||||
|
if got := ListenAddr(); got != want {
|
||||||
|
t.Fatalf("ListenAddr() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListenAddr_PortOverride(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "valid port honored", env: "1081", want: 1081},
|
||||||
|
{name: "non-numeric falls back", env: "abc", want: DefaultSocks5Port},
|
||||||
|
{name: "out of range falls back", env: "70000", want: DefaultSocks5Port},
|
||||||
|
{name: "zero falls back", env: "0", want: DefaultSocks5Port},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv(EnvSocks5ListenerPort, tc.env)
|
||||||
|
want := net.JoinHostPort("127.0.0.1", strconv.Itoa(tc.want))
|
||||||
|
if got := ListenAddr(); got != want {
|
||||||
|
t.Fatalf("ListenAddr() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +351,7 @@ 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.SyncMessageVersion,
|
||||||
a.config.EnableSSHRoot,
|
a.config.EnableSSHRoot,
|
||||||
a.config.EnableSSHSFTP,
|
a.config.EnableSSHSFTP,
|
||||||
a.config.EnableSSHLocalPortForwarding,
|
a.config.EnableSSHLocalPortForwarding,
|
||||||
@@ -473,3 +503,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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,12 +259,18 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
|||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
log.Infof("device flow: waiting for user authorization, polling token endpoint every %s, code expires in %s", interval, timeout)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
polls := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-waitCtx.Done():
|
case <-waitCtx.Done():
|
||||||
return TokenInfo{}, waitCtx.Err()
|
return TokenInfo{}, waitCtx.Err()
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
|
||||||
|
polls++
|
||||||
tokenResponse, err := d.requestToken(info)
|
tokenResponse, err := d.requestToken(info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err)
|
return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err)
|
||||||
@@ -272,10 +278,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
|||||||
|
|
||||||
if tokenResponse.Error != "" {
|
if tokenResponse.Error != "" {
|
||||||
if tokenResponse.Error == "authorization_pending" {
|
if tokenResponse.Error == "authorization_pending" {
|
||||||
|
log.Tracef("device flow: authorization still pending after poll %d", polls)
|
||||||
continue
|
continue
|
||||||
} else if tokenResponse.Error == "slow_down" {
|
} else if tokenResponse.Error == "slow_down" {
|
||||||
interval += (3 * time.Second)
|
interval += (3 * time.Second)
|
||||||
ticker.Reset(interval)
|
ticker.Reset(interval)
|
||||||
|
log.Infof("device flow: IdP requested slow_down, polling interval increased to %s", interval)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,11 +299,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
|||||||
UseIDToken: d.providerConfig.UseIDToken,
|
UseIDToken: d.providerConfig.UseIDToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
|
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
|
||||||
return tokenInfo, err
|
return tokenInfo, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -188,6 +188,8 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo
|
|||||||
waitCtx, cancel := context.WithTimeout(ctx, timeout)
|
waitCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
log.Infof("pkce flow: waiting for authorization callback on %s, timeout %s", p.oAuthConfig.RedirectURL, timeout)
|
||||||
|
|
||||||
tokenChan := make(chan *oauth2.Token, 1)
|
tokenChan := make(chan *oauth2.Token, 1)
|
||||||
errChan := make(chan error, 1)
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
@@ -221,6 +223,7 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo
|
|||||||
func (p *PKCEAuthorizationFlow) startServer(server *http.Server, tokenChan chan<- *oauth2.Token, errChan chan<- error) {
|
func (p *PKCEAuthorizationFlow) startServer(server *http.Server, tokenChan chan<- *oauth2.Token, errChan chan<- error) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
|
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Infof("pkce flow: received authorization callback from IdP")
|
||||||
cert := p.providerConfig.ClientCertPair
|
cert := p.providerConfig.ClientCertPair
|
||||||
if cert != nil {
|
if cert != nil {
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
@@ -271,11 +274,18 @@ func (p *PKCEAuthorizationFlow) handleRequest(req *http.Request) (*oauth2.Token,
|
|||||||
return nil, fmt.Errorf("authentication failed: missing code")
|
return nil, fmt.Errorf("authentication failed: missing code")
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.oAuthConfig.Exchange(
|
exchangeStart := time.Now()
|
||||||
|
token, err := p.oAuthConfig.Exchange(
|
||||||
req.Context(),
|
req.Context(),
|
||||||
code,
|
code,
|
||||||
oauth2.SetAuthURLParam("code_verifier", p.codeVerifier),
|
oauth2.SetAuthURLParam("code_verifier", p.codeVerifier),
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("pkce flow: authorization code exchanged for token in %s", time.Since(exchangeStart).Round(time.Millisecond))
|
||||||
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, error) {
|
func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, error) {
|
||||||
@@ -296,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
|||||||
audience = p.providerConfig.ClientID
|
audience = p.providerConfig.ClientID
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil {
|
if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil {
|
||||||
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
|
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +320,11 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
|||||||
return tokenInfo, nil
|
return tokenInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
|
||||||
|
// without verifying its signature. The value is best-effort and used only as a
|
||||||
|
// UX convenience (login hint prefill and display); it never drives an
|
||||||
|
// authorization decision. The authoritative identity is established server-side
|
||||||
|
// from the signature-verified token.
|
||||||
func parseEmailFromIDToken(token string) (string, error) {
|
func parseEmailFromIDToken(token string) (string, error) {
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
382
client/internal/auth/sessionwatch/watcher.go
Normal file
382
client/internal/auth/sessionwatch/watcher.go
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
// Package sessionwatch tracks the SSO session expiry deadline that the
|
||||||
|
// management server publishes via LoginResponse / SyncResponse and fires
|
||||||
|
// two warning events at fixed lead times before expiry: an interactive
|
||||||
|
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
|
||||||
|
// fallback dialog.
|
||||||
|
//
|
||||||
|
// The watcher is idempotent: Update may be called as often as the network
|
||||||
|
// map snapshots arrive. Repeating the same deadline is a no-op; a new
|
||||||
|
// deadline reschedules the timers and arms a fresh warning cycle.
|
||||||
|
//
|
||||||
|
// Warning firing is edge-detected. Each unique deadline value fires each
|
||||||
|
// warning callback at most once.
|
||||||
|
package sessionwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxPastHorizon = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||||
|
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||||
|
// glitch, and silently arming a 100-year timer would hide the bug.
|
||||||
|
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
|
||||||
|
|
||||||
|
// WarningLead is how far before expiry the first (interactive)
|
||||||
|
// warning fires. Drives the T-10 OS notification with
|
||||||
|
// Extend/Dismiss actions.
|
||||||
|
WarningLead = 10 * time.Minute
|
||||||
|
|
||||||
|
// FinalWarningLead is how far before expiry the fallback final
|
||||||
|
// warning fires. Drives the auto-opened SessionAboutToExpire dialog,
|
||||||
|
// but only when the user has not dismissed the T-WarningLead warning
|
||||||
|
// for the same deadline. Must be strictly less than WarningLead.
|
||||||
|
FinalWarningLead = 2 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrDeadlineBeforeEpoch is returned by Update when the supplied
|
||||||
|
// deadline pre-dates 1970-01-01.
|
||||||
|
ErrDeadlineBeforeEpoch = errors.New("session deadline before unix epoch")
|
||||||
|
|
||||||
|
// ErrDeadlineTooFarFuture is returned by Update when the supplied
|
||||||
|
// deadline is more than maxDeadlineHorizon in the future.
|
||||||
|
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
|
||||||
|
|
||||||
|
// ErrDeadlineInPast is returned by Update when the supplied deadline
|
||||||
|
// is more than maxPastHorizon in the past.
|
||||||
|
ErrDeadlineInPast = errors.New("session deadline in the past")
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusRecorder is the side-effect surface the watcher drives on every
|
||||||
|
// state transition. Production wires this to peer.Status (SetSessionExpiresAt
|
||||||
|
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
||||||
|
// a fake recorder so the same surface is observable without an engine.
|
||||||
|
//
|
||||||
|
// While the watcher runs, it owns the deadline propagated to the recorder:
|
||||||
|
// every set, clear and sanity-check rejection routes the value through
|
||||||
|
// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can
|
||||||
|
// never drift from the watcher's timer state. (SetSessionExpiresAt fans
|
||||||
|
// out its own state-change notification, so no separate notify is needed.)
|
||||||
|
// The recorder is server-scoped and outlives this engine-scoped watcher;
|
||||||
|
// Close deliberately leaves the recorder value in place so transient engine
|
||||||
|
// restarts don't blank it — the client run loop clears it on real teardown.
|
||||||
|
//
|
||||||
|
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
|
||||||
|
// composes the metadata internally so the wire format (MetaSession*) is
|
||||||
|
// owned by sessionwatch, not the caller.
|
||||||
|
type StatusRecorder interface {
|
||||||
|
SetSessionExpiresAt(deadline time.Time)
|
||||||
|
PublishEvent(
|
||||||
|
severity cProto.SystemEvent_Severity,
|
||||||
|
category cProto.SystemEvent_Category,
|
||||||
|
message string,
|
||||||
|
userMessage string,
|
||||||
|
metadata map[string]string,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher observes the latest session deadline and fires two warnings
|
||||||
|
// before it expires: the interactive T-WarningLead notification, and the
|
||||||
|
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
|
||||||
|
// the first one for the same deadline). Safe for concurrent use.
|
||||||
|
type Watcher struct {
|
||||||
|
lead time.Duration
|
||||||
|
finalLead time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
current time.Time
|
||||||
|
timer *time.Timer
|
||||||
|
finalTimer *time.Timer
|
||||||
|
firedAt time.Time // deadline value the T-WarningLead callback last fired against
|
||||||
|
finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against
|
||||||
|
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
|
||||||
|
closed bool
|
||||||
|
recorder StatusRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a watcher with the package defaults WarningLead and
|
||||||
|
// FinalWarningLead. Pass nil for recorder to silence side effects (handy
|
||||||
|
// in unit tests that exercise sanity checks without observing the publish
|
||||||
|
// path).
|
||||||
|
func New(recorder StatusRecorder) *Watcher {
|
||||||
|
return NewWithLeads(WarningLead, FinalWarningLead, recorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithLeads returns a watcher with custom lead times. Useful for tests.
|
||||||
|
// final must be strictly less than lead; otherwise both timers fire in the
|
||||||
|
// wrong order or simultaneously and the UI flow breaks. A zero final lead
|
||||||
|
// disables the final-warning timer entirely (see armTimerLocked) so a
|
||||||
|
// millisecond-scale deadline doesn't flush both timers in one tick.
|
||||||
|
func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||||
|
return &Watcher{
|
||||||
|
lead: lead,
|
||||||
|
finalLead: final,
|
||||||
|
recorder: recorder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
|
||||||
|
// a Sync push from the server omits the field because login expiration
|
||||||
|
// was disabled).
|
||||||
|
//
|
||||||
|
// Same-value updates are no-ops. A different non-zero value cancels any
|
||||||
|
// pending timer, resets the "already fired" guards, and — when the
|
||||||
|
// deadline lies in the future — arms fresh warning timers. A deadline
|
||||||
|
// already in the past (within maxPastHorizon) is recorded as-is with no
|
||||||
|
// timers: the session has expired and consumers render it that way.
|
||||||
|
//
|
||||||
|
// Returns one of the sentinel Err* values when the deadline fails the
|
||||||
|
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
|
||||||
|
// In every error case the watcher first clears its state so it stays
|
||||||
|
// consistent with what the caller will push into its other sinks (e.g.
|
||||||
|
// applySessionDeadline forces a zero deadline into the status recorder
|
||||||
|
// after a non-nil error).
|
||||||
|
func (w *Watcher) Update(deadline time.Time) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if deadline.IsZero() {
|
||||||
|
w.clearLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
switch {
|
||||||
|
case deadline.Before(time.Unix(0, 0)):
|
||||||
|
w.clearLocked()
|
||||||
|
return fmt.Errorf("%w: %v", ErrDeadlineBeforeEpoch, deadline)
|
||||||
|
case deadline.After(now.Add(maxDeadlineHorizon)):
|
||||||
|
w.clearLocked()
|
||||||
|
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
|
||||||
|
case deadline.Before(now.Add(-maxPastHorizon)):
|
||||||
|
w.clearLocked()
|
||||||
|
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deadline.Equal(w.current) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w.stopTimerLocked()
|
||||||
|
w.current = deadline
|
||||||
|
// Reset every per-deadline guard so a refreshed deadline arms a fresh
|
||||||
|
// warning cycle: both edge triggers and the user Dismiss decision
|
||||||
|
// (the user agreed to the old deadline expiring; a new deadline
|
||||||
|
// restarts the contract).
|
||||||
|
w.firedAt = time.Time{}
|
||||||
|
w.finalFiredAt = time.Time{}
|
||||||
|
w.dismissedAt = time.Time{}
|
||||||
|
|
||||||
|
if deadline.After(now) {
|
||||||
|
w.armTimerLocked(deadline)
|
||||||
|
}
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder != nil {
|
||||||
|
recorder.SetSessionExpiresAt(deadline)
|
||||||
|
}
|
||||||
|
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline returns the most recently observed deadline. Zero when no
|
||||||
|
// deadline is currently tracked.
|
||||||
|
func (w *Watcher) Deadline() time.Time {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.current
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss records the user's "Dismiss" action against the current deadline
|
||||||
|
// and suppresses the upcoming final-warning callback for that deadline.
|
||||||
|
// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
|
||||||
|
// deadline resets the dismissal so the final-warning cycle re-arms.
|
||||||
|
//
|
||||||
|
// No-op when the watcher holds no deadline or has been closed.
|
||||||
|
func (w *Watcher) Dismiss() {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed || w.current.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.dismissedAt.Equal(w.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.dismissedAt = w.current
|
||||||
|
// Cancel the armed final-warning timer eagerly. fireFinal would also
|
||||||
|
// gate on dismissedAt, but stopping the timer avoids a wakeup with
|
||||||
|
// nothing to do and makes the intent visible.
|
||||||
|
if w.finalTimer != nil {
|
||||||
|
w.finalTimer.Stop()
|
||||||
|
w.finalTimer = nil
|
||||||
|
}
|
||||||
|
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops any pending timer. Update calls after Close are ignored.
|
||||||
|
// The recorder keeps its deadline: the watcher is engine-scoped and closes
|
||||||
|
// on every engine restart (network change, sleep/wake, stream errors)
|
||||||
|
// while the SSO deadline stays valid across those, so clearing here would
|
||||||
|
// blank the UI's "expires in" row on every transient reconnect. The
|
||||||
|
// client run loop clears the server-scoped recorder when it exits for
|
||||||
|
// real (Down, profile switch, permanent login failure).
|
||||||
|
func (w *Watcher) Close() {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.closed = true
|
||||||
|
w.stopTimerLocked()
|
||||||
|
w.current = time.Time{}
|
||||||
|
w.firedAt = time.Time{}
|
||||||
|
w.finalFiredAt = time.Time{}
|
||||||
|
w.dismissedAt = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||||
|
// downstream consumers (SubscribeStatus stream, UI) drop their anchor.
|
||||||
|
// The caller must hold w.mu; this helper releases it before invoking
|
||||||
|
// the recorder.
|
||||||
|
func (w *Watcher) clearLocked() {
|
||||||
|
if w.current.IsZero() {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.stopTimerLocked()
|
||||||
|
w.current = time.Time{}
|
||||||
|
w.firedAt = time.Time{}
|
||||||
|
w.finalFiredAt = time.Time{}
|
||||||
|
w.dismissedAt = time.Time{}
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder != nil {
|
||||||
|
recorder.SetSessionExpiresAt(time.Time{})
|
||||||
|
}
|
||||||
|
log.Infof("auth session deadline cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) stopTimerLocked() {
|
||||||
|
if w.timer != nil {
|
||||||
|
w.timer.Stop()
|
||||||
|
w.timer = nil
|
||||||
|
}
|
||||||
|
if w.finalTimer != nil {
|
||||||
|
w.finalTimer.Stop()
|
||||||
|
w.finalTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) armTimerLocked(deadline time.Time) {
|
||||||
|
w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) })
|
||||||
|
// finalLead <= 0 disables the final-warning timer entirely. Used by
|
||||||
|
// tests that predate the final-warning fallback so a millisecond-scale
|
||||||
|
// deadline does not flush both timers at once.
|
||||||
|
if w.finalLead > 0 {
|
||||||
|
w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) fire(armedFor time.Time) {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed || !w.current.Equal(armedFor) {
|
||||||
|
// Deadline moved while we were waiting (e.g. a successful extend).
|
||||||
|
// The reschedule path armed a fresh timer; this one is stale.
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.firedAt = armedFor
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("auth session expiry soon warning fired")
|
||||||
|
publishWarning(recorder, armedFor, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra
|
||||||
|
// dismiss-gate: if the user dismissed the T-WarningLead notification for
|
||||||
|
// this deadline, the final warning is suppressed entirely.
|
||||||
|
func (w *Watcher) fireFinal(armedFor time.Time) {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed || !w.current.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.dismissedAt.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
log.Infof("auth session final-warning skipped (dismissed by user)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.finalFiredAt = armedFor
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("auth session final-warning fired")
|
||||||
|
publishWarning(recorder, armedFor, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
|
||||||
|
// past it dispatches on the next scheduler tick so a state-change recorder
|
||||||
|
// notification (invoked after w.mu is released) lands first. Caller must
|
||||||
|
// hold w.mu.
|
||||||
|
func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer {
|
||||||
|
delay := time.Until(fireAt)
|
||||||
|
if delay <= 0 {
|
||||||
|
return time.AfterFunc(0, cb)
|
||||||
|
}
|
||||||
|
return time.AfterFunc(delay, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishWarning composes the SystemEvent for a watcher-fired warning and
|
||||||
|
// pushes it through the recorder. Severity is CRITICAL on both — bypassing
|
||||||
|
// the user's Notifications toggle is deliberate: missing the warning
|
||||||
|
// window forces the post-mortem SessionExpired flow (tunnel torn down,
|
||||||
|
// lock icon, manual re-login), which is the UX we are trying to avoid.
|
||||||
|
func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
|
||||||
|
lead := WarningLead
|
||||||
|
message := "session expiry warning"
|
||||||
|
meta := map[string]string{
|
||||||
|
MetaSessionWarning: "true",
|
||||||
|
MetaSessionExpiresAt: FormatExpiresAt(deadline),
|
||||||
|
}
|
||||||
|
if final {
|
||||||
|
lead = FinalWarningLead
|
||||||
|
message = "session expiry final warning"
|
||||||
|
meta[MetaSessionFinal] = "true"
|
||||||
|
}
|
||||||
|
meta[MetaSessionLeadMinutes] = FormatLeadMinutes(lead)
|
||||||
|
|
||||||
|
recorder.PublishEvent(
|
||||||
|
cProto.SystemEvent_CRITICAL,
|
||||||
|
cProto.SystemEvent_AUTHENTICATION,
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
meta,
|
||||||
|
)
|
||||||
|
}
|
||||||
529
client/internal/auth/sessionwatch/watcher_test.go
Normal file
529
client/internal/auth/sessionwatch/watcher_test.go
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
package sessionwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRecorder satisfies StatusRecorder and records every call so tests
|
||||||
|
// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent
|
||||||
|
// land in the same ordered events slice (with the Kind distinguishing
|
||||||
|
// them) so tests that care about ordering still work. lastDeadline holds
|
||||||
|
// the most recent value passed to SetSessionExpiresAt so tests can assert
|
||||||
|
// the recorder ended up cleared/set as expected.
|
||||||
|
type fakeRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []event
|
||||||
|
lastDeadline time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateChange eventKind = iota
|
||||||
|
publish
|
||||||
|
)
|
||||||
|
|
||||||
|
type event struct {
|
||||||
|
kind eventKind
|
||||||
|
// Set only for publish events.
|
||||||
|
severity cProto.SystemEvent_Severity
|
||||||
|
category cProto.SystemEvent_Category
|
||||||
|
message string
|
||||||
|
meta map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op,
|
||||||
|
// a real change records the new value and fans out a state-change (the
|
||||||
|
// production recorder calls notifyStateChange internally). The baseline
|
||||||
|
// is the zero time, so an initial clear before any deadline is set emits
|
||||||
|
// nothing — matching the real recorder.
|
||||||
|
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.lastDeadline.Equal(deadline) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.lastDeadline = deadline
|
||||||
|
r.events = append(r.events, event{kind: stateChange})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRecorder) deadline() time.Time {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.lastDeadline
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRecorder) PublishEvent(
|
||||||
|
severity cProto.SystemEvent_Severity,
|
||||||
|
category cProto.SystemEvent_Category,
|
||||||
|
message string,
|
||||||
|
_ string,
|
||||||
|
metadata map[string]string,
|
||||||
|
) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.events = append(r.events, event{
|
||||||
|
kind: publish,
|
||||||
|
severity: severity,
|
||||||
|
category: category,
|
||||||
|
message: message,
|
||||||
|
meta: metadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRecorder) snapshot() []event {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]event, len(r.events))
|
||||||
|
copy(out, r.events)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e event) isFinalWarning() bool {
|
||||||
|
return e.kind == publish && e.meta[MetaSessionFinal] == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e event) isWarning() bool {
|
||||||
|
return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
func countWhere(events []event, pred func(event) bool) int {
|
||||||
|
n := 0
|
||||||
|
for _, e := range events {
|
||||||
|
if pred(e) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if got := r.snapshot(); len(got) >= want {
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
got := r.snapshot()
|
||||||
|
t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
|
||||||
|
// matching the lead-only behaviour the pre-final-warning tests assume.
|
||||||
|
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
||||||
|
return NewWithLeads(lead, 0, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
_ = w.Update(time.Time{})
|
||||||
|
|
||||||
|
if got := r.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no events on initial zero, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNonZeroFiresStateChange(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(time.Hour)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 1)
|
||||||
|
if events[0].kind != stateChange {
|
||||||
|
t.Fatalf("expected stateChange, got %+v", events[0])
|
||||||
|
}
|
||||||
|
if !w.Deadline().Equal(d) {
|
||||||
|
t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameDeadlineIsNoop(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(time.Hour)
|
||||||
|
_ = w.Update(d)
|
||||||
|
_ = w.Update(d)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 1)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
lead := 50 * time.Millisecond
|
||||||
|
w := newWatcher(lead, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
// Deadline 80ms out — warning should fire after ~30ms.
|
||||||
|
d := time.Now().Add(80 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if events[0].kind != stateChange {
|
||||||
|
t.Fatalf("event[0] should be stateChange, got %+v", events[0])
|
||||||
|
}
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("event[1] should be a warning publish, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r) // lead > delta => fire immediately
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(10 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("expected immediate warning publish, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
lead := 50 * time.Millisecond
|
||||||
|
w := newWatcher(lead, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
|
||||||
|
_ = w.Update(first)
|
||||||
|
|
||||||
|
// Replace with a far-future deadline before the warning fires.
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
second := time.Now().Add(time.Hour)
|
||||||
|
_ = w.Update(second)
|
||||||
|
|
||||||
|
// Wait past when first's warning would have fired.
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
|
||||||
|
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
|
||||||
|
t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
lead := 150 * time.Millisecond
|
||||||
|
w := newWatcher(lead, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
// Warning fires ~20ms in; the deadline itself stays 150ms away so the
|
||||||
|
// replacement below lands well before it.
|
||||||
|
first := time.Now().Add(170 * time.Millisecond)
|
||||||
|
_ = w.Update(first)
|
||||||
|
|
||||||
|
// Wait for stateChange + warning of the first cycle.
|
||||||
|
waitForEvents(t, r, 2)
|
||||||
|
|
||||||
|
// Simulate a successful extend: brand new deadline.
|
||||||
|
second := time.Now().Add(60 * time.Millisecond)
|
||||||
|
_ = w.Update(second)
|
||||||
|
|
||||||
|
// 4 events total: stateChange, warning (first), stateChange, warning (second).
|
||||||
|
events := waitForEvents(t, r, 4)
|
||||||
|
if events[2].kind != stateChange {
|
||||||
|
t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2])
|
||||||
|
}
|
||||||
|
if !events[3].isWarning() {
|
||||||
|
t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(2 * time.Hour)
|
||||||
|
_ = w.Update(d)
|
||||||
|
waitForEvents(t, r, 1)
|
||||||
|
|
||||||
|
_ = w.Update(time.Time{})
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if events[1].kind != stateChange {
|
||||||
|
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("Deadline should be zero after clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRejectsBeforeEpoch(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
good := time.Now().Add(time.Hour)
|
||||||
|
if err := w.Update(good); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := w.Update(time.Unix(-100, 0))
|
||||||
|
if !errors.Is(err, ErrDeadlineBeforeEpoch) {
|
||||||
|
t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
good := time.Now().Add(time.Hour)
|
||||||
|
if err := w.Update(good); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour))
|
||||||
|
if !errors.Is(err, ErrDeadlineTooFarFuture) {
|
||||||
|
t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRecentPastRecordedAsExpired(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(-1 * time.Hour)
|
||||||
|
if err := w.Update(d); err != nil {
|
||||||
|
t.Fatalf("recent-past Update should succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().Equal(d) {
|
||||||
|
t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d)
|
||||||
|
}
|
||||||
|
if got := r.deadline(); !got.Equal(d) {
|
||||||
|
t.Fatalf("recorder deadline = %v, want %v", got, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||||
|
t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAncientPastRejected(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
good := time.Now().Add(time.Hour)
|
||||||
|
if err := w.Update(good); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
// Drain the stateChange from the seed.
|
||||||
|
waitForEvents(t, r, 1)
|
||||||
|
|
||||||
|
err := w.Update(time.Now().Add(-31 * 24 * time.Hour))
|
||||||
|
if !errors.Is(err, ErrDeadlineInPast) {
|
||||||
|
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline())
|
||||||
|
}
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if events[1].kind != stateChange {
|
||||||
|
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseSilencesUpdates(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("Update after Close: want nil, got %v", err)
|
||||||
|
}
|
||||||
|
if got := r.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no events after Close, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher
|
||||||
|
// closes on every engine restart (network change, sleep/wake) while the
|
||||||
|
// SSO deadline stays valid across those, so Close must leave the
|
||||||
|
// server-scoped recorder's value in place. The client run loop clears the
|
||||||
|
// recorder when it exits for real.
|
||||||
|
func TestCloseKeepsRecorderDeadline(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r)
|
||||||
|
|
||||||
|
d := time.Now().Add(2 * time.Hour)
|
||||||
|
if err := w.Update(d); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
if got := r.deadline(); !got.Equal(d) {
|
||||||
|
t.Fatalf("recorder deadline after Update = %v, want %v", got, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
if got := r.deadline(); !got.Equal(d) {
|
||||||
|
t.Fatalf("recorder deadline after Close = %v, want %v", got, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric
|
||||||
|
// case: closing a watcher that never held a deadline must not emit a
|
||||||
|
// redundant clear (the recorder may legitimately hold a value written by
|
||||||
|
// some other path; the watcher only owns what it set).
|
||||||
|
func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r)
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
if got := r.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no events from Close on an empty watcher, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
// Warning fires at deadline-80ms, final at deadline-30ms.
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
// Expect stateChange + warning + final-warning.
|
||||||
|
events := waitForEvents(t, r, 3)
|
||||||
|
|
||||||
|
if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 stateChange, got %+v", events)
|
||||||
|
}
|
||||||
|
if countWhere(events, event.isWarning) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 warning publish, got %+v", events)
|
||||||
|
}
|
||||||
|
if countWhere(events, event.isFinalWarning) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 final-warning publish, got %+v", events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warning must precede final (same deadline, longer lead fires first).
|
||||||
|
var wIdx, fIdx int
|
||||||
|
for i, e := range events {
|
||||||
|
switch {
|
||||||
|
case e.isWarning():
|
||||||
|
wIdx = i
|
||||||
|
case e.isFinalWarning():
|
||||||
|
fIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wIdx > fIdx {
|
||||||
|
t.Fatalf("warning must publish before final-warning, got order %+v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissSuppressesFinalWarning(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
// Wait for the warning publish so we know we're inside the warning
|
||||||
|
// window, then dismiss before the final timer would fire.
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if countWhere(r.snapshot(), event.isWarning) >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if countWhere(r.snapshot(), event.isWarning) < 1 {
|
||||||
|
t.Fatalf("warning did not publish in time, events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Dismiss()
|
||||||
|
|
||||||
|
// Now wait past when the final would have fired.
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
|
||||||
|
if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 {
|
||||||
|
t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissResetByNewDeadline(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
first := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(first)
|
||||||
|
|
||||||
|
// Dismiss against the first deadline.
|
||||||
|
w.Dismiss()
|
||||||
|
|
||||||
|
// Replace with a fresh deadline before the first's timers complete.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
second := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(second)
|
||||||
|
|
||||||
|
// The second cycle must publish a final-warning (the dismiss state
|
||||||
|
// did not carry over).
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if countWhere(r.snapshot(), event.isFinalWarning) < 1 {
|
||||||
|
t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
|
||||||
|
w.Dismiss()
|
||||||
|
|
||||||
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
// Final warning should still publish — Dismiss only acts on the current
|
||||||
|
// deadline, and there was none at the time of the call.
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
@@ -20,14 +20,26 @@ func randomBytesInHex(count int) (string, error) {
|
|||||||
return hex.EncodeToString(buf), nil
|
return hex.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidAccessToken is a simple validation of the access token
|
// validateTokenAudience checks that the token is a well-formed JWT whose
|
||||||
func isValidAccessToken(token string, audience string) error {
|
// audience claim matches the expected audience.
|
||||||
|
//
|
||||||
|
// It does NOT verify the token's cryptographic signature and therefore must not
|
||||||
|
// be treated as an authenticity check. The token is obtained by the client
|
||||||
|
// directly from the IdP token endpoint over TLS, and its signature is verified
|
||||||
|
// server-side by the management server against the IdP's JWKS
|
||||||
|
// (see shared/auth/jwt/validator.go). This function is only a client-side
|
||||||
|
// sanity check that the returned token targets the expected audience.
|
||||||
|
func validateTokenAudience(token string, audience string) error {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return fmt.Errorf("token received is empty")
|
return fmt.Errorf("token received is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
encodedClaims := strings.Split(token, ".")[1]
|
parts := strings.Split(token, ".")
|
||||||
claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims)
|
if len(parts) != 3 {
|
||||||
|
return fmt.Errorf("token is not a well-formed JWT")
|
||||||
|
}
|
||||||
|
|
||||||
|
claimsString, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
108
client/internal/auth/util_test.go
Normal file
108
client/internal/auth/util_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeJWT builds an unsigned JWT-shaped string (header.payload.signature) with
|
||||||
|
// the given claims payload. The signature part is arbitrary because
|
||||||
|
// validateTokenAudience intentionally does not verify it.
|
||||||
|
func makeJWT(t *testing.T, claims map[string]interface{}) string {
|
||||||
|
t.Helper()
|
||||||
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||||
|
payloadBytes, err := json.Marshal(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal claims: %v", err)
|
||||||
|
}
|
||||||
|
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
||||||
|
return header + "." + payload + ".unverified-signature"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTokenAudience(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
audience string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty token",
|
||||||
|
token: "",
|
||||||
|
audience: "netbird",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not a JWT - no dots",
|
||||||
|
token: "notajwt",
|
||||||
|
audience: "netbird",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not a JWT - two parts only",
|
||||||
|
token: "header.payload",
|
||||||
|
audience: "netbird",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching string audience",
|
||||||
|
token: makeJWT(t, map[string]interface{}{"aud": "netbird"}),
|
||||||
|
audience: "netbird",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mismatching string audience",
|
||||||
|
token: makeJWT(t, map[string]interface{}{"aud": "other"}),
|
||||||
|
audience: "netbird",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching audience in array",
|
||||||
|
token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"other", "netbird"}}),
|
||||||
|
audience: "netbird",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mismatching audience array",
|
||||||
|
token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"a", "b"}}),
|
||||||
|
audience: "netbird",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing audience claim",
|
||||||
|
token: makeJWT(t, map[string]interface{}{"sub": "user"}),
|
||||||
|
audience: "netbird",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid base64 payload",
|
||||||
|
token: "header.!!!not-base64!!!.sig",
|
||||||
|
audience: "netbird",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateTokenAudience(tc.token, tc.audience)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateTokenAudienceNoPanic guards the regression where a non-empty
|
||||||
|
// token without the JWT dot structure caused an index-out-of-range panic.
|
||||||
|
func TestValidateTokenAudienceNoPanic(t *testing.T) {
|
||||||
|
inputs := []string{"a", ".", "a.", "aaaa", "no-dots-here"}
|
||||||
|
for _, in := range inputs {
|
||||||
|
if err := validateTokenAudience(in, "netbird"); err == nil {
|
||||||
|
t.Fatalf("expected error for malformed token %q, got nil", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ const (
|
|||||||
// - Handling connection establishment based on peer signaling
|
// - Handling connection establishment based on peer signaling
|
||||||
//
|
//
|
||||||
// The implementation is not thread-safe; it is protected by engine.syncMsgMux.
|
// The implementation is not thread-safe; it is protected by engine.syncMsgMux.
|
||||||
|
// The only exception is ActivatePeer, which is safe for concurrent use so the
|
||||||
|
// DNS warm-up path can call it without contending on the engine mutex.
|
||||||
type ConnMgr struct {
|
type ConnMgr struct {
|
||||||
peerStore *peerstore.Store
|
peerStore *peerstore.Store
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
@@ -42,12 +44,26 @@ type ConnMgr struct {
|
|||||||
rosenpassEnabled bool
|
rosenpassEnabled bool
|
||||||
|
|
||||||
lazyConnMgr *manager.Manager
|
lazyConnMgr *manager.Manager
|
||||||
|
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
|
||||||
|
// engine loop (ActivatePeer). Writers hold it in addition to
|
||||||
|
// engine.syncMsgMux; all other reads stay under engine.syncMsgMux only.
|
||||||
|
lazyConnMgrMu sync.RWMutex
|
||||||
|
|
||||||
|
// reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is
|
||||||
|
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
|
||||||
|
reconcileRoutedIPs func(peerKey string) error
|
||||||
|
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
lazyCtx context.Context
|
lazyCtx context.Context
|
||||||
lazyCtxCancel context.CancelFunc
|
lazyCtxCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when
|
||||||
|
// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts.
|
||||||
|
func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
|
||||||
|
e.reconcileRoutedIPs = fn
|
||||||
|
}
|
||||||
|
|
||||||
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
|
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
|
||||||
e := &ConnMgr{
|
e := &ConnMgr{
|
||||||
peerStore: peerStore,
|
peerStore: peerStore,
|
||||||
@@ -109,7 +125,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Warnf("lazy connection manager is enabled by management feature flag")
|
log.Infof("lazy connection manager is enabled by the management feature flag")
|
||||||
e.initLazyManager(ctx)
|
e.initLazyManager(ctx)
|
||||||
e.statusRecorder.UpdateLazyConnection(true)
|
e.statusRecorder.UpdateLazyConnection(true)
|
||||||
return e.addPeersToLazyConnManager()
|
return e.addPeersToLazyConnManager()
|
||||||
@@ -238,12 +254,20 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) {
|
|||||||
conn.Log.Infof("removed peer from lazy conn manager")
|
conn.Log.Infof("removed peer from lazy conn manager")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is
|
||||||
|
// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu
|
||||||
|
// and the manager itself is internally synchronized, so callers outside the
|
||||||
|
// engine loop (DNS warm-up) do not need engine.syncMsgMux.
|
||||||
func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) {
|
func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) {
|
||||||
if !e.isStartedWithLazyMgr() {
|
e.lazyConnMgrMu.RLock()
|
||||||
|
lazyConnMgr := e.lazyConnMgr
|
||||||
|
started := lazyConnMgr != nil && e.lazyCtxCancel != nil
|
||||||
|
e.lazyConnMgrMu.RUnlock()
|
||||||
|
if !started {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
||||||
if err := conn.Open(ctx); err != nil {
|
if err := conn.Open(ctx); err != nil {
|
||||||
conn.Log.Errorf("failed to open connection: %v", err)
|
conn.Log.Errorf("failed to open connection: %v", err)
|
||||||
}
|
}
|
||||||
@@ -268,16 +292,22 @@ func (e *ConnMgr) Close() {
|
|||||||
|
|
||||||
e.lazyCtxCancel()
|
e.lazyCtxCancel()
|
||||||
e.wg.Wait()
|
e.wg.Wait()
|
||||||
|
|
||||||
|
e.lazyConnMgrMu.Lock()
|
||||||
e.lazyConnMgr = nil
|
e.lazyConnMgr = nil
|
||||||
|
e.lazyConnMgrMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
||||||
cfg := manager.Config{
|
cfg := manager.Config{
|
||||||
InactivityThreshold: inactivityThresholdEnv(),
|
InactivityThreshold: inactivityThresholdEnv(),
|
||||||
|
ReconcileAllowedIPs: e.reconcileRoutedIPs,
|
||||||
}
|
}
|
||||||
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
|
|
||||||
|
|
||||||
|
e.lazyConnMgrMu.Lock()
|
||||||
|
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
|
||||||
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
|
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
|
||||||
|
e.lazyConnMgrMu.Unlock()
|
||||||
|
|
||||||
e.wg.Add(1)
|
e.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -316,7 +346,10 @@ func (e *ConnMgr) closeManager(ctx context.Context) {
|
|||||||
|
|
||||||
e.lazyCtxCancel()
|
e.lazyCtxCancel()
|
||||||
e.wg.Wait()
|
e.wg.Wait()
|
||||||
|
|
||||||
|
e.lazyConnMgrMu.Lock()
|
||||||
e.lazyConnMgr = nil
|
e.lazyConnMgr = nil
|
||||||
|
e.lazyConnMgrMu.Unlock()
|
||||||
|
|
||||||
for _, peerID := range e.peerStore.PeersPubKey() {
|
for _, peerID := range e.peerStore.PeersPubKey() {
|
||||||
e.peerStore.PeerConnOpen(ctx, peerID)
|
e.peerStore.PeerConnOpen(ctx, peerID)
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||||
|
"github.com/netbirdio/netbird/monotime"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveLazyForce(t *testing.T) {
|
func TestResolveLazyForce(t *testing.T) {
|
||||||
@@ -38,3 +49,58 @@ func TestResolveLazyForce(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mockLazyWGIface struct{}
|
||||||
|
|
||||||
|
func (mockLazyWGIface) RemovePeer(string) error { return nil }
|
||||||
|
func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (mockLazyWGIface) IsUserspaceBind() bool { return false }
|
||||||
|
func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} }
|
||||||
|
func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil }
|
||||||
|
func (mockLazyWGIface) MTU() uint16 { return 1280 }
|
||||||
|
|
||||||
|
// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from
|
||||||
|
// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle,
|
||||||
|
// which stays on the engine loop. Run with -race: it fails if ActivatePeer
|
||||||
|
// still requires engine.syncMsgMux for safety.
|
||||||
|
func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) {
|
||||||
|
t.Setenv(lazyconn.EnvLazyConn, "on")
|
||||||
|
|
||||||
|
status := peer.NewRecorder("https://mgm")
|
||||||
|
store := peerstore.NewConnStore()
|
||||||
|
connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{})
|
||||||
|
|
||||||
|
conn := newTestPeerConn(t, "peerA")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
connMgr.Start(ctx)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 4 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
connMgr.ActivatePeer(ctx, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the activators spin against the started manager, then tear it down
|
||||||
|
// underneath them and let them spin against the stopped manager.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
connMgr.Close()
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
close(done)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|||||||
@@ -257,7 +257,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
log.Errorf("failed to clean up temporary installer file: %v", err)
|
log.Errorf("failed to clean up temporary installer file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer c.statusRecorder.ClientStop()
|
defer func() {
|
||||||
|
c.statusRecorder.SetSessionExpiresAt(time.Time{})
|
||||||
|
c.statusRecorder.ClientStop()
|
||||||
|
}()
|
||||||
operation := func() error {
|
operation := func() error {
|
||||||
// if context cancelled we not start new backoff cycle
|
// if context cancelled we not start new backoff cycle
|
||||||
if c.ctx.Err() != nil {
|
if c.ctx.Err() != nil {
|
||||||
@@ -277,6 +280,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)
|
||||||
@@ -415,6 +427,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)
|
||||||
|
|
||||||
@@ -451,6 +467,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)
|
||||||
@@ -601,6 +621,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
|||||||
BlockLANAccess: config.BlockLANAccess,
|
BlockLANAccess: config.BlockLANAccess,
|
||||||
BlockInbound: config.BlockInbound,
|
BlockInbound: config.BlockInbound,
|
||||||
DisableIPv6: config.DisableIPv6,
|
DisableIPv6: config.DisableIPv6,
|
||||||
|
SyncMessageVersion: config.SyncMessageVersion,
|
||||||
|
|
||||||
LazyConnection: lazyconn.ParseState(config.LazyConnection),
|
LazyConnection: lazyconn.ParseState(config.LazyConnection),
|
||||||
|
|
||||||
@@ -676,6 +697,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
|||||||
config.BlockLANAccess,
|
config.BlockLANAccess,
|
||||||
config.BlockInbound,
|
config.BlockInbound,
|
||||||
config.DisableIPv6,
|
config.DisableIPv6,
|
||||||
|
config.SyncMessageVersion,
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -466,7 +480,6 @@ func (g *BundleGenerator) addStatus() error {
|
|||||||
|
|
||||||
fullStatus := g.statusRecorder.GetFullStatus()
|
fullStatus := g.statusRecorder.GetFullStatus()
|
||||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||||
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
|
|
||||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||||
Anonymize: g.anonymize,
|
Anonymize: g.anonymize,
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
@@ -663,6 +676,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
|||||||
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
|
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
|
||||||
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
|
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
|
||||||
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
|
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
|
||||||
|
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
|
||||||
|
|
||||||
if g.internalConfig.DisableNotifications != nil {
|
if g.internalConfig.DisableNotifications != nil {
|
||||||
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))
|
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))
|
||||||
@@ -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()))
|
||||||
|
|||||||
@@ -887,6 +887,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
|||||||
ClientCertKeyPath: "/tmp/key",
|
ClientCertKeyPath: "/tmp/key",
|
||||||
LazyConnection: "on",
|
LazyConnection: "on",
|
||||||
MTU: 1280,
|
MTU: 1280,
|
||||||
|
DisableIPv6: true,
|
||||||
|
SyncMessageVersion: func(v int) *int { return &v }(1),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, anonymize := range []bool{false, true} {
|
for _, anonymize := range []bool{false, true} {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -36,7 +37,43 @@ type resolver interface {
|
|||||||
// record is left alone (it points at something outside our mesh, e.g.
|
// record is left alone (it points at something outside our mesh, e.g.
|
||||||
// a non-peer upstream).
|
// a non-peer upstream).
|
||||||
type PeerConnectivity interface {
|
type PeerConnectivity interface {
|
||||||
IsConnectedByIP(ip string) (known, connected bool)
|
IsConnectedByIP(ip netip.Addr) (known, connected bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerActivator wakes lazy-connection peers on demand. The local resolver calls
|
||||||
|
// it with the tunnel IPs an answer points at, so a peer that is idle (lazily
|
||||||
|
// disconnected) starts connecting at DNS-resolution time rather than racing the
|
||||||
|
// client's first request packet. nil disables warm-up.
|
||||||
|
type PeerActivator interface {
|
||||||
|
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks
|
||||||
|
// until one is connected or ctx (a short per-query budget) expires. It is a
|
||||||
|
// fast no-op for unknown or already-connected addresses.
|
||||||
|
ActivatePeersByIP(ctx context.Context, addrs []netip.Addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultLazyWarmupTimeout = 2 * time.Second
|
||||||
|
envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a
|
||||||
|
// lazy-connection peer a DNS answer points at. Tunable via
|
||||||
|
// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time.
|
||||||
|
func lazyWarmupTimeoutFromEnv() time.Duration {
|
||||||
|
v := os.Getenv(envLazyWarmupTimeout)
|
||||||
|
if v == "" {
|
||||||
|
return defaultLazyWarmupTimeout
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err)
|
||||||
|
return defaultLazyWarmupTimeout
|
||||||
|
}
|
||||||
|
if d <= 0 {
|
||||||
|
log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout)
|
||||||
|
return defaultLazyWarmupTimeout
|
||||||
|
}
|
||||||
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
@@ -51,6 +88,12 @@ type Resolver struct {
|
|||||||
// filter and preserves the legacy "return whatever is registered"
|
// filter and preserves the legacy "return whatever is registered"
|
||||||
// behaviour for callers that never wire a status source.
|
// behaviour for callers that never wire a status source.
|
||||||
peerConn PeerConnectivity
|
peerConn PeerConnectivity
|
||||||
|
// peerActivator, when non-nil, is called at resolution time to warm the
|
||||||
|
// lazy connection to the peer(s) an answer points at. nil disables warm-up.
|
||||||
|
peerActivator PeerActivator
|
||||||
|
// warmupTimeout is the per-query budget for the lazy-connection warm-up
|
||||||
|
// wait, resolved from the environment once at construction time.
|
||||||
|
warmupTimeout time.Duration
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -59,11 +102,12 @@ type Resolver struct {
|
|||||||
func NewResolver() *Resolver {
|
func NewResolver() *Resolver {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &Resolver{
|
return &Resolver{
|
||||||
records: make(map[dns.Question][]dns.RR),
|
records: make(map[dns.Question][]dns.RR),
|
||||||
domains: make(map[domain.Domain]struct{}),
|
domains: make(map[domain.Domain]struct{}),
|
||||||
zones: make(map[domain.Domain]bool),
|
zones: make(map[domain.Domain]bool),
|
||||||
ctx: ctx,
|
warmupTimeout: lazyWarmupTimeoutFromEnv(),
|
||||||
cancel: cancel,
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +120,14 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
|
|||||||
d.peerConn = p
|
d.peerConn = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to
|
||||||
|
// disable. Safe to call multiple times; the latest value wins.
|
||||||
|
func (d *Resolver) SetPeerActivator(a PeerActivator) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.peerActivator = a
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Resolver) MatchSubdomains() bool {
|
func (d *Resolver) MatchSubdomains() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -122,6 +174,9 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
|||||||
replyMessage.RecursionAvailable = true
|
replyMessage.RecursionAvailable = true
|
||||||
|
|
||||||
result := d.lookupRecords(logger, question)
|
result := d.lookupRecords(logger, question)
|
||||||
|
// Warm before filtering: activation flips a lazily-idle target to connected,
|
||||||
|
// which then lets it survive the disconnected-peer filter below.
|
||||||
|
d.warmLazyPeers(question, result.records)
|
||||||
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
|
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
|
||||||
replyMessage.Authoritative = !result.hasExternalData
|
replyMessage.Authoritative = !result.hasExternalData
|
||||||
replyMessage.Answer = result.records
|
replyMessage.Answer = result.records
|
||||||
@@ -495,8 +550,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
|||||||
kept := make([]dns.RR, 0, len(records))
|
kept := make([]dns.RR, 0, len(records))
|
||||||
var dropped int
|
var dropped int
|
||||||
for _, rr := range records {
|
for _, rr := range records {
|
||||||
ip := extractRecordIP(rr)
|
ip, ok := extractRecordAddr(rr)
|
||||||
if ip == "" {
|
if !ok {
|
||||||
kept = append(kept, rr)
|
kept = append(kept, rr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -518,22 +573,57 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
|||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
|
// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved
|
||||||
// an A or AAAA record, or "" for any other record type.
|
// answer points at and waits briefly for one to connect, so the caller's first
|
||||||
func extractRecordIP(rr dns.RR) string {
|
// request doesn't race the connection establishment. Warm-up is scoped to
|
||||||
|
// match-only (non-authoritative) zones — the synthesized private-service zones
|
||||||
|
// and user-created zones whose records point at specific peers. The account's
|
||||||
|
// peer zone is authoritative, so plain peer-name lookups never trigger warm-up;
|
||||||
|
// otherwise resolving any peer's name would wake its idle connection, defeating
|
||||||
|
// laziness mesh-wide. No-op when no activator is wired (lazy connections
|
||||||
|
// disabled) or the answer carries no peer IPs.
|
||||||
|
func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) {
|
||||||
|
if len(records) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.mu.RLock()
|
||||||
|
activator := d.peerActivator
|
||||||
|
var nonAuth, found bool
|
||||||
|
if activator != nil {
|
||||||
|
nonAuth, found = d.findZone(question.Name)
|
||||||
|
}
|
||||||
|
d.mu.RUnlock()
|
||||||
|
if activator == nil || !found || !nonAuth {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var addrs []netip.Addr
|
||||||
|
for _, rr := range records {
|
||||||
|
if addr, ok := extractRecordAddr(rr); ok {
|
||||||
|
addrs = append(addrs, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
activator.ActivatePeersByIP(ctx, addrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRecordAddr returns the IP address carried by an A or AAAA record.
|
||||||
|
// ok is false for any other record type or a record with no address.
|
||||||
|
func extractRecordAddr(rr dns.RR) (netip.Addr, bool) {
|
||||||
switch r := rr.(type) {
|
switch r := rr.(type) {
|
||||||
case *dns.A:
|
case *dns.A:
|
||||||
if r.A == nil {
|
addr, ok := netip.AddrFromSlice(r.A)
|
||||||
return ""
|
return addr.Unmap(), ok
|
||||||
}
|
|
||||||
return r.A.String()
|
|
||||||
case *dns.AAAA:
|
case *dns.AAAA:
|
||||||
if r.AAAA == nil {
|
addr, ok := netip.AddrFromSlice(r.AAAA)
|
||||||
return ""
|
return addr.Unmap(), ok
|
||||||
}
|
|
||||||
return r.AAAA.String()
|
|
||||||
}
|
}
|
||||||
return ""
|
return netip.Addr{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update replaces all zones and their records
|
// Update replaces all zones and their records
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ type mockPeerConnectivity struct {
|
|||||||
byIP map[string]struct{ known, connected bool }
|
byIP map[string]struct{ known, connected bool }
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
|
||||||
v, ok := m.byIP[ip]
|
v, ok := m.byIP[ip.String()]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|||||||
204
client/internal/dns/local/warmup_test.go
Normal file
204
client/internal/dns/local/warmup_test.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/dns/test"
|
||||||
|
nbdns "github.com/netbirdio/netbird/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingActivator records the addresses it was asked to warm and returns
|
||||||
|
// immediately, so ServeDNS is not blocked by the test.
|
||||||
|
type recordingActivator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
called bool
|
||||||
|
addrs []netip.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.called = true
|
||||||
|
r.addrs = append(r.addrs, addrs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg {
|
||||||
|
t.Helper()
|
||||||
|
var resp *dns.Msg
|
||||||
|
w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}
|
||||||
|
resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA))
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceZone registers rec in a match-only (non-authoritative) zone, the shape
|
||||||
|
// the synthesized private-service zones arrive in.
|
||||||
|
func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) {
|
||||||
|
t.Helper()
|
||||||
|
resolver.Update([]nbdns.CustomZone{{
|
||||||
|
Domain: zone,
|
||||||
|
Records: records,
|
||||||
|
NonAuthoritative: true,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) {
|
||||||
|
// Warm-up fires only for multi-record answers (the HA / round-robin shape of
|
||||||
|
// the synthesized private-service zones), so register two peer targets.
|
||||||
|
const name = "svc.proxy.netbird.cloud."
|
||||||
|
recs := []nbdns.SimpleRecord{
|
||||||
|
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"},
|
||||||
|
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"},
|
||||||
|
}
|
||||||
|
resolver := NewResolver()
|
||||||
|
serviceZone(t, resolver, "proxy.netbird.cloud", recs...)
|
||||||
|
|
||||||
|
act := &recordingActivator{}
|
||||||
|
resolver.SetPeerActivator(act)
|
||||||
|
|
||||||
|
resp := serveA(t, resolver, name)
|
||||||
|
require.NotNil(t, resp, "resolver must answer")
|
||||||
|
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
|
||||||
|
|
||||||
|
act.mu.Lock()
|
||||||
|
defer act.mu.Unlock()
|
||||||
|
assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer")
|
||||||
|
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP")
|
||||||
|
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) {
|
||||||
|
// A single-record answer does not trigger warm-up; the resolver only warms
|
||||||
|
// multi-record answers.
|
||||||
|
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
|
||||||
|
resolver := NewResolver()
|
||||||
|
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
|
||||||
|
|
||||||
|
act := &recordingActivator{}
|
||||||
|
resolver.SetPeerActivator(act)
|
||||||
|
|
||||||
|
resp := serveA(t, resolver, rec.Name)
|
||||||
|
require.NotNil(t, resp, "resolver must answer")
|
||||||
|
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||||
|
|
||||||
|
act.mu.Lock()
|
||||||
|
defer act.mu.Unlock()
|
||||||
|
assert.False(t, act.called, "activator must not be invoked for a single-record answer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) {
|
||||||
|
// With no activator wired the resolver behaves exactly as before.
|
||||||
|
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
|
||||||
|
resolver := NewResolver()
|
||||||
|
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
|
||||||
|
|
||||||
|
resp := serveA(t, resolver, rec.Name)
|
||||||
|
require.NotNil(t, resp, "resolver must still answer without an activator")
|
||||||
|
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) {
|
||||||
|
// A query that resolves to nothing must not invoke the activator (no IPs).
|
||||||
|
resolver := NewResolver()
|
||||||
|
serviceZone(t, resolver, "proxy.netbird.cloud",
|
||||||
|
nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"})
|
||||||
|
|
||||||
|
act := &recordingActivator{}
|
||||||
|
resolver.SetPeerActivator(act)
|
||||||
|
|
||||||
|
serveA(t, resolver, "absent.proxy.netbird.cloud.")
|
||||||
|
|
||||||
|
act.mu.Lock()
|
||||||
|
defer act.mu.Unlock()
|
||||||
|
assert.False(t, act.called, "activator must not be invoked when there is no answer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) {
|
||||||
|
// The account's peer zone is authoritative; resolving a peer's name there
|
||||||
|
// must not wake its lazy connection — warm-up is scoped to match-only
|
||||||
|
// (non-authoritative) zones such as the synthesized private-service zones.
|
||||||
|
// Use a multi-record answer so the authoritative-zone scoping is the only
|
||||||
|
// reason warm-up is skipped, not the single-record guard.
|
||||||
|
const name = "peer.netbird.cloud."
|
||||||
|
recs := []nbdns.SimpleRecord{
|
||||||
|
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"},
|
||||||
|
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"},
|
||||||
|
}
|
||||||
|
resolver := NewResolver()
|
||||||
|
resolver.Update([]nbdns.CustomZone{{
|
||||||
|
Domain: "netbird.cloud",
|
||||||
|
Records: recs,
|
||||||
|
}})
|
||||||
|
|
||||||
|
act := &recordingActivator{}
|
||||||
|
resolver.SetPeerActivator(act)
|
||||||
|
|
||||||
|
resp := serveA(t, resolver, name)
|
||||||
|
require.NotNil(t, resp, "resolver must answer")
|
||||||
|
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
|
||||||
|
|
||||||
|
act.mu.Lock()
|
||||||
|
defer act.mu.Unlock()
|
||||||
|
assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLazyWarmupTimeoutFromEnv(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
envSet bool
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{name: "unset uses default", want: defaultLazyWarmupTimeout},
|
||||||
|
{name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second},
|
||||||
|
{name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout},
|
||||||
|
{name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout},
|
||||||
|
{name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.envSet {
|
||||||
|
t.Setenv(envLazyWarmupTimeout, tt.value)
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv())
|
||||||
|
assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRecordAddr(t *testing.T) {
|
||||||
|
t.Run("A record yields unmapped v4", func(t *testing.T) {
|
||||||
|
// net.ParseIP returns the 16-byte v4-in-v6 form, the same shape
|
||||||
|
// miekg/dns stores after parsing an A record; the extracted address
|
||||||
|
// must compare equal to a plain v4 netip.Addr.
|
||||||
|
addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")})
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, addr.Is4())
|
||||||
|
assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AAAA record yields v6", func(t *testing.T) {
|
||||||
|
addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")})
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, netip.MustParseAddr("fd00::1"), addr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A record without address", func(t *testing.T) {
|
||||||
|
_, ok := extractRecordAddr(&dns.A{})
|
||||||
|
assert.False(t, ok)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-address record", func(t *testing.T) {
|
||||||
|
_, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."})
|
||||||
|
assert.False(t, ok)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/dns/local"
|
||||||
nbdns "github.com/netbirdio/netbird/dns"
|
nbdns "github.com/netbirdio/netbird/dns"
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
@@ -92,6 +93,11 @@ func (m *MockServer) SetFirewall(Firewall) {
|
|||||||
// Mock implementation - no-op
|
// Mock implementation - no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPeerActivator mock implementation of SetPeerActivator from Server interface
|
||||||
|
func (m *MockServer) SetPeerActivator(local.PeerActivator) {
|
||||||
|
// Mock implementation - no-op
|
||||||
|
}
|
||||||
|
|
||||||
// BeginBatch mock implementation of BeginBatch from Server interface
|
// BeginBatch mock implementation of BeginBatch from Server interface
|
||||||
func (m *MockServer) BeginBatch() {
|
func (m *MockServer) BeginBatch() {
|
||||||
// Mock implementation - no-op
|
// Mock implementation - no-op
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ type Server interface {
|
|||||||
PopulateManagementDomain(mgmtURL *url.URL) error
|
PopulateManagementDomain(mgmtURL *url.URL) error
|
||||||
SetRouteSources(selected, active func() route.HAMap)
|
SetRouteSources(selected, active func() route.HAMap)
|
||||||
SetFirewall(Firewall)
|
SetFirewall(Firewall)
|
||||||
|
SetPeerActivator(local.PeerActivator)
|
||||||
}
|
}
|
||||||
|
|
||||||
type nsGroupsByDomain struct {
|
type nsGroupsByDomain struct {
|
||||||
@@ -491,6 +492,13 @@ func (s *DefaultServer) SetFirewall(fw Firewall) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local
|
||||||
|
// resolver. Injected after the connection manager exists (it does not at
|
||||||
|
// DNS-server construction time). Pass nil to disable.
|
||||||
|
func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) {
|
||||||
|
s.localResolver.SetPeerActivator(a)
|
||||||
|
}
|
||||||
|
|
||||||
// Stop stops the server
|
// Stop stops the server
|
||||||
func (s *DefaultServer) Stop() {
|
func (s *DefaultServer) Stop() {
|
||||||
s.ctxCancel()
|
s.ctxCancel()
|
||||||
@@ -1435,11 +1443,11 @@ type localPeerConnectivity struct {
|
|||||||
|
|
||||||
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
|
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
|
||||||
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
|
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
|
||||||
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
|
||||||
if l.status == nil {
|
if l.status == nil {
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
state, ok := l.status.PeerStateByIP(ip)
|
state, ok := l.status.PeerStateByIP(ip.String())
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,18 +292,16 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) {
|
|||||||
return customPort, nil
|
return customPort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0"))
|
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
||||||
probeListener, err := net.ListenUDP("udp", udpAddr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("failed to bind random port for DNS: %s", err)
|
log.Debugf("failed to bind random port for DNS: %s", err)
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect
|
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
|
||||||
err = probeListener.Close()
|
if err = probeListener.Close(); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Debugf("failed to free up DNS port: %s", err)
|
log.Debugf("failed to free up DNS port: %s", err)
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return addrPort.Port(), nil
|
return port, nil
|
||||||
}
|
}
|
||||||
|
|||||||
76
client/internal/dns_peer_activator.go
Normal file
76
client/internal/dns_peer_activator.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dnsActivationPollInterval = 50 * time.Millisecond
|
||||||
|
|
||||||
|
// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It
|
||||||
|
// implements dns/local.PeerActivator. DNS queries run on their own goroutines,
|
||||||
|
// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer,
|
||||||
|
// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux,
|
||||||
|
// keeping DNS resolution from contending with network-map processing.
|
||||||
|
type dnsPeerActivator struct {
|
||||||
|
connMgr *ConnMgr
|
||||||
|
peerStore *peerstore.Store
|
||||||
|
status *peer.Status
|
||||||
|
// ctx is the engine's long-lived context. The connection dial is tied to it
|
||||||
|
// (not the per-query DNS wait budget) so a handshake that outlasts the wait
|
||||||
|
// still completes in the background rather than being cancelled at the deadline.
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits
|
||||||
|
// until one is connected or ctx (the per-query DNS wait budget) expires.
|
||||||
|
// Activation itself is tied to the engine's long-lived context so the dial
|
||||||
|
// survives a wait that times out. Unknown or already-connected addresses are
|
||||||
|
// skipped, so the steady-state (warm) path adds no latency.
|
||||||
|
func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) {
|
||||||
|
if a == nil || a.connMgr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending []string
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ip := addr.String()
|
||||||
|
st, ok := a.status.PeerStateByIP(ip)
|
||||||
|
if !ok || st.ConnStatus == peer.StatusConnected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conn, ok := a.peerStore.PeerConn(st.PubKey)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.connMgr.ActivatePeer(a.ctx, conn)
|
||||||
|
pending = append(pending, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.waitConnected(ctx, pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitConnected blocks until any of ips reports a connected peer or ctx expires.
|
||||||
|
func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) {
|
||||||
|
ticker := time.NewTicker(dnsActivationPollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
for _, ip := range ips {
|
||||||
|
if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
client/internal/dns_peer_activator_test.go
Normal file
129
client/internal/dns_peer_activator_test.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestPeerConn(t *testing.T, key string) *peer.Conn {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := peer.NewConn(peer.ConnConfig{
|
||||||
|
Key: key,
|
||||||
|
LocalKey: "local",
|
||||||
|
WgConfig: peer.WgConfig{
|
||||||
|
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||||
|
},
|
||||||
|
}, peer.ServiceDependencies{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) {
|
||||||
|
t.Helper()
|
||||||
|
status := peer.NewRecorder("https://mgm")
|
||||||
|
store := peerstore.NewConnStore()
|
||||||
|
// ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a
|
||||||
|
// no-op — these tests exercise the activator's skip/wait logic.
|
||||||
|
connMgr := NewConnMgr(&EngineConfig{}, status, store, nil)
|
||||||
|
return &dnsPeerActivator{
|
||||||
|
connMgr: connMgr,
|
||||||
|
peerStore: store,
|
||||||
|
status: status,
|
||||||
|
ctx: context.Background(),
|
||||||
|
}, status, store
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDNSPeerActivator_NilSafe(t *testing.T) {
|
||||||
|
var a *dnsPeerActivator
|
||||||
|
a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state
|
||||||
|
// (warm) path adds no latency: already-connected and unknown addresses never
|
||||||
|
// enter the wait loop.
|
||||||
|
func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) {
|
||||||
|
a, status, store := newTestDNSPeerActivator(t)
|
||||||
|
|
||||||
|
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1"))
|
||||||
|
require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}))
|
||||||
|
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
a.ActivatePeersByIP(ctx, []netip.Addr{
|
||||||
|
netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped
|
||||||
|
netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped
|
||||||
|
netip.MustParseAddr("100.64.0.99"), // unknown -> skipped
|
||||||
|
})
|
||||||
|
require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop
|
||||||
|
// returns as soon as a pending peer reports connected, well before the
|
||||||
|
// per-query budget expires.
|
||||||
|
func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) {
|
||||||
|
a, status, store := newTestDNSPeerActivator(t)
|
||||||
|
|
||||||
|
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||||
|
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
_ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer")
|
||||||
|
require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that
|
||||||
|
// never connects releases the DNS response at the per-query budget instead of
|
||||||
|
// blocking it indefinitely.
|
||||||
|
func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) {
|
||||||
|
a, status, store := newTestDNSPeerActivator(t)
|
||||||
|
|
||||||
|
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||||
|
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer")
|
||||||
|
require.Less(t, elapsed, 5*time.Second, "must not block past the budget")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer
|
||||||
|
// with no connection object in the store is not waited on: there is nothing to
|
||||||
|
// activate, so waiting could only ever time out.
|
||||||
|
func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) {
|
||||||
|
a, status, _ := newTestDNSPeerActivator(t)
|
||||||
|
|
||||||
|
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||||
|
require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on")
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -52,11 +52,14 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
|
|||||||
|
|
||||||
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
|
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
|
||||||
udp->dest = dns_port;
|
udp->dest = dns_port;
|
||||||
|
// Clear the now-stale checksum; zero means "not computed" for IPv4.
|
||||||
|
udp->check = 0;
|
||||||
return XDP_PASS;
|
return XDP_PASS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (udp->source == dns_port && ip->saddr == dns_ip) {
|
if (udp->source == dns_port && ip->saddr == dns_ip) {
|
||||||
udp->source = GENERAL_DNS_PORT;
|
udp->source = GENERAL_DNS_PORT;
|
||||||
|
udp->check = 0;
|
||||||
return XDP_PASS;
|
return XDP_PASS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
|
|||||||
__be16 new_dst_port = htons(proxy_port);
|
__be16 new_dst_port = htons(proxy_port);
|
||||||
udp->dest = new_dst_port;
|
udp->dest = new_dst_port;
|
||||||
udp->source = new_src_port;
|
udp->source = new_src_port;
|
||||||
|
|
||||||
|
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
|
||||||
|
// and the payload is already integrity-protected, so clear the checksum (a
|
||||||
|
// zero UDP checksum means "not computed" for IPv4) rather than leave a
|
||||||
|
// stale value the kernel would drop as UDP_CSUM.
|
||||||
|
udp->check = 0;
|
||||||
return XDP_PASS;
|
return XDP_PASS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ import (
|
|||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
|
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
|
||||||
|
nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
types "github.com/netbirdio/netbird/shared/management/types"
|
||||||
"github.com/netbirdio/netbird/shared/netiputil"
|
"github.com/netbirdio/netbird/shared/netiputil"
|
||||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||||
@@ -147,6 +150,7 @@ type EngineConfig struct {
|
|||||||
BlockLANAccess bool
|
BlockLANAccess bool
|
||||||
BlockInbound bool
|
BlockInbound bool
|
||||||
DisableIPv6 bool
|
DisableIPv6 bool
|
||||||
|
SyncMessageVersion *int
|
||||||
|
|
||||||
// LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to
|
// LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to
|
||||||
// the env var and management feature flag.
|
// the env var and management feature flag.
|
||||||
@@ -220,6 +224,13 @@ type Engine struct {
|
|||||||
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
|
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
|
||||||
networkSerial uint64
|
networkSerial uint64
|
||||||
|
|
||||||
|
// latestComponents is the most-recent NetworkMapComponents decoded from
|
||||||
|
// a NetworkMapEnvelope (capability=3 peers only). Held alongside the
|
||||||
|
// NetworkMap that Calculate() produced from it so future incremental
|
||||||
|
// updates have a base to apply changes against. nil for legacy-format
|
||||||
|
// peers. Guarded by syncMsgMux.
|
||||||
|
latestComponents *types.NetworkMapComponents
|
||||||
|
|
||||||
networkMonitor *networkmonitor.NetworkMonitor
|
networkMonitor *networkmonitor.NetworkMonitor
|
||||||
|
|
||||||
sshServer sshServer
|
sshServer sshServer
|
||||||
@@ -274,6 +285,20 @@ type Engine struct {
|
|||||||
jobExecutorWG sync.WaitGroup
|
jobExecutorWG sync.WaitGroup
|
||||||
|
|
||||||
exposeManager *expose.Manager
|
exposeManager *expose.Manager
|
||||||
|
|
||||||
|
sessionWatcher sessionDeadlineWatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionDeadlineWatcher is the engine-facing surface of the SSO session
|
||||||
|
// expiry watcher. The concrete implementation (sessionwatch.Watcher) is wired
|
||||||
|
// in via newSessionWatcher, which is build-tagged so the js/wasm build links a
|
||||||
|
// no-op stub instead of pulling the full sessionwatch package (and its timer
|
||||||
|
// machinery) into the binary — the wasm client never runs the engine's
|
||||||
|
// session-warning flow.
|
||||||
|
type sessionDeadlineWatcher interface {
|
||||||
|
Update(deadline time.Time) error
|
||||||
|
Dismiss()
|
||||||
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer is an instance of the Connection Peer
|
// Peer is an instance of the Connection Peer
|
||||||
@@ -325,6 +350,17 @@ func NewEngine(
|
|||||||
updateManager: services.UpdateManager,
|
updateManager: services.UpdateManager,
|
||||||
syncStoreDir: config.StateDir,
|
syncStoreDir: config.StateDir,
|
||||||
}
|
}
|
||||||
|
// sessionWatcher keeps the SubscribeStatus consumers in sync with the
|
||||||
|
// session expiry deadline. Deadline-change ticks come for free via
|
||||||
|
// Status.SetSessionExpiresAt; the watcher exists to push a wake-up at
|
||||||
|
// T-WarningLead and T-FinalWarningLead so the UI repaints the remaining
|
||||||
|
// time / warning state even when nothing else changed, and to publish
|
||||||
|
// two SystemEvents (the warning composition lives in sessionwatch so
|
||||||
|
// the wire format stays owned by one package):
|
||||||
|
// - T-WarningLead → interactive "Extend now / Dismiss" notification
|
||||||
|
// - T-FinalWarningLead → auto-opened SessionAboutToExpire dialog,
|
||||||
|
// suppressed when the user dismissed the earlier warning
|
||||||
|
engine.sessionWatcher = newSessionWatcher(engine.statusRecorder)
|
||||||
|
|
||||||
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
||||||
return engine
|
return engine
|
||||||
@@ -391,6 +427,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()
|
||||||
}
|
}
|
||||||
@@ -522,7 +562,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
} else {
|
} else {
|
||||||
log.Infof("running rosenpass in strict mode")
|
log.Infof("running rosenpass in strict mode")
|
||||||
}
|
}
|
||||||
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName)
|
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create rosenpass manager: %w", err)
|
return fmt.Errorf("create rosenpass manager: %w", err)
|
||||||
}
|
}
|
||||||
@@ -623,8 +663,24 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
iceCfg := e.createICEConfig()
|
iceCfg := e.createICEConfig()
|
||||||
|
|
||||||
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
|
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
|
||||||
|
e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error {
|
||||||
|
if e.routeManager == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.routeManager.ReconcilePeerAllowedIPs(peerKey)
|
||||||
|
})
|
||||||
e.connMgr.Start(e.ctx)
|
e.connMgr.Start(e.ctx)
|
||||||
|
|
||||||
|
// Wire DNS-time lazy-connection warm-up now that the connection manager
|
||||||
|
// exists (it does not at DNS-server construction time). A DNS answer that
|
||||||
|
// points at an idle peer then wakes it before the client's first request.
|
||||||
|
e.dnsServer.SetPeerActivator(&dnsPeerActivator{
|
||||||
|
connMgr: e.connMgr,
|
||||||
|
peerStore: e.peerStore,
|
||||||
|
status: e.statusRecorder,
|
||||||
|
ctx: e.ctx,
|
||||||
|
})
|
||||||
|
|
||||||
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
|
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
|
||||||
e.srWatcher.Start(peer.IsForceRelayed())
|
e.srWatcher.Start(peer.IsForceRelayed())
|
||||||
|
|
||||||
@@ -932,8 +988,14 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
return e.ctx.Err()
|
return e.ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
e.ApplySessionDeadline(update.GetSessionExpiresAt())
|
||||||
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
|
||||||
|
// Envelope sync responses carry PeerConfig at the top level; legacy
|
||||||
|
// NetworkMap syncs carry it under NetworkMap.PeerConfig.
|
||||||
|
if pc := update.GetPeerConfig(); pc != nil {
|
||||||
|
e.handleAutoUpdateVersion(pc.GetAutoUpdate())
|
||||||
|
} else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil {
|
||||||
|
e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate())
|
||||||
}
|
}
|
||||||
|
|
||||||
done := e.phase("netbird_config")
|
done := e.phase("netbird_config")
|
||||||
@@ -943,12 +1005,47 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode the network map from either the components envelope or the
|
||||||
|
// legacy proto.NetworkMap before the posture-check gating below, so the
|
||||||
|
// "is there a network map" decision covers both wire shapes.
|
||||||
|
var (
|
||||||
|
nm *mgmProto.NetworkMap
|
||||||
|
components *types.NetworkMapComponents
|
||||||
|
)
|
||||||
|
if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) {
|
||||||
|
// Components-format peer: decode the envelope back to typed
|
||||||
|
// components, run Calculate() locally, and convert to the wire
|
||||||
|
// NetworkMap shape the rest of the engine consumes. Components are
|
||||||
|
// retained so future incremental updates can apply deltas instead
|
||||||
|
// of doing a full reconstruction.
|
||||||
|
envelope := update.GetNetworkMapEnvelope()
|
||||||
|
if envelope == nil {
|
||||||
|
return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
localKey := e.config.WgPrivateKey.PublicKey().String()
|
||||||
|
dnsName := ""
|
||||||
|
if pc := update.GetPeerConfig(); pc != nil {
|
||||||
|
// PeerConfig.Fqdn = "<dns_label>.<dns_domain>" — extract the
|
||||||
|
// shared domain by stripping the peer's own label prefix. Falls
|
||||||
|
// back to empty if the FQDN doesn't have the expected shape.
|
||||||
|
dnsName = extractDNSDomainFromFQDN(pc.GetFqdn())
|
||||||
|
}
|
||||||
|
result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode network map envelope: %w", err)
|
||||||
|
}
|
||||||
|
nm = result.NetworkMap
|
||||||
|
components = result.Components
|
||||||
|
} else {
|
||||||
|
nm = update.GetNetworkMap()
|
||||||
|
}
|
||||||
|
|
||||||
// Posture checks are bound to the network map presence:
|
// Posture checks are bound to the network map presence:
|
||||||
// NetworkMap != nil, checks present -> apply the received checks
|
// NetworkMap != nil, checks present -> apply the received checks
|
||||||
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
|
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
|
||||||
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
|
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
|
||||||
// leave the previously applied checks untouched
|
// leave the previously applied checks untouched
|
||||||
nm := update.GetNetworkMap()
|
|
||||||
if nm == nil {
|
if nm == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -961,6 +1058,14 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
done = e.phase("persist")
|
done = e.phase("persist")
|
||||||
|
// Only retain the components view when the server sent the envelope
|
||||||
|
// path. A legacy proto.NetworkMap means components == nil; writing it
|
||||||
|
// here would clobber a previously-cached snapshot, breaking the
|
||||||
|
// incremental-delta base on a future envelope sync.
|
||||||
|
if components != nil {
|
||||||
|
e.latestComponents = components
|
||||||
|
}
|
||||||
|
|
||||||
e.persistSyncResponse(update)
|
e.persistSyncResponse(update)
|
||||||
done()
|
done()
|
||||||
|
|
||||||
@@ -974,6 +1079,19 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractDNSDomainFromFQDN returns the trailing dotted domain part of the
|
||||||
|
// receiving peer's FQDN — the same value the management server fills as
|
||||||
|
// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" →
|
||||||
|
// "netbird.cloud". An empty string is returned for unrecognized formats.
|
||||||
|
func extractDNSDomainFromFQDN(fqdn string) string {
|
||||||
|
for i := 0; i < len(fqdn); i++ {
|
||||||
|
if fqdn[i] == '.' && i+1 < len(fqdn) {
|
||||||
|
return fqdn[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// updateNetbirdConfig applies the management-provided NetBird configuration:
|
// updateNetbirdConfig applies the management-provided NetBird configuration:
|
||||||
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
|
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
|
||||||
// which is the case for sync updates carrying only a network map.
|
// which is the case for sync updates carrying only a network map.
|
||||||
@@ -1133,6 +1251,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
|||||||
e.config.BlockLANAccess,
|
e.config.BlockLANAccess,
|
||||||
e.config.BlockInbound,
|
e.config.BlockInbound,
|
||||||
e.config.DisableIPv6,
|
e.config.DisableIPv6,
|
||||||
|
e.config.SyncMessageVersion,
|
||||||
e.config.EnableSSHRoot,
|
e.config.EnableSSHRoot,
|
||||||
e.config.EnableSSHSFTP,
|
e.config.EnableSSHSFTP,
|
||||||
e.config.EnableSSHLocalPortForwarding,
|
e.config.EnableSSHLocalPortForwarding,
|
||||||
@@ -1267,7 +1386,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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2001,6 +2120,7 @@ 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.SyncMessageVersion,
|
||||||
e.config.EnableSSHRoot,
|
e.config.EnableSSHRoot,
|
||||||
e.config.EnableSSHSFTP,
|
e.config.EnableSSHSFTP,
|
||||||
e.config.EnableSSHLocalPortForwarding,
|
e.config.EnableSSHLocalPortForwarding,
|
||||||
@@ -2193,7 +2313,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()
|
||||||
@@ -2216,9 +2349,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)
|
||||||
|
|
||||||
@@ -2561,13 +2694,14 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal
|
|||||||
|
|
||||||
func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool {
|
func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool {
|
||||||
excludedPeers := make(map[string]bool)
|
excludedPeers := make(map[string]bool)
|
||||||
|
|
||||||
|
// Ingress forward targets: inbound forwarded traffic is initiated remotely and
|
||||||
|
// cannot wake a lazy connection, so the peer routing the target must stay
|
||||||
|
// permanently connected. AllowedIPs are already parsed on the peer conn, so
|
||||||
|
// reuse those typed prefixes instead of re-parsing the network map strings.
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
ip := r.TranslatedAddress
|
|
||||||
for _, p := range peers {
|
for _, p := range peers {
|
||||||
for _, allowedIP := range p.GetAllowedIps() {
|
if e.peerRoutesAddr(p, r.TranslatedAddress) {
|
||||||
if allowedIP != ip.String() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey())
|
log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey())
|
||||||
excludedPeers[p.GetWgPubKey()] = true
|
excludedPeers[p.GetWgPubKey()] = true
|
||||||
}
|
}
|
||||||
@@ -2577,6 +2711,27 @@ func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers
|
|||||||
return excludedPeers
|
return excludedPeers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// peerRoutesAddr reports whether the peer is a router for addr, matched against
|
||||||
|
// the peer's already-parsed AllowedIPs from the store (the same typed value the
|
||||||
|
// lazy manager consumes) rather than re-parsing the network map strings.
|
||||||
|
func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool {
|
||||||
|
prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey())
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return prefixesContain(prefixes, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixesContain reports whether addr falls within any of the prefixes.
|
||||||
|
func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool {
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// isChecksEqual checks if two slices of checks are equal.
|
// isChecksEqual checks if two slices of checks are equal.
|
||||||
func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
|
func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
|
||||||
normalize := func(checks []*mgmProto.Checks) []string {
|
normalize := func(checks []*mgmProto.Checks) []string {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
87
client/internal/engine_lazy_exclude_test.go
Normal file
87
client/internal/engine_lazy_exclude_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrefixesContain(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefixes []string
|
||||||
|
addr string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true},
|
||||||
|
{name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true},
|
||||||
|
{name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false},
|
||||||
|
{name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false},
|
||||||
|
{name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true},
|
||||||
|
{name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
prefixes := make([]netip.Prefix, 0, len(tt.prefixes))
|
||||||
|
for _, p := range tt.prefixes {
|
||||||
|
prefixes = append(prefixes, netip.MustParsePrefix(p))
|
||||||
|
}
|
||||||
|
require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target
|
||||||
|
// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from
|
||||||
|
// lazy connections, matched via the peer's already-parsed AllowedIPs.
|
||||||
|
func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) {
|
||||||
|
const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0="
|
||||||
|
const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0="
|
||||||
|
|
||||||
|
store := peerstore.NewConnStore()
|
||||||
|
store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32"))
|
||||||
|
store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32"))
|
||||||
|
|
||||||
|
e := &Engine{peerStore: store}
|
||||||
|
|
||||||
|
peers := []*mgmProto.RemotePeerConfig{
|
||||||
|
{WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}},
|
||||||
|
{WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}},
|
||||||
|
}
|
||||||
|
rules := []firewallManager.ForwardRule{
|
||||||
|
{TranslatedAddress: netip.MustParseAddr("100.110.8.145")},
|
||||||
|
}
|
||||||
|
|
||||||
|
excluded := e.toExcludedLazyPeers(rules, peers)
|
||||||
|
|
||||||
|
require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections")
|
||||||
|
require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded")
|
||||||
|
require.Len(t, excluded, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToExcludedLazyPeers_NoRules(t *testing.T) {
|
||||||
|
e := &Engine{peerStore: peerstore.NewConnStore()}
|
||||||
|
|
||||||
|
peers := []*mgmProto.RemotePeerConfig{
|
||||||
|
{WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Empty(t, e.toExcludedLazyPeers(nil, peers))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := peer.NewConn(peer.ConnConfig{
|
||||||
|
Key: key,
|
||||||
|
WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}},
|
||||||
|
}, peer.ServiceDependencies{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return conn
|
||||||
|
}
|
||||||
88
client/internal/engine_session_deadline_test.go
Normal file
88
client/internal/engine_session_deadline_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the
|
||||||
|
// wire field carried on LoginResponse / SyncResponse:
|
||||||
|
//
|
||||||
|
// - nil pointer → no info; previously-anchored deadline survives
|
||||||
|
// - explicit zero value → "expiry disabled" sentinel; both sinks cleared
|
||||||
|
// - valid future timestamp → new deadline propagated to both sinks
|
||||||
|
func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
||||||
|
newEngine := func() *Engine {
|
||||||
|
recorder := peer.NewRecorder("")
|
||||||
|
return &Engine{
|
||||||
|
statusRecorder: recorder,
|
||||||
|
sessionWatcher: sessionwatch.New(recorder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(deadline))
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline),
|
||||||
|
"status recorder should hold the new deadline")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(nil)
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded),
|
||||||
|
"nil snapshot must not disturb the existing deadline")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||||
|
|
||||||
|
// Explicit zero Timestamp{} (seconds=0, nanos=0) is the
|
||||||
|
// "expiry disabled / not SSO" sentinel.
|
||||||
|
e.ApplySessionDeadline(×tamppb.Timestamp{})
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||||
|
"explicit zero sentinel must clear the deadline")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid timestamp clears the deadline", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||||
|
|
||||||
|
// Out-of-range nanos → IsValid()==false; same-meaning as the
|
||||||
|
// disabled sentinel for downstream sinks.
|
||||||
|
e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1})
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||||
|
"invalid timestamp must clear the deadline")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(expired))
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
|
||||||
|
"recently-expired deadline must stay on the recorder so consumers render it as expired")
|
||||||
|
})
|
||||||
|
}
|
||||||
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).
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ type managedPeer struct {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
InactivityThreshold *time.Duration
|
InactivityThreshold *time.Duration
|
||||||
|
// ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is
|
||||||
|
// armed. The activity listener creates the wake peer with the overlay /32 only; without the
|
||||||
|
// routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an
|
||||||
|
// idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile.
|
||||||
|
ReconcileAllowedIPs func(peerKey string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager manages lazy connections
|
// Manager manages lazy connections
|
||||||
@@ -56,6 +61,9 @@ type Manager struct {
|
|||||||
peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to
|
peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to
|
||||||
haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group
|
haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group
|
||||||
routesMu sync.RWMutex
|
routesMu sync.RWMutex
|
||||||
|
|
||||||
|
// reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed.
|
||||||
|
reconcileAllowedIPs func(peerKey string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new lazy connection manager
|
// NewManager creates a new lazy connection manager
|
||||||
@@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S
|
|||||||
activityManager: activity.NewManager(wgIface),
|
activityManager: activity.NewManager(wgIface),
|
||||||
peerToHAGroups: make(map[string][]route.HAUniqueID),
|
peerToHAGroups: make(map[string][]route.HAUniqueID),
|
||||||
haGroupToPeers: make(map[route.HAUniqueID][]string),
|
haGroupToPeers: make(map[route.HAUniqueID][]string),
|
||||||
|
reconcileAllowedIPs: config.ReconcileAllowedIPs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if wgIface.IsUserspaceBind() {
|
if wgIface.IsUserspaceBind() {
|
||||||
@@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) {
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
|
if err := m.armActivityListener(peerCfg); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) {
|
|||||||
|
|
||||||
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
|
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
|
||||||
|
|
||||||
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
|
if err := m.armActivityListener(*mp.peerCfg); err != nil {
|
||||||
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
|
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -465,6 +474,31 @@ func (m *Manager) close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements
|
// shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements
|
||||||
|
// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake
|
||||||
|
// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without
|
||||||
|
// this the routed prefixes would be missing and traffic to a routed subnet could not wake the
|
||||||
|
// idle routing peer. It is a no-op when no reconciler is configured.
|
||||||
|
// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then
|
||||||
|
// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing
|
||||||
|
// peer. The routed prefixes must be re-applied after the wake endpoint exists because the
|
||||||
|
// listener creates it with the overlay /32 only.
|
||||||
|
func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error {
|
||||||
|
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.armRoutedAllowedIPs(&peerCfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) {
|
||||||
|
if m.reconcileAllowedIPs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil {
|
||||||
|
peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool {
|
func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool {
|
||||||
m.routesMu.RLock()
|
m.routesMu.RLock()
|
||||||
defer m.routesMu.RUnlock()
|
defer m.routesMu.RUnlock()
|
||||||
@@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
|
|||||||
|
|
||||||
mp.peerCfg.Log.Infof("start activity monitor")
|
mp.peerCfg.Log.Infof("start activity monitor")
|
||||||
|
|
||||||
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
|
if err := m.armActivityListener(*mp.peerCfg); err != nil {
|
||||||
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
|
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
365
client/internal/netflow/store/event_aggregation_test.go
Normal file
365
client/internal/netflow/store/event_aggregation_test.go
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
var random = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
|
||||||
|
func TestFlowAggregation(t *testing.T) {
|
||||||
|
var protocols = []types.Protocol{types.ICMP, types.ICMPv6, types.TCP, types.UDP}
|
||||||
|
var tests = []struct {
|
||||||
|
description string
|
||||||
|
addresses [][]netip.Addr
|
||||||
|
dstPort uint16
|
||||||
|
eventTypes []types.Type
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "start and stop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start and drop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "drop only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeDrop},
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, protocol := range protocols {
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description+" "+protocol.String(), func(t *testing.T) {
|
||||||
|
store := NewAggregatingMemoryStore()
|
||||||
|
store.WindowEnd = time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
allExpected := make([]*types.Event, 0)
|
||||||
|
|
||||||
|
for _, srcAndDst := range tt.addresses {
|
||||||
|
inEvents, expected := generateEvents(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, protocol, types.Ingress, 0, store.WindowStart, store.WindowEnd)
|
||||||
|
for _, e := range inEvents {
|
||||||
|
store.StoreEvent(e)
|
||||||
|
}
|
||||||
|
allExpected = append(allExpected, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := store.GetAggregatedEvents()
|
||||||
|
assert.ElementsMatch(t, events, allExpected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIcmpEventAggregation(t *testing.T) {
|
||||||
|
var protocols = []types.Protocol{types.ICMP, types.ICMPv6}
|
||||||
|
var icmpTypes = []uint8{1, 2, 3}
|
||||||
|
|
||||||
|
var tests = []struct {
|
||||||
|
description string
|
||||||
|
addresses [][]netip.Addr
|
||||||
|
eventTypes []types.Type
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "start and stop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start and drop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
eventTypes: []types.Type{types.TypeStart},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "drop only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
eventTypes: []types.Type{types.TypeDrop},
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, protocol := range protocols {
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description+" "+protocol.String(), func(t *testing.T) {
|
||||||
|
store := NewAggregatingMemoryStore()
|
||||||
|
store.WindowEnd = time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
allExpected := make([]*types.Event, 0)
|
||||||
|
for _, icmpType := range icmpTypes {
|
||||||
|
events, expected := generateEvents(tt.addresses[0][0], tt.addresses[0][1], 0, tt.eventTypes, protocol, types.Ingress, icmpType, store.WindowStart, store.WindowEnd)
|
||||||
|
for _, e := range events {
|
||||||
|
store.StoreEvent(e)
|
||||||
|
}
|
||||||
|
allExpected = append(allExpected, expected)
|
||||||
|
}
|
||||||
|
aggregatedEvents := store.GetAggregatedEvents()
|
||||||
|
assert.Len(t, aggregatedEvents, len(allExpected))
|
||||||
|
assert.ElementsMatch(t, aggregatedEvents, allExpected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlowAggregationOfUnknownProtocols(t *testing.T) {
|
||||||
|
var tests = []struct {
|
||||||
|
description string
|
||||||
|
addresses [][]netip.Addr
|
||||||
|
dstPort uint16
|
||||||
|
eventTypes []types.Type
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
description: "start and stop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start and drop",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "start only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeStart},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "drop only",
|
||||||
|
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
||||||
|
dstPort: uint16(random.Uint32() >> 16),
|
||||||
|
eventTypes: []types.Type{types.TypeDrop},
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.description+" "+types.ProtocolUnknown.String(), func(t *testing.T) {
|
||||||
|
store := NewAggregatingMemoryStore()
|
||||||
|
store.WindowEnd = time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
allExpected := make([]*types.Event, 0)
|
||||||
|
|
||||||
|
for _, srcAndDst := range tt.addresses {
|
||||||
|
inEvents, expected := generateEventsForUnknownProtocol(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, types.ProtocolUnknown, types.Ingress, store.WindowStart, store.WindowEnd)
|
||||||
|
for _, e := range inEvents {
|
||||||
|
store.StoreEvent(e)
|
||||||
|
}
|
||||||
|
allExpected = append(allExpected, expected...)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := store.GetAggregatedEvents()
|
||||||
|
assert.ElementsMatch(t, events, allExpected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetAggregationWindow(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
nowFunc := func() time.Time { return now }
|
||||||
|
store := NewAggregatingMemoryStoreWithTimeFunc(nowFunc)
|
||||||
|
store.StoreEvent(&types.Event{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
EventFields: types.EventFields{
|
||||||
|
FlowID: uuid.New(),
|
||||||
|
Type: types.TypeStart,
|
||||||
|
Protocol: types.TCP,
|
||||||
|
RuleID: []byte("rule-id-1"),
|
||||||
|
Direction: types.Ingress,
|
||||||
|
SourceIP: netip.MustParseAddr("1.1.1.1"),
|
||||||
|
SourcePort: 1234,
|
||||||
|
DestIP: netip.MustParseAddr("2.2.2.2"),
|
||||||
|
DestPort: 5678,
|
||||||
|
SourceResourceID: []byte("source-resource-id"),
|
||||||
|
DestResourceID: []byte("dest-resource-id"),
|
||||||
|
RxPackets: random.Uint64(),
|
||||||
|
TxPackets: random.Uint64(),
|
||||||
|
RxBytes: random.Uint64(),
|
||||||
|
TxBytes: random.Uint64(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
now = now.Add(1 * time.Second)
|
||||||
|
reset := store.ResetAggregationWindow()
|
||||||
|
previousEvents, ok := reset.(*AggregatingMemory)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.NotEqual(t, previousEvents.WindowStart, store.WindowStart)
|
||||||
|
assert.Equal(t, previousEvents.WindowEnd, store.WindowStart)
|
||||||
|
assert.NotEmpty(t, previousEvents.events)
|
||||||
|
assert.Empty(t, store.events)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateEvents(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol,
|
||||||
|
direction types.Direction, icmpType uint8, windowStart, windowEnd time.Time) ([]*types.Event, *types.Event) {
|
||||||
|
var rxPackets, txPackets, rxBytes, txBytes uint64
|
||||||
|
inEvents := make([]*types.Event, 0)
|
||||||
|
ts := time.Now()
|
||||||
|
flowId := uuid.New()
|
||||||
|
srcPort := uint16(random.Uint32() >> 16)
|
||||||
|
|
||||||
|
for idx, eventType := range eventTypes {
|
||||||
|
e := &types.Event{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Timestamp: ts.Add(time.Duration(idx) * time.Second),
|
||||||
|
EventFields: types.EventFields{
|
||||||
|
FlowID: flowId,
|
||||||
|
Type: eventType,
|
||||||
|
Protocol: protocol,
|
||||||
|
RuleID: []byte("rule-id-1"),
|
||||||
|
Direction: direction,
|
||||||
|
SourceIP: srcIp,
|
||||||
|
SourcePort: srcPort,
|
||||||
|
DestIP: dstIp,
|
||||||
|
DestPort: dstPort,
|
||||||
|
SourceResourceID: []byte("source-resource-id"),
|
||||||
|
DestResourceID: []byte("dest-resource-id"),
|
||||||
|
RxPackets: random.Uint64(),
|
||||||
|
TxPackets: random.Uint64(),
|
||||||
|
RxBytes: random.Uint64(),
|
||||||
|
TxBytes: random.Uint64(),
|
||||||
|
}}
|
||||||
|
rxBytes += e.RxBytes
|
||||||
|
txBytes += e.TxBytes
|
||||||
|
rxPackets += e.RxPackets
|
||||||
|
txPackets += e.TxPackets
|
||||||
|
inEvents = append(inEvents, e)
|
||||||
|
if protocol == types.ICMP || protocol == types.ICMPv6 {
|
||||||
|
e.ICMPType = icmpType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var start, end, drop uint64
|
||||||
|
for _, eventType := range eventTypes {
|
||||||
|
switch eventType {
|
||||||
|
case types.TypeStart:
|
||||||
|
start += 1
|
||||||
|
case types.TypeDrop:
|
||||||
|
drop += 1
|
||||||
|
case types.TypeEnd:
|
||||||
|
end += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aggregatedEvent := &types.Event{
|
||||||
|
ID: inEvents[0].ID,
|
||||||
|
Timestamp: inEvents[0].Timestamp,
|
||||||
|
WindowStart: windowStart,
|
||||||
|
WindowEnd: windowEnd,
|
||||||
|
EventFields: types.EventFields{
|
||||||
|
FlowID: flowId,
|
||||||
|
Type: types.TypeUnknown,
|
||||||
|
Protocol: inEvents[0].Protocol,
|
||||||
|
RuleID: []byte("rule-id-1"),
|
||||||
|
Direction: inEvents[0].Direction,
|
||||||
|
SourceIP: srcIp,
|
||||||
|
SourcePort: srcPort,
|
||||||
|
DestIP: dstIp,
|
||||||
|
DestPort: dstPort,
|
||||||
|
SourceResourceID: []byte("source-resource-id"),
|
||||||
|
DestResourceID: []byte("dest-resource-id"),
|
||||||
|
RxPackets: rxPackets,
|
||||||
|
TxPackets: txPackets,
|
||||||
|
RxBytes: rxBytes,
|
||||||
|
TxBytes: txBytes,
|
||||||
|
NumOfStarts: start,
|
||||||
|
NumOfEnds: end,
|
||||||
|
NumOfDrops: drop,
|
||||||
|
}}
|
||||||
|
if protocol == types.ICMP || protocol == types.ICMPv6 {
|
||||||
|
aggregatedEvent.ICMPType = icmpType
|
||||||
|
}
|
||||||
|
|
||||||
|
return inEvents, aggregatedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateEventsForUnknownProtocol(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol,
|
||||||
|
direction types.Direction, windowStart, windowEnd time.Time) ([]*types.Event, []*types.Event) {
|
||||||
|
inEvents := make([]*types.Event, 0)
|
||||||
|
expectedEvents := make([]*types.Event, 0)
|
||||||
|
|
||||||
|
ts := time.Now()
|
||||||
|
flowId := uuid.New()
|
||||||
|
srcPort := uint16(random.Uint32() >> 16)
|
||||||
|
|
||||||
|
for idx, eventType := range eventTypes {
|
||||||
|
e := &types.Event{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Timestamp: ts.Add(time.Duration(idx) * time.Second),
|
||||||
|
EventFields: types.EventFields{
|
||||||
|
FlowID: flowId,
|
||||||
|
Type: eventType,
|
||||||
|
Protocol: protocol,
|
||||||
|
RuleID: []byte("rule-id-1"),
|
||||||
|
Direction: direction,
|
||||||
|
SourceIP: srcIp,
|
||||||
|
SourcePort: srcPort,
|
||||||
|
DestIP: dstIp,
|
||||||
|
DestPort: dstPort,
|
||||||
|
SourceResourceID: []byte("source-resource-id"),
|
||||||
|
DestResourceID: []byte("dest-resource-id"),
|
||||||
|
RxPackets: random.Uint64(),
|
||||||
|
TxPackets: random.Uint64(),
|
||||||
|
RxBytes: random.Uint64(),
|
||||||
|
TxBytes: random.Uint64(),
|
||||||
|
}}
|
||||||
|
inEvents = append(inEvents, e)
|
||||||
|
|
||||||
|
var start, end, drop uint64
|
||||||
|
switch eventType {
|
||||||
|
case types.TypeStart:
|
||||||
|
start = 1
|
||||||
|
case types.TypeDrop:
|
||||||
|
drop = 1
|
||||||
|
case types.TypeEnd:
|
||||||
|
end = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedEvents = append(expectedEvents, &types.Event{
|
||||||
|
ID: e.ID,
|
||||||
|
Timestamp: e.Timestamp,
|
||||||
|
WindowStart: windowStart,
|
||||||
|
WindowEnd: windowEnd,
|
||||||
|
EventFields: types.EventFields{
|
||||||
|
FlowID: flowId,
|
||||||
|
Type: types.TypeUnknown,
|
||||||
|
Protocol: e.Protocol,
|
||||||
|
RuleID: []byte("rule-id-1"),
|
||||||
|
Direction: e.Direction,
|
||||||
|
SourceIP: srcIp,
|
||||||
|
SourcePort: srcPort,
|
||||||
|
DestIP: dstIp,
|
||||||
|
DestPort: dstPort,
|
||||||
|
SourceResourceID: []byte("source-resource-id"),
|
||||||
|
DestResourceID: []byte("dest-resource-id"),
|
||||||
|
RxPackets: e.RxPackets,
|
||||||
|
TxPackets: e.TxPackets,
|
||||||
|
RxBytes: e.RxBytes,
|
||||||
|
TxBytes: e.TxBytes,
|
||||||
|
NumOfStarts: start,
|
||||||
|
NumOfEnds: end,
|
||||||
|
NumOfDrops: drop,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
return inEvents, expectedEvents
|
||||||
|
}
|
||||||
@@ -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,14 @@ 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
|
||||||
|
nowFunc func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
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 +61,104 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) {
|
|||||||
delete(m.events, id)
|
delete(m.events, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewAggregatingMemoryStore() *AggregatingMemory {
|
||||||
|
return NewAggregatingMemoryStoreWithTimeFunc(defaultNowFunc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// used in tests when deterministic (less random) time intervals are required
|
||||||
|
func NewAggregatingMemoryStoreWithTimeFunc(nowFunc func() time.Time) *AggregatingMemory {
|
||||||
|
return &AggregatingMemory{WindowStart: nowFunc(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, nowFunc: nowFunc, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AggregatingMemory) ResetAggregationWindow() types.FlowEventAggregator {
|
||||||
|
am.mux.Lock()
|
||||||
|
defer am.mux.Unlock()
|
||||||
|
|
||||||
|
now := am.nowFunc()
|
||||||
|
toret := AggregatingMemory{WindowStart: am.WindowStart, WindowEnd: now, Memory: Memory{events: am.events}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
|
||||||
|
|
||||||
|
am.events = make(map[uuid.UUID]*types.Event)
|
||||||
|
am.WindowStart = now
|
||||||
|
|
||||||
|
return &toret
|
||||||
|
}
|
||||||
|
|
||||||
|
type aggregationKey struct {
|
||||||
|
srcAddr netip.Addr
|
||||||
|
destAddr netip.Addr
|
||||||
|
destPort uint16
|
||||||
|
direction int
|
||||||
|
protocol uint8
|
||||||
|
icmpType uint8
|
||||||
|
unique uint64 // used to prevent aggregation on non icmp/udp/tcp events
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AggregatingMemory) GetAggregatedEvents() []*types.Event {
|
||||||
|
am.mux.Lock()
|
||||||
|
defer am.mux.Unlock()
|
||||||
|
|
||||||
|
aggregated := make(map[aggregationKey]*types.Event)
|
||||||
|
for _, v := range am.events {
|
||||||
|
lookupKey := aggregationKey{srcAddr: v.SourceIP, destAddr: v.DestIP, destPort: v.DestPort, direction: int(v.Direction), protocol: uint8(v.Protocol), icmpType: v.ICMPType}
|
||||||
|
if _, ok := aggregated[lookupKey]; !ok {
|
||||||
|
event := v.Clone()
|
||||||
|
|
||||||
|
switch event.Type {
|
||||||
|
case types.TypeStart:
|
||||||
|
event.NumOfStarts += 1
|
||||||
|
case types.TypeDrop:
|
||||||
|
event.NumOfDrops += 1
|
||||||
|
case types.TypeEnd:
|
||||||
|
event.NumOfEnds += 1
|
||||||
|
}
|
||||||
|
event.Type = types.TypeUnknown
|
||||||
|
|
||||||
|
// Please note that ICMPCode field isn't propagated by the manager (see flow/proto/flow.pb.go, FlowFields struct)
|
||||||
|
// so the field value in an icmp event in the "aggregated" doesn't matter
|
||||||
|
|
||||||
|
event.WindowStart = am.WindowStart
|
||||||
|
event.WindowEnd = am.WindowEnd
|
||||||
|
|
||||||
|
if event.Protocol != types.ICMP && event.Protocol != types.ICMPv6 && event.Protocol != types.UDP && event.Protocol != types.TCP {
|
||||||
|
lookupKey.unique = am.rnd.Uint64() // to make the lookup key unique so we don't aggregate on it
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregated[lookupKey] = event
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregatedEvent := aggregated[lookupKey]
|
||||||
|
if aggregatedEvent.Protocol != types.ICMP && aggregatedEvent.Protocol != types.ICMPv6 && aggregatedEvent.Protocol != types.UDP && aggregatedEvent.Protocol != types.TCP {
|
||||||
|
continue // we don't aggregate this type of events; shouldn't ever get here
|
||||||
|
}
|
||||||
|
|
||||||
|
// track the number of connections, duration?, open and close events?
|
||||||
|
aggregatedEvent.RxBytes += v.RxBytes
|
||||||
|
aggregatedEvent.RxPackets += v.RxPackets
|
||||||
|
aggregatedEvent.TxBytes += v.TxBytes
|
||||||
|
aggregatedEvent.TxPackets += v.TxPackets
|
||||||
|
switch v.Type {
|
||||||
|
case types.TypeStart:
|
||||||
|
aggregatedEvent.NumOfStarts += 1
|
||||||
|
case types.TypeDrop:
|
||||||
|
aggregatedEvent.NumOfDrops += 1
|
||||||
|
case types.TypeEnd:
|
||||||
|
aggregatedEvent.NumOfEnds += 1
|
||||||
|
}
|
||||||
|
if aggregatedEvent.Timestamp.Compare(v.Timestamp) > 0 {
|
||||||
|
aggregatedEvent.Timestamp = v.Timestamp
|
||||||
|
aggregatedEvent.ID = v.ID
|
||||||
|
aggregatedEvent.SourcePort = v.SourcePort
|
||||||
|
}
|
||||||
|
if len(aggregatedEvent.RuleID) == 0 && len(v.RuleID) != 0 {
|
||||||
|
aggregatedEvent.RuleID = slices.Clone(v.RuleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slices.Collect(maps.Values(aggregated)) // could return an iterator instead here
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultNowFunc() time.Time {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import (
|
|||||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// wgTimeoutEscalationThreshold is the number of consecutive WireGuard
|
||||||
|
// handshake timeouts after which the rosenpass state for the peer is
|
||||||
|
// considered desynced and gets reset.
|
||||||
|
const wgTimeoutEscalationThreshold = 3
|
||||||
|
|
||||||
// MetricsRecorder is an interface for recording peer connection metrics
|
// MetricsRecorder is an interface for recording peer connection metrics
|
||||||
type MetricsRecorder interface {
|
type MetricsRecorder interface {
|
||||||
RecordConnectionStages(
|
RecordConnectionStages(
|
||||||
@@ -118,6 +123,9 @@ type Conn struct {
|
|||||||
wgWatcher *WGWatcher
|
wgWatcher *WGWatcher
|
||||||
wgWatcherWg sync.WaitGroup
|
wgWatcherWg sync.WaitGroup
|
||||||
wgWatcherCancel context.CancelFunc
|
wgWatcherCancel context.CancelFunc
|
||||||
|
// wgTimeouts counts consecutive WireGuard handshake timeouts without a
|
||||||
|
// successful handshake in between. Guarded by mu.
|
||||||
|
wgTimeouts int
|
||||||
|
|
||||||
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
|
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
|
||||||
rosenpassRemoteKey []byte
|
rosenpassRemoteKey []byte
|
||||||
@@ -195,7 +203,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
|
|||||||
statusICE: worker.NewAtomicStatus(),
|
statusICE: worker.NewAtomicStatus(),
|
||||||
dumpState: dumpState,
|
dumpState: dumpState,
|
||||||
endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)),
|
endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)),
|
||||||
wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState),
|
|
||||||
metricsRecorder: services.MetricsRecorder,
|
metricsRecorder: services.MetricsRecorder,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,11 +670,12 @@ func (conn *Conn) onGuardEvent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (conn *Conn) onWGDisconnected() {
|
func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
|
||||||
conn.mu.Lock()
|
conn.mu.Lock()
|
||||||
defer conn.mu.Unlock()
|
defer conn.mu.Unlock()
|
||||||
|
|
||||||
if conn.ctx.Err() != nil {
|
// watcherCtx guards against a stale watcher tearing down a connection that already superseded it.
|
||||||
|
if conn.ctx.Err() != nil || watcherCtx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,6 +691,29 @@ func (conn *Conn) onWGDisconnected() {
|
|||||||
default:
|
default:
|
||||||
conn.Log.Debugf("No active connection to close on WG timeout")
|
conn.Log.Debugf("No active connection to close on WG timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conn.escalateWGTimeoutLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalateWGTimeoutLocked resets the peer's rosenpass state after repeated
|
||||||
|
// handshake timeouts. With rosenpass enabled, persistent timeouts mean the
|
||||||
|
// preshared keys have desynced; the renewal exchange runs over the dead
|
||||||
|
// tunnel and cannot resync them. Reporting the peer disconnected drops its
|
||||||
|
// rosenpass state, so the next connection configuration programs the
|
||||||
|
// rendezvous key and the tunnel can bootstrap again. Callers must hold mu.
|
||||||
|
func (conn *Conn) escalateWGTimeoutLocked() {
|
||||||
|
if conn.config.RosenpassConfig.PubKey == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.wgTimeouts++
|
||||||
|
if conn.wgTimeouts < wgTimeoutEscalationThreshold || conn.onDisconnected == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.wgTimeouts = 0
|
||||||
|
|
||||||
|
conn.Log.Warnf("%d consecutive WireGuard handshake timeouts, resetting rosenpass state for peer", wgTimeoutEscalationThreshold)
|
||||||
|
conn.onDisconnected(conn.config.WgConfig.RemoteKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) {
|
func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) {
|
||||||
@@ -802,25 +833,39 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enableWgWatcherIfNeeded starts a fresh watcher instance per connection attempt, so its
|
||||||
|
// lifecycle stays bound to conn.mu and enable/disable can't race an old goroutine's shutdown.
|
||||||
|
// Caller must hold conn.mu.
|
||||||
func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
|
func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
|
||||||
if !conn.wgWatcher.PrepareInitialHandshake() {
|
if conn.wgWatcher != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watcher := NewWGWatcher(conn.Log, conn.config.WgConfig.WgInterface, conn.config.Key, conn.dumpState)
|
||||||
|
watcher.PrepareInitialHandshake()
|
||||||
|
|
||||||
wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
|
wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
|
||||||
|
conn.wgWatcher = watcher
|
||||||
conn.wgWatcherCancel = wgWatcherCancel
|
conn.wgWatcherCancel = wgWatcherCancel
|
||||||
|
|
||||||
conn.wgWatcherWg.Add(1)
|
conn.wgWatcherWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer conn.wgWatcherWg.Done()
|
defer conn.wgWatcherWg.Done()
|
||||||
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess)
|
onDisconnected := func() { conn.onWGDisconnected(wgWatcherCtx) }
|
||||||
|
watcher.EnableWgWatcher(wgWatcherCtx, enabledTime, onDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// disableWgWatcherIfNeeded cancels and drops the watcher once no transport is active. It never
|
||||||
|
// waits for the goroutine: the timeout path reentrantly calls back here under conn.mu, so
|
||||||
|
// blocking would deadlock. Caller must hold conn.mu.
|
||||||
func (conn *Conn) disableWgWatcherIfNeeded() {
|
func (conn *Conn) disableWgWatcherIfNeeded() {
|
||||||
if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil {
|
if conn.currentConnPriority != conntype.None || conn.wgWatcher == nil {
|
||||||
conn.wgWatcherCancel()
|
return
|
||||||
conn.wgWatcherCancel = nil
|
|
||||||
}
|
}
|
||||||
|
conn.wgWatcherCancel()
|
||||||
|
conn.wgWatcher = nil
|
||||||
|
conn.wgWatcherCancel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
|
func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
|
||||||
@@ -843,7 +888,9 @@ func (conn *Conn) resetEndpoint() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
conn.Log.Infof("reset wg endpoint")
|
conn.Log.Infof("reset wg endpoint")
|
||||||
conn.wgWatcher.Reset()
|
if conn.wgWatcher != nil {
|
||||||
|
conn.wgWatcher.Reset()
|
||||||
|
}
|
||||||
if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil {
|
if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil {
|
||||||
conn.Log.Warnf("failed to remove endpoint address before update: %v", err)
|
conn.Log.Warnf("failed to remove endpoint address before update: %v", err)
|
||||||
}
|
}
|
||||||
@@ -892,6 +939,15 @@ func (conn *Conn) onWGHandshakeSuccess(when time.Time) {
|
|||||||
conn.recordConnectionMetrics()
|
conn.recordConnectionMetrics()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onWGCheckSuccess is called for every watcher check that observed a fresh
|
||||||
|
// handshake, including handshakes of connections that were already up when
|
||||||
|
// the watcher started.
|
||||||
|
func (conn *Conn) onWGCheckSuccess() {
|
||||||
|
conn.mu.Lock()
|
||||||
|
conn.wgTimeouts = 0
|
||||||
|
conn.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// recordConnectionMetrics records connection stage timestamps as metrics
|
// recordConnectionMetrics records connection stage timestamps as metrics
|
||||||
func (conn *Conn) recordConnectionMetrics() {
|
func (conn *Conn) recordConnectionMetrics() {
|
||||||
if conn.metricsRecorder == nil {
|
if conn.metricsRecorder == nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
@@ -304,3 +305,84 @@ func TestConn_presharedKey_RosenpassManaged(t *testing.T) {
|
|||||||
t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK")
|
t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn {
|
||||||
|
cfg := ConnConfig{
|
||||||
|
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
||||||
|
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
||||||
|
WgConfig: WgConfig{RemoteKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU="},
|
||||||
|
}
|
||||||
|
if rosenpassEnabled {
|
||||||
|
cfg.RosenpassConfig = RosenpassConfig{PubKey: []byte("dummykey")}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn := &Conn{
|
||||||
|
ctx: context.Background(),
|
||||||
|
config: cfg,
|
||||||
|
Log: log.WithField("peer", cfg.Key),
|
||||||
|
metricsStages: &MetricsStages{},
|
||||||
|
}
|
||||||
|
conn.SetOnDisconnected(func(remotePeer string) {
|
||||||
|
*disconnected = append(*disconnected, remotePeer)
|
||||||
|
})
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConn_onWGDisconnected_EscalatesToRosenpassReset: repeated handshake
|
||||||
|
// timeouts with rosenpass enabled mean the preshared keys have desynced. The
|
||||||
|
// renewal exchange runs over the dead tunnel and cannot resync them, so after
|
||||||
|
// wgTimeoutEscalationThreshold consecutive timeouts the conn must report the
|
||||||
|
// peer disconnected, dropping its rosenpass state so the next configuration
|
||||||
|
// programs the rendezvous key.
|
||||||
|
func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) {
|
||||||
|
var disconnected []string
|
||||||
|
conn := newWGTimeoutTestConn(true, &disconnected)
|
||||||
|
|
||||||
|
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||||
|
conn.onWGDisconnected(conn.ctx)
|
||||||
|
}
|
||||||
|
assert.Empty(t, disconnected, "escalation must not fire below the threshold")
|
||||||
|
|
||||||
|
conn.onWGDisconnected(conn.ctx)
|
||||||
|
assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected,
|
||||||
|
"reaching the threshold must report the peer disconnected once")
|
||||||
|
|
||||||
|
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||||
|
conn.onWGDisconnected(conn.ctx)
|
||||||
|
}
|
||||||
|
assert.Len(t, disconnected, 1, "escalation must restart counting after firing")
|
||||||
|
|
||||||
|
conn.onWGDisconnected(conn.ctx)
|
||||||
|
assert.Len(t, disconnected, 2, "continued timeouts must escalate again")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConn_onWGDisconnected_CheckSuccessResetsEscalation: a successful
|
||||||
|
// handshake between timeouts means the tunnel recovered; the counter must
|
||||||
|
// start over.
|
||||||
|
func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
|
||||||
|
var disconnected []string
|
||||||
|
conn := newWGTimeoutTestConn(true, &disconnected)
|
||||||
|
|
||||||
|
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||||
|
conn.onWGDisconnected(conn.ctx)
|
||||||
|
}
|
||||||
|
conn.onWGCheckSuccess()
|
||||||
|
|
||||||
|
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||||
|
conn.onWGDisconnected(conn.ctx)
|
||||||
|
}
|
||||||
|
assert.Empty(t, disconnected, "handshake success must reset the timeout count")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass
|
||||||
|
// there is no per-peer key state to reset; repeated timeouts must not report
|
||||||
|
// disconnects.
|
||||||
|
func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
|
||||||
|
var disconnected []string
|
||||||
|
conn := newWGTimeoutTestConn(false, &disconnected)
|
||||||
|
|
||||||
|
for i := 0; i < wgTimeoutEscalationThreshold*3; i++ {
|
||||||
|
conn.onWGDisconnected(conn.ctx)
|
||||||
|
}
|
||||||
|
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,36 @@ 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 in the past is
|
||||||
|
// returned as-is: it means the session has expired, and consumers (tray row,
|
||||||
|
// CLI status) render it as "expired" rather than hiding it — masking it as
|
||||||
|
// "none" would blank the UI at the exact moment it should say the session
|
||||||
|
// ended.
|
||||||
|
func (d *Status) GetSessionExpiresAt() time.Time {
|
||||||
|
d.mux.Lock()
|
||||||
|
defer d.mux.Unlock()
|
||||||
|
return d.sessionExpiresAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLocalPeerStateRoute adds a route to the local peer state
|
// AddLocalPeerStateRoute adds a route to the local peer state
|
||||||
@@ -828,11 +890,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 +910,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 +927,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 +961,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 +972,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 +989,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 +1196,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 +1350,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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package peer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -24,14 +23,14 @@ type WGInterfaceStater interface {
|
|||||||
GetStats() (map[string]configurer.WGStats, error)
|
GetStats() (map[string]configurer.WGStats, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WGWatcher is single-shot: one instance per connection attempt, run once, then discarded.
|
||||||
|
// Lifecycle is owned by Conn under conn.mu, so it keeps no "enabled" state to go stale.
|
||||||
type WGWatcher struct {
|
type WGWatcher struct {
|
||||||
log *log.Entry
|
log *log.Entry
|
||||||
wgIfaceStater WGInterfaceStater
|
wgIfaceStater WGInterfaceStater
|
||||||
peerKey string
|
peerKey string
|
||||||
stateDump *stateDump
|
stateDump *stateDump
|
||||||
|
|
||||||
enabled bool
|
|
||||||
muEnabled sync.Mutex
|
|
||||||
// initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
|
// initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
|
||||||
initialHandshake time.Time
|
initialHandshake time.Time
|
||||||
|
|
||||||
@@ -48,36 +47,23 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrepareInitialHandshake reserves the watcher and reads the peer's current WireGuard
|
// PrepareInitialHandshake reads the peer's current WireGuard handshake time. It must be
|
||||||
// handshake time. It must be called before the peer is (re)configured on the WireGuard
|
// called before the peer is (re)configured on the WireGuard interface, so the captured
|
||||||
// interface, so the captured baseline reflects the state prior to this connection attempt
|
// baseline reflects the state prior to this connection attempt instead of racing with
|
||||||
// instead of racing with that configuration. Returns ok=false if the watcher is already
|
// that configuration.
|
||||||
// running, in which case EnableWgWatcher must not be called.
|
func (w *WGWatcher) PrepareInitialHandshake() {
|
||||||
func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
|
|
||||||
w.muEnabled.Lock()
|
|
||||||
if w.enabled {
|
|
||||||
w.muEnabled.Unlock()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
w.log.Debugf("enable WireGuard watcher")
|
w.log.Debugf("enable WireGuard watcher")
|
||||||
w.enabled = true
|
|
||||||
w.muEnabled.Unlock()
|
|
||||||
|
|
||||||
handshake, _ := w.wgState()
|
handshake, _ := w.wgState()
|
||||||
w.initialHandshake = handshake
|
w.initialHandshake = handshake
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by
|
// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by
|
||||||
// PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible
|
// PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible
|
||||||
// for context lifecycle management.
|
// for context lifecycle management. onHandshakeSuccessFn is called only for the first
|
||||||
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
|
// handshake observed by this run, onCheckSuccessFn for every check that observed a fresh
|
||||||
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake)
|
// handshake, including the first.
|
||||||
|
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) {
|
||||||
w.muEnabled.Lock()
|
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake)
|
||||||
w.enabled = false
|
|
||||||
w.muEnabled.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -90,7 +76,7 @@ func (w *WGWatcher) Reset() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wgStateCheck help to check the state of the WireGuard handshake and relay connection
|
// wgStateCheck help to check the state of the WireGuard handshake and relay connection
|
||||||
func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) {
|
func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func(), enabledTime time.Time, initialHandshake time.Time) {
|
||||||
w.log.Infof("WireGuard watcher started")
|
w.log.Infof("WireGuard watcher started")
|
||||||
|
|
||||||
timer := time.NewTimer(wgHandshakeOvertime)
|
timer := time.NewTimer(wgHandshakeOvertime)
|
||||||
@@ -103,6 +89,7 @@ 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 {
|
||||||
|
// early ctx cancel check return
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -117,6 +104,10 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if onCheckSuccessFn != nil && ctx.Err() == nil {
|
||||||
|
onCheckSuccessFn()
|
||||||
|
}
|
||||||
|
|
||||||
lastHandshake = *handshake
|
lastHandshake = *handshake
|
||||||
|
|
||||||
resetTime := time.Until(handshake.Add(checkPeriod))
|
resetTime := time.Until(handshake.Add(checkPeriod))
|
||||||
@@ -147,9 +138,9 @@ func (w *WGWatcher) handshakeCheck(lastHandshake time.Time) (*time.Time, bool) {
|
|||||||
|
|
||||||
w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake)
|
w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake)
|
||||||
|
|
||||||
// the current know handshake did not change
|
// the current known handshake did not change
|
||||||
if handshake.Equal(lastHandshake) {
|
if handshake.Equal(lastHandshake) {
|
||||||
w.log.Warnf("WireGuard handshake timed out: %v", handshake)
|
w.log.Warnf("WireGuard handshake not updated: %v", handshake)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -24,6 +23,72 @@ func (m *MocWgIface) disconnect() {
|
|||||||
m.stop = true
|
m.stop = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mockHandshakeStats struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
handshake time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHandshakeStats) GetStats() (map[string]configurer.WGStats, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return map[string]configurer.WGStats{"": {LastHandshake: m.handshake}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHandshakeStats) advance() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.handshake = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWGWatcher_CheckSuccessCallback: onCheckSuccessFn must fire for a fresh
|
||||||
|
// handshake even when the watcher started with an existing handshake baseline,
|
||||||
|
// the case where onHandshakeSuccessFn stays silent.
|
||||||
|
func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
|
||||||
|
// checkPeriod bounds how stale a handshake may be before the watcher treats it
|
||||||
|
// as a suspended-machine timeout. The first check fires after wgHandshakeOvertime,
|
||||||
|
// so keep checkPeriod well above any scheduling jitter to avoid a false timeout
|
||||||
|
// converting the expected success into a disconnect on a loaded runner.
|
||||||
|
checkPeriod = 1 * time.Minute
|
||||||
|
wgHandshakeOvertime = 1 * time.Second
|
||||||
|
|
||||||
|
mlog := log.WithField("peer", "tet")
|
||||||
|
// Use an old baseline so advance() yields a strictly newer handshake even on
|
||||||
|
// platforms with coarse clock resolution (Windows), where two time.Now() calls
|
||||||
|
// microseconds apart can return the same instant and read as a timed-out handshake.
|
||||||
|
stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)}
|
||||||
|
watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{}))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
watcher.PrepareInitialHandshake()
|
||||||
|
|
||||||
|
firstHandshake := make(chan struct{}, 1)
|
||||||
|
checkSuccess := make(chan struct{}, 1)
|
||||||
|
go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {
|
||||||
|
firstHandshake <- struct{}{}
|
||||||
|
}, func() {
|
||||||
|
select {
|
||||||
|
case checkSuccess <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
stats.advance()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-checkSuccess:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Errorf("timeout waiting for check success callback")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-firstHandshake:
|
||||||
|
t.Errorf("first-handshake callback must not fire for a non-zero baseline")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWGWatcher_EnableWgWatcher(t *testing.T) {
|
func TestWGWatcher_EnableWgWatcher(t *testing.T) {
|
||||||
checkPeriod = 5 * time.Second
|
checkPeriod = 5 * time.Second
|
||||||
wgHandshakeOvertime = 1 * time.Second
|
wgHandshakeOvertime = 1 * time.Second
|
||||||
@@ -35,8 +100,7 @@ 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()
|
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() {
|
||||||
@@ -44,7 +108,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
|
|||||||
onDisconnected <- struct{}{}
|
onDisconnected <- struct{}{}
|
||||||
}, func(when time.Time) {
|
}, func(when time.Time) {
|
||||||
mlog.Infof("onHandshakeSuccess: %v", when)
|
mlog.Infof("onHandshakeSuccess: %v", when)
|
||||||
})
|
}, nil)
|
||||||
|
|
||||||
// wait for initial reading
|
// wait for initial reading
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
@@ -66,14 +130,13 @@ 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()
|
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() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {})
|
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}, nil)
|
||||||
}()
|
}()
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
@@ -83,13 +146,12 @@ 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()
|
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{}{}
|
||||||
}, func(when time.Time) {})
|
}, func(when time.Time) {}, nil)
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
mocWgIface.disconnect()
|
mocWgIface.disconnect()
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ type ConfigInput struct {
|
|||||||
BlockLANAccess *bool
|
BlockLANAccess *bool
|
||||||
BlockInbound *bool
|
BlockInbound *bool
|
||||||
DisableIPv6 *bool
|
DisableIPv6 *bool
|
||||||
|
SyncMessageVersion *int
|
||||||
|
|
||||||
DisableNotifications *bool
|
DisableNotifications *bool
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ type Config struct {
|
|||||||
BlockLANAccess bool
|
BlockLANAccess bool
|
||||||
BlockInbound bool
|
BlockInbound bool
|
||||||
DisableIPv6 bool
|
DisableIPv6 bool
|
||||||
|
SyncMessageVersion *int
|
||||||
|
|
||||||
DisableNotifications *bool
|
DisableNotifications *bool
|
||||||
|
|
||||||
@@ -587,6 +589,12 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion {
|
||||||
|
log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion)
|
||||||
|
*config.SyncMessageVersion = *input.SyncMessageVersion
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
|
||||||
if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) {
|
if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) {
|
||||||
if *input.DisableNotifications {
|
if *input.DisableNotifications {
|
||||||
log.Infof("disabling notifications")
|
log.Infof("disabling notifications")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
@@ -439,7 +440,11 @@ func (s *ServiceManager) GetStatePath() string {
|
|||||||
|
|
||||||
activeProf, err := s.GetActiveProfileState()
|
activeProf, err := s.GetActiveProfileState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("failed to get active profile state: %v", err)
|
if errors.Is(err, syscall.ENOSYS) {
|
||||||
|
log.Debugf("active profile state unavailable on this platform: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Warnf("failed to get active profile state: %v", err)
|
||||||
|
}
|
||||||
return defaultStatePath
|
return defaultStatePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type rpServer interface {
|
|||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
ifaceName string
|
ifaceName string
|
||||||
|
localWgKey wgtypes.Key
|
||||||
spk []byte
|
spk []byte
|
||||||
ssk []byte
|
ssk []byte
|
||||||
rpKeyHash string
|
rpKeyHash string
|
||||||
@@ -51,8 +52,9 @@ type Manager struct {
|
|||||||
wgIface PresharedKeySetter
|
wgIface PresharedKeySetter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new Rosenpass manager
|
// NewManager creates a new Rosenpass manager. localWgKey is the local
|
||||||
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) {
|
// WireGuard public key, used to derive the per-peer rendezvous key.
|
||||||
|
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) {
|
||||||
public, secret, err := rp.GenerateKeyPair()
|
public, secret, err := rp.GenerateKeyPair()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -62,6 +64,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
|
|||||||
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
|
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
|
||||||
return &Manager{
|
return &Manager{
|
||||||
ifaceName: wgIfaceName,
|
ifaceName: wgIfaceName,
|
||||||
|
localWgKey: localWgKey,
|
||||||
rpKeyHash: rpKeyHash,
|
rpKeyHash: rpKeyHash,
|
||||||
spk: public,
|
spk: public,
|
||||||
ssk: secret,
|
ssk: secret,
|
||||||
@@ -73,7 +76,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
|
|||||||
// nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
|
// nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
|
||||||
// replace it with a fresh handler on each Run() to clear stale peer
|
// replace it with a fresh handler on each Run() to clear stale peer
|
||||||
// state from previous engine sessions.
|
// state from previous engine sessions.
|
||||||
rpWgHandler: NewNetbirdHandler(),
|
rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey),
|
||||||
lock: sync.Mutex{},
|
lock: sync.Mutex{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -161,7 +164,7 @@ func (m *Manager) generateConfig() (rp.Config, error) {
|
|||||||
cfg.Peers = []rp.PeerConfig{}
|
cfg.Peers = []rp.PeerConfig{}
|
||||||
|
|
||||||
m.lock.Lock()
|
m.lock.Lock()
|
||||||
m.rpWgHandler = NewNetbirdHandler()
|
m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey)
|
||||||
if m.wgIface != nil {
|
if m.wgIface != nil {
|
||||||
m.rpWgHandler.SetInterface(m.wgIface)
|
m.rpWgHandler.SetInterface(m.wgIface)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
|
|||||||
ssk: make([]byte, 32),
|
ssk: make([]byte, 32),
|
||||||
rpKeyHash: "test-hash",
|
rpKeyHash: "test-hash",
|
||||||
rpPeerIDs: make(map[string]*rp.PeerID),
|
rpPeerIDs: make(map[string]*rp.PeerID),
|
||||||
rpWgHandler: NewNetbirdHandler(),
|
rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}),
|
||||||
server: mock,
|
server: mock,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
|
|||||||
// issue #4341 cannot occur in the window between NewManager and Run().
|
// issue #4341 cannot occur in the window between NewManager and Run().
|
||||||
func TestNewManager_PreInitializesHandler(t *testing.T) {
|
func TestNewManager_PreInitializesHandler(t *testing.T) {
|
||||||
psk := wgtypes.Key{}
|
psk := wgtypes.Key{}
|
||||||
m, err := NewManager(&psk, "wt0")
|
m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
|
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
|
||||||
}
|
}
|
||||||
@@ -329,10 +329,10 @@ func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing
|
|||||||
require.False(t, m.IsPresharedKeyInitialized(wgKey))
|
require.False(t, m.IsPresharedKeyInitialized(wgKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NetbirdHandler.outputKey ----------------------------------------------
|
// --- NetbirdHandler.applyKey ----------------------------------------------
|
||||||
|
|
||||||
func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
||||||
h := NewNetbirdHandler()
|
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||||
iface := &mockIface{}
|
iface := &mockIface{}
|
||||||
h.SetInterface(iface)
|
h.SetInterface(iface)
|
||||||
|
|
||||||
@@ -348,8 +348,8 @@ func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
|||||||
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
||||||
h := NewNetbirdHandler()
|
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||||
iface := &mockIface{}
|
iface := &mockIface{}
|
||||||
h.SetInterface(iface)
|
h.SetInterface(iface)
|
||||||
|
|
||||||
@@ -364,8 +364,8 @@ func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
|||||||
require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
|
require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
func TestHandler_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
||||||
h := NewNetbirdHandler()
|
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||||
// no SetInterface — iface remains nil
|
// no SetInterface — iface remains nil
|
||||||
pid := rp.PeerID{0x03}
|
pid := rp.PeerID{0x03}
|
||||||
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
|
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
|
||||||
@@ -374,8 +374,8 @@ func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
|||||||
h.HandshakeCompleted(pid, rp.Key{})
|
h.HandshakeCompleted(pid, rp.Key{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
|
func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) {
|
||||||
h := NewNetbirdHandler()
|
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||||
iface := &mockIface{}
|
iface := &mockIface{}
|
||||||
h.SetInterface(iface)
|
h.SetInterface(iface)
|
||||||
|
|
||||||
@@ -384,7 +384,7 @@ func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
|
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
|
||||||
h := NewNetbirdHandler()
|
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||||
iface := &mockIface{}
|
iface := &mockIface{}
|
||||||
h.SetInterface(iface)
|
h.SetInterface(iface)
|
||||||
|
|
||||||
@@ -398,7 +398,7 @@ func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
|
func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
|
||||||
h := NewNetbirdHandler()
|
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||||
pid := rp.PeerID{0x05}
|
pid := rp.PeerID{0x05}
|
||||||
wgKey := wgtypes.Key{0xEE}
|
wgKey := wgtypes.Key{0xEE}
|
||||||
h.AddPeer(pid, "wt0", rp.Key(wgKey))
|
h.AddPeer(pid, "wt0", rp.Key(wgKey))
|
||||||
|
|||||||
@@ -18,19 +18,34 @@ type PresharedKeySetter interface {
|
|||||||
type wireGuardPeer struct {
|
type wireGuardPeer struct {
|
||||||
Interface string
|
Interface string
|
||||||
PublicKey rp.Key
|
PublicKey rp.Key
|
||||||
|
// initialized is true once a completed exchange has set a
|
||||||
|
// Rosenpass-managed PSK for this peer.
|
||||||
|
initialized bool
|
||||||
|
// chainKey is the key output by the last completed exchange, advanced by
|
||||||
|
// one ratchet step on expiry. Nil until the first exchange completes and
|
||||||
|
// after the peer has fallen back to the rendezvous key.
|
||||||
|
chainKey *wgtypes.Key
|
||||||
|
// expiries counts failed renewals since the last completed exchange.
|
||||||
|
expiries int
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetbirdHandler struct {
|
type NetbirdHandler struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
iface PresharedKeySetter
|
iface PresharedKeySetter
|
||||||
peers map[rp.PeerID]wireGuardPeer
|
// preSharedKey is the account-level preshared key, used as the rendezvous
|
||||||
initializedPeers map[rp.PeerID]bool
|
// key when set. Nil means the deterministic seed key is used instead.
|
||||||
|
preSharedKey *[32]byte
|
||||||
|
// localWgKey is the local WireGuard public key, one of the two inputs to
|
||||||
|
// the deterministic seed key.
|
||||||
|
localWgKey wgtypes.Key
|
||||||
|
peers map[rp.PeerID]*wireGuardPeer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNetbirdHandler() *NetbirdHandler {
|
func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler {
|
||||||
return &NetbirdHandler{
|
return &NetbirdHandler{
|
||||||
peers: map[rp.PeerID]wireGuardPeer{},
|
preSharedKey: preSharedKey,
|
||||||
initializedPeers: map[rp.PeerID]bool{},
|
localWgKey: localWgKey,
|
||||||
|
peers: map[rp.PeerID]*wireGuardPeer{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,10 +57,16 @@ func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) {
|
|||||||
h.iface = iface
|
h.iface = iface
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddPeer registers a peer with the handler. Re-adding a known peer (every
|
||||||
|
// reconnection does) keeps its key recovery state.
|
||||||
func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) {
|
func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
h.peers[pid] = wireGuardPeer{
|
if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk {
|
||||||
|
existing.Interface = intf
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.peers[pid] = &wireGuardPeer{
|
||||||
Interface: intf,
|
Interface: intf,
|
||||||
PublicKey: pk,
|
PublicKey: pk,
|
||||||
}
|
}
|
||||||
@@ -55,7 +76,6 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
delete(h.peers, pid)
|
delete(h.peers, pid)
|
||||||
delete(h.initializedPeers, pid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsPeerInitialized returns true if Rosenpass has completed a handshake
|
// IsPeerInitialized returns true if Rosenpass has completed a handshake
|
||||||
@@ -63,50 +83,120 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
|
|||||||
func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool {
|
func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
return h.initializedPeers[pid]
|
peer, ok := h.peers[pid]
|
||||||
|
return ok && peer.initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandshakeCompleted programs the freshly exchanged output key and resets the
|
||||||
|
// peer's key recovery state.
|
||||||
func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) {
|
func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) {
|
||||||
h.outputKey(rp.KeyOutputReasonStale, pid, key)
|
psk := wgtypes.Key(key)
|
||||||
}
|
|
||||||
|
|
||||||
func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
|
|
||||||
key, _ := rp.GeneratePresharedKey()
|
|
||||||
h.outputKey(rp.KeyOutputReasonStale, pid, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) {
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
iface := h.iface
|
defer h.mu.Unlock()
|
||||||
wg, ok := h.peers[pid]
|
|
||||||
isInitialized := h.initializedPeers[pid]
|
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
if iface == nil {
|
peer, ok := h.peers[pid]
|
||||||
log.Warn("rosenpass: interface not set, cannot update preshared key")
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if peer.expiries > 0 {
|
||||||
|
log.Infof("rosenpass exchange completed for peer %s after %d expired renewals", wgtypes.Key(peer.PublicKey), peer.expiries)
|
||||||
|
}
|
||||||
|
// chainKey tracks the shared exchange output regardless of the local write
|
||||||
|
// outcome, so both ends still converge on the next expiry.
|
||||||
|
peer.chainKey = &psk
|
||||||
|
peer.expiries = 0
|
||||||
|
if !h.applyKeyLocked(pid, psk, peer.initialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peer.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandshakeExpired replaces the expired key. The renewal exchange runs over
|
||||||
|
// the tunnel keyed by the PSK itself, so the replacement must be derivable on
|
||||||
|
// both ends without communication: the first expiry ratchets the last shared
|
||||||
|
// key forward, repeated expiries (and expiries without a completed exchange)
|
||||||
|
// fall back to the rendezvous key and drop the peer out of the initialized
|
||||||
|
// state so connection reconfigurations reprogram the rendezvous key as well.
|
||||||
|
func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
peer, ok := h.peers[pid]
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
peerKey := wgtypes.Key(wg.PublicKey).String()
|
peer.expiries++
|
||||||
pskKey := wgtypes.Key(psk)
|
|
||||||
|
|
||||||
// Use updateOnly=true for later rotations (peer already has Rosenpass PSK)
|
var psk wgtypes.Key
|
||||||
// Use updateOnly=false for first rotation (peer has original/empty PSK)
|
if peer.chainKey != nil && peer.expiries == 1 {
|
||||||
if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil {
|
log.Infof("rosenpass key for peer %s expired without renewal, advancing to ratcheted key", wgtypes.Key(peer.PublicKey))
|
||||||
|
psk = RatchetKey(*peer.chainKey)
|
||||||
|
peer.chainKey = &psk
|
||||||
|
} else {
|
||||||
|
rendezvous, err := h.rendezvousKey(peer)
|
||||||
|
if err != nil {
|
||||||
|
// Fail closed: without a rendezvous key the expired key must
|
||||||
|
// still be rotated out, even if the replacement is unusable.
|
||||||
|
log.Errorf("failed to derive rendezvous key, replacing expired key with a random one: %v", err)
|
||||||
|
h.applyRandomKeyLocked(pid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Warnf("rosenpass key for peer %s expired %d times without renewal, falling back to the rendezvous key", wgtypes.Key(peer.PublicKey), peer.expiries)
|
||||||
|
psk = rendezvous
|
||||||
|
peer.chainKey = nil
|
||||||
|
peer.initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
h.applyKeyLocked(pid, psk, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rendezvousKey returns the key both ends converge on without communication:
|
||||||
|
// the account-level preshared key when configured, the deterministic seed key
|
||||||
|
// otherwise. It mirrors the key that peer connections program when Rosenpass
|
||||||
|
// does not manage the peer yet.
|
||||||
|
func (h *NetbirdHandler) rendezvousKey(peer *wireGuardPeer) (wgtypes.Key, error) {
|
||||||
|
if h.preSharedKey != nil {
|
||||||
|
return *h.preSharedKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seed, err := DeterministicSeedKey(h.localWgKey.String(), wgtypes.Key(peer.PublicKey).String())
|
||||||
|
if err != nil {
|
||||||
|
return wgtypes.Key{}, err
|
||||||
|
}
|
||||||
|
return *seed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyKeyLocked writes the preshared key for the peer to the WireGuard
|
||||||
|
// interface and reports whether the write succeeded. Callers must hold h.mu
|
||||||
|
// for the whole state-mutation-plus-write so that a concurrent completion and
|
||||||
|
// expiry cannot reorder their writes relative to the in-memory chain key.
|
||||||
|
func (h *NetbirdHandler) applyKeyLocked(pid rp.PeerID, psk wgtypes.Key, updateOnly bool) bool {
|
||||||
|
peer, ok := h.peers[pid]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.iface == nil {
|
||||||
|
log.Warn("rosenpass: interface not set, cannot update preshared key")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
peerKey := wgtypes.Key(peer.PublicKey).String()
|
||||||
|
if err := h.iface.SetPresharedKey(peerKey, psk, updateOnly); err != nil {
|
||||||
log.Errorf("Failed to apply rosenpass key: %v", err)
|
log.Errorf("Failed to apply rosenpass key: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NetbirdHandler) applyRandomKeyLocked(pid rp.PeerID) {
|
||||||
|
key, err := rp.GeneratePresharedKey()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to generate random preshared key: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.applyKeyLocked(pid, wgtypes.Key(key), true)
|
||||||
// Mark peer as isInitialized after the successful first rotation
|
|
||||||
if !isInitialized {
|
|
||||||
h.mu.Lock()
|
|
||||||
if _, exists := h.peers[pid]; exists {
|
|
||||||
h.initializedPeers[pid] = true
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
250
client/internal/rosenpass/netbird_handler_test.go
Normal file
250
client/internal/rosenpass/netbird_handler_test.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package rosenpass
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
rp "cunicu.li/go-rosenpass"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handlerTestLink wires two NetbirdHandlers as the two ends of a single
|
||||||
|
// tunnel: handler A manages the rosenpass peer B and vice versa, the way two
|
||||||
|
// NetBird clients see each other.
|
||||||
|
type handlerTestLink struct {
|
||||||
|
handlerA, handlerB *NetbirdHandler
|
||||||
|
ifaceA, ifaceB *mockIface
|
||||||
|
pidA, pidB rp.PeerID
|
||||||
|
wgKeyA, wgKeyB wgtypes.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHandlerTestLink(t *testing.T, preSharedKey *[32]byte) *handlerTestLink {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
link := &handlerTestLink{
|
||||||
|
ifaceA: &mockIface{},
|
||||||
|
ifaceB: &mockIface{},
|
||||||
|
}
|
||||||
|
link.pidA[0] = 0xaa
|
||||||
|
link.pidB[0] = 0xbb
|
||||||
|
link.wgKeyA[31] = 1
|
||||||
|
link.wgKeyB[31] = 2
|
||||||
|
|
||||||
|
link.handlerA = NewNetbirdHandler(preSharedKey, link.wgKeyA)
|
||||||
|
link.handlerB = NewNetbirdHandler(preSharedKey, link.wgKeyB)
|
||||||
|
|
||||||
|
link.handlerA.SetInterface(link.ifaceA)
|
||||||
|
link.handlerB.SetInterface(link.ifaceB)
|
||||||
|
|
||||||
|
link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB))
|
||||||
|
link.handlerB.AddPeer(link.pidA, "wt0", rp.Key(link.wgKeyA))
|
||||||
|
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
|
||||||
|
// complete simulates a completed rosenpass exchange: both ends derive the
|
||||||
|
// same output key.
|
||||||
|
func (l *handlerTestLink) complete(osk rp.Key) {
|
||||||
|
l.handlerA.HandshakeCompleted(l.pidB, osk)
|
||||||
|
l.handlerB.HandshakeCompleted(l.pidA, osk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expire simulates a failed key renewal on both ends.
|
||||||
|
func (l *handlerTestLink) expire() {
|
||||||
|
l.handlerA.HandshakeExpired(l.pidB)
|
||||||
|
l.handlerB.HandshakeExpired(l.pidA)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastPSK(t *testing.T, m *mockIface) wgtypes.Key {
|
||||||
|
t.Helper()
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
require.NotEmpty(t, m.calls, "expected at least one SetPresharedKey call")
|
||||||
|
return m.calls[len(m.calls)-1].psk
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandshakeCompleted_SetsKeyAndInitializes(t *testing.T) {
|
||||||
|
link := newHandlerTestLink(t, nil)
|
||||||
|
|
||||||
|
var osk rp.Key
|
||||||
|
osk[0] = 0x42
|
||||||
|
link.complete(osk)
|
||||||
|
|
||||||
|
require.Equal(t, wgtypes.Key(osk), lastPSK(t, link.ifaceA), "completed exchange must program the osk")
|
||||||
|
require.False(t, link.ifaceA.calls[0].updateOnly, "first rotation must not be update-only")
|
||||||
|
require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized after first completed exchange")
|
||||||
|
|
||||||
|
link.complete(osk)
|
||||||
|
require.True(t, link.ifaceA.calls[1].updateOnly, "later rotations must be update-only")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandshakeExpired_BothSidesConverge encodes the core recovery invariant:
|
||||||
|
// rosenpass renewals run over the tunnel that the PSK itself keys, so when a
|
||||||
|
// renewal fails on both ends, both ends must fall back to the same key or the
|
||||||
|
// tunnel can never handshake again.
|
||||||
|
func TestHandshakeExpired_BothSidesConverge(t *testing.T) {
|
||||||
|
link := newHandlerTestLink(t, nil)
|
||||||
|
|
||||||
|
var osk rp.Key
|
||||||
|
osk[0] = 0x42
|
||||||
|
link.complete(osk)
|
||||||
|
|
||||||
|
link.expire()
|
||||||
|
keyA := lastPSK(t, link.ifaceA)
|
||||||
|
keyB := lastPSK(t, link.ifaceB)
|
||||||
|
require.NotEqual(t, wgtypes.Key(osk), keyA, "expired key must be rotated out")
|
||||||
|
require.Equal(t, keyA, keyB, "both ends must converge on the same key after expiry")
|
||||||
|
|
||||||
|
link.expire()
|
||||||
|
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
|
||||||
|
"both ends must still converge after repeated expiries")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandshakeExpired_ExpiryWithoutCompletionConverges covers the bootstrap
|
||||||
|
// case: the initial exchange never completed (the tunnel ran on the rendezvous
|
||||||
|
// key), so an expiry must not replace the working key with an unrecoverable
|
||||||
|
// one on either end.
|
||||||
|
func TestHandshakeExpired_ExpiryWithoutCompletionConverges(t *testing.T) {
|
||||||
|
link := newHandlerTestLink(t, nil)
|
||||||
|
|
||||||
|
link.expire()
|
||||||
|
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
|
||||||
|
"both ends must converge when the exchange never completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandshakeExpired_RepeatedExpiryClearsInitialized: once renewals keep
|
||||||
|
// failing, the peer must drop out of the initialized state so the next
|
||||||
|
// connection reconfiguration reprograms the rendezvous key instead of
|
||||||
|
// preserving a poisoned rosenpass-managed key.
|
||||||
|
func TestHandshakeExpired_RepeatedExpiryClearsInitialized(t *testing.T) {
|
||||||
|
link := newHandlerTestLink(t, nil)
|
||||||
|
|
||||||
|
var osk rp.Key
|
||||||
|
osk[0] = 0x42
|
||||||
|
link.complete(osk)
|
||||||
|
|
||||||
|
link.expire()
|
||||||
|
link.expire()
|
||||||
|
|
||||||
|
require.False(t, link.handlerA.IsPeerInitialized(link.pidB),
|
||||||
|
"repeated expiries must clear the initialized state")
|
||||||
|
require.False(t, link.handlerB.IsPeerInitialized(link.pidA),
|
||||||
|
"repeated expiries must clear the initialized state")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandshakeCompleted_AfterExpiryRecovers: a completed exchange after a
|
||||||
|
// desync must fully reset the recovery state.
|
||||||
|
func TestHandshakeCompleted_AfterExpiryRecovers(t *testing.T) {
|
||||||
|
link := newHandlerTestLink(t, nil)
|
||||||
|
|
||||||
|
var osk1, osk2 rp.Key
|
||||||
|
osk1[0] = 1
|
||||||
|
osk2[0] = 2
|
||||||
|
|
||||||
|
link.complete(osk1)
|
||||||
|
link.expire()
|
||||||
|
link.expire()
|
||||||
|
|
||||||
|
link.complete(osk2)
|
||||||
|
require.Equal(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "new exchange must program the fresh osk")
|
||||||
|
require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized again after recovery")
|
||||||
|
|
||||||
|
link.expire()
|
||||||
|
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
|
||||||
|
"recovered link must converge again on the next expiry")
|
||||||
|
require.NotEqual(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "expired key must be rotated out")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandshakeExpired_FirstExpiryRatchetsLastKey: the first expiry must
|
||||||
|
// derive the replacement from the last shared key, so an attacker who only
|
||||||
|
// blocks the renewal exchange gains nothing over the previous key.
|
||||||
|
func TestHandshakeExpired_FirstExpiryRatchetsLastKey(t *testing.T) {
|
||||||
|
link := newHandlerTestLink(t, nil)
|
||||||
|
|
||||||
|
var osk rp.Key
|
||||||
|
osk[0] = 0x42
|
||||||
|
link.complete(osk)
|
||||||
|
|
||||||
|
link.expire()
|
||||||
|
require.Equal(t, RatchetKey(wgtypes.Key(osk)), lastPSK(t, link.ifaceA),
|
||||||
|
"first expiry must program the ratcheted key")
|
||||||
|
require.True(t, link.handlerA.IsPeerInitialized(link.pidB),
|
||||||
|
"ratchet step must keep the peer initialized so reconfigurations preserve the key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandshakeExpired_RepeatedExpiryFallsBackToSeed: once the ratchet key
|
||||||
|
// also fails, both ends must land on the same key that peer connections
|
||||||
|
// program for uninitialized peers, so a reconnect completes the recovery.
|
||||||
|
func TestHandshakeExpired_RepeatedExpiryFallsBackToSeed(t *testing.T) {
|
||||||
|
link := newHandlerTestLink(t, nil)
|
||||||
|
|
||||||
|
var osk rp.Key
|
||||||
|
osk[0] = 0x42
|
||||||
|
link.complete(osk)
|
||||||
|
|
||||||
|
link.expire()
|
||||||
|
link.expire()
|
||||||
|
|
||||||
|
seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, *seed, lastPSK(t, link.ifaceA), "repeated expiry must fall back to the seed key")
|
||||||
|
require.Equal(t, *seed, lastPSK(t, link.ifaceB), "repeated expiry must fall back to the seed key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous: with an account-level
|
||||||
|
// preshared key configured, the fallback must be that key, matching what peer
|
||||||
|
// connections program for uninitialized peers.
|
||||||
|
func TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous(t *testing.T) {
|
||||||
|
psk := &[32]byte{0x77}
|
||||||
|
link := newHandlerTestLink(t, psk)
|
||||||
|
|
||||||
|
var osk rp.Key
|
||||||
|
osk[0] = 0x42
|
||||||
|
link.complete(osk)
|
||||||
|
|
||||||
|
link.expire()
|
||||||
|
link.expire()
|
||||||
|
|
||||||
|
require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceA),
|
||||||
|
"fallback must be the configured preshared key")
|
||||||
|
require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceB),
|
||||||
|
"fallback must be the configured preshared key on both ends")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandshakeExpired_ExpiryWritesAreUpdateOnly: expiry replacements must
|
||||||
|
// never create a WireGuard peer that connection management has removed.
|
||||||
|
func TestHandshakeExpired_ExpiryWritesAreUpdateOnly(t *testing.T) {
|
||||||
|
link := newHandlerTestLink(t, nil)
|
||||||
|
|
||||||
|
var osk rp.Key
|
||||||
|
osk[0] = 0x42
|
||||||
|
link.complete(osk)
|
||||||
|
|
||||||
|
link.expire()
|
||||||
|
link.expire()
|
||||||
|
|
||||||
|
for _, call := range link.ifaceA.calls[1:] {
|
||||||
|
require.True(t, call.updateOnly, "expiry writes must be update-only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddPeer_ReAddKeepsRecoveryState: reconnections re-add the peer on every
|
||||||
|
// OnConnected; that must not reset the expiry chain state.
|
||||||
|
func TestAddPeer_ReAddKeepsRecoveryState(t *testing.T) {
|
||||||
|
link := newHandlerTestLink(t, nil)
|
||||||
|
|
||||||
|
var osk rp.Key
|
||||||
|
osk[0] = 0x42
|
||||||
|
link.complete(osk)
|
||||||
|
link.expire()
|
||||||
|
|
||||||
|
link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB))
|
||||||
|
require.True(t, link.handlerA.IsPeerInitialized(link.pidB),
|
||||||
|
"re-adding a known peer must keep its state")
|
||||||
|
|
||||||
|
link.expire()
|
||||||
|
seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, *seed, lastPSK(t, link.ifaceA),
|
||||||
|
"second expiry after re-add must continue to the seed fallback")
|
||||||
|
}
|
||||||
@@ -1,11 +1,28 @@
|
|||||||
package rosenpass
|
package rosenpass
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ratchetLabel domain-separates the expiry ratchet from other uses of the
|
||||||
|
// rosenpass output key.
|
||||||
|
const ratchetLabel = "netbird-rosenpass-expiry-ratchet"
|
||||||
|
|
||||||
|
// RatchetKey derives the successor preshared key from the previous Rosenpass
|
||||||
|
// output key. When a key expires without a completed renewal, both peers
|
||||||
|
// advance their last shared key by one ratchet step: the expired key is
|
||||||
|
// rotated out while both ends still converge on an identical, non-public
|
||||||
|
// replacement without communicating.
|
||||||
|
func RatchetKey(prev wgtypes.Key) wgtypes.Key {
|
||||||
|
input := make([]byte, 0, len(ratchetLabel)+len(prev))
|
||||||
|
input = append(input, ratchetLabel...)
|
||||||
|
input = append(input, prev[:]...)
|
||||||
|
return sha256.Sum256(input)
|
||||||
|
}
|
||||||
|
|
||||||
// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
|
// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
|
||||||
// of peer public keys. Both peers, given the same key pair, produce the same
|
// of peer public keys. Both peers, given the same key pair, produce the same
|
||||||
// output regardless of which side runs the function: the inputs are ordered
|
// output regardless of which side runs the function: the inputs are ordered
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Route) update(ctx context.Context) error {
|
func (r *Route) update(ctx context.Context) error {
|
||||||
resolved, err := r.resolveDomains()
|
resolved, err := r.resolveDomains(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if len(resolved) == 0 {
|
if len(resolved) == 0 {
|
||||||
return fmt.Errorf("resolve domains: %w", err)
|
return fmt.Errorf("resolve domains: %w", err)
|
||||||
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Route) resolveDomains() (domainMap, error) {
|
func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
|
||||||
results := make(chan resolveResult)
|
results := make(chan resolveResult)
|
||||||
go r.resolve(results)
|
go r.resolve(ctx, results)
|
||||||
|
|
||||||
resolved := domainMap{}
|
resolved := domainMap{}
|
||||||
var merr *multierror.Error
|
var merr *multierror.Error
|
||||||
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) {
|
|||||||
return resolved, nberrors.FormatErrorOrNil(merr)
|
return resolved, nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Route) resolve(results chan resolveResult) {
|
func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for _, d := range r.route.Domains {
|
for _, d := range r.route.Domains {
|
||||||
@@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) {
|
|||||||
go func(domain domain.Domain) {
|
go func(domain domain.Domain) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
ips, err := r.getIPsFromResolver(domain)
|
ips, err := r.getIPsFromResolver(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
|
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
|
||||||
ips, err = net.LookupIP(domain.PunycodeString())
|
ips, err = lookupHostIPs(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
|
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
|
||||||
return
|
return
|
||||||
@@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
|
||||||
|
func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
|
||||||
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ips := make([]net.IP, 0, len(addrs))
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ips = append(ips, addr.IP)
|
||||||
|
}
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
|
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
|
||||||
prefixSet := make(map[netip.Prefix]struct{})
|
prefixSet := make(map[netip.Prefix]struct{})
|
||||||
for _, prefix := range oldPrefixes {
|
for _, prefix := range oldPrefixes {
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
package dynamic
|
package dynamic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||||
return net.LookupIP(domain.PunycodeString())
|
return lookupHostIPs(ctx, domain)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package dynamic
|
package dynamic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
|
|
||||||
const dialTimeout = 10 * time.Second
|
const dialTimeout = 10 * time.Second
|
||||||
|
|
||||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||||
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
|
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error while creating private client: %s", err)
|
return nil, fmt.Errorf("error while creating private client: %s", err)
|
||||||
@@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
|||||||
msg := new(dns.Msg)
|
msg := new(dns.Msg)
|
||||||
msg.SetQuestion(fqdn, qtype)
|
msg.SetQuestion(fqdn, qtype)
|
||||||
|
|
||||||
response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
|
response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if queryErr == nil {
|
if queryErr == nil {
|
||||||
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
|
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user