mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-30 04:11:28 +02:00
Compare commits
16 Commits
android/gu
...
netmap_pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d613189f6 | ||
|
|
7673067605 | ||
|
|
79567fe347 | ||
|
|
cf8d92fbb0 | ||
|
|
b70fc4015b | ||
|
|
4988b6726e | ||
|
|
2552830184 | ||
|
|
3b8fc688f4 | ||
|
|
d82d62e818 | ||
|
|
0bf964dad7 | ||
|
|
297dcb3e24 | ||
|
|
bc22926fe0 | ||
|
|
d3f2ef9adb | ||
|
|
5bec1e8f03 | ||
|
|
74bb5c613e | ||
|
|
29dde908ae |
@@ -1,18 +0,0 @@
|
|||||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
|
||||||
language: en-US
|
|
||||||
reviews:
|
|
||||||
profile: chill
|
|
||||||
request_changes_workflow: false
|
|
||||||
high_level_summary: true
|
|
||||||
poem: false
|
|
||||||
review_status: true
|
|
||||||
auto_review:
|
|
||||||
enabled: true
|
|
||||||
drafts: false
|
|
||||||
path_filters:
|
|
||||||
- "!**/*.tsx"
|
|
||||||
- "!**/*.ts"
|
|
||||||
- "!**/*.js"
|
|
||||||
- "!**/*.svg"
|
|
||||||
chat:
|
|
||||||
auto_reply: true
|
|
||||||
@@ -6,6 +6,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
|||||||
iptables=1.8.9-2 \
|
iptables=1.8.9-2 \
|
||||||
libgl1-mesa-dev=22.3.6-1+deb12u1 \
|
libgl1-mesa-dev=22.3.6-1+deb12u1 \
|
||||||
xorg-dev=1:7.7+23 \
|
xorg-dev=1:7.7+23 \
|
||||||
|
libayatana-appindicator3-dev=0.5.92-1 \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& go install -v golang.org/x/tools/gopls@latest
|
&& go install -v golang.org/x/tools/gopls@latest
|
||||||
|
|||||||
12
.github/workflows/agent-network-e2e.yml
vendored
12
.github/workflows/agent-network-e2e.yml
vendored
@@ -5,13 +5,6 @@ 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 }}
|
||||||
@@ -58,9 +51,6 @@ 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 }}
|
||||||
@@ -69,8 +59,6 @@ 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
98
.github/workflows/frontend-ui.yml
vendored
@@ -1,98 +0,0 @@
|
|||||||
name: UI Frontend
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "client/ui/frontend/**"
|
|
||||||
- "client/ui/i18n/**"
|
|
||||||
- "client/ui/**/*.go"
|
|
||||||
- ".github/workflows/frontend-ui.yml"
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "client/ui/frontend/**"
|
|
||||||
- "client/ui/i18n/**"
|
|
||||||
- "client/ui/**/*.go"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint-and-build:
|
|
||||||
name: Lint & Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: client/ui/frontend
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
|
|
||||||
- name: Set up pnpm
|
|
||||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
|
||||||
with:
|
|
||||||
version: 11
|
|
||||||
|
|
||||||
# Bindings are generated by wails3 from the Go service definitions and
|
|
||||||
# are not checked in (see client/ui/frontend/bindings/). Without them,
|
|
||||||
# typecheck/build fail on missing module imports.
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
|
||||||
with:
|
|
||||||
go-version-file: "go.mod"
|
|
||||||
cache: false
|
|
||||||
|
|
||||||
# wails3 CLI links against GTK4 / WebKitGTK 6.0 via its internal/operatingsystem
|
|
||||||
# package, so the dev libraries must be present before `go install`.
|
|
||||||
- name: Install Wails Linux system dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
pkg-config \
|
|
||||||
libgtk-4-dev \
|
|
||||||
libwebkitgtk-6.0-dev
|
|
||||||
|
|
||||||
- name: Install wails3 CLI
|
|
||||||
# Version derived from go.mod so the binding generator always matches
|
|
||||||
# the wails runtime the daemon links against.
|
|
||||||
working-directory: ${{ github.workspace }}
|
|
||||||
run: |
|
|
||||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
|
||||||
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
id: pnpm-store
|
|
||||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache pnpm store
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ${{ steps.pnpm-store.outputs.path }}
|
|
||||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('client/ui/frontend/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile --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,15 +45,7 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged)
|
||||||
# which fails to compile until the frontend has been built. The Wails UI
|
|
||||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
|
||||||
# before goreleaser.
|
|
||||||
# `go list -e` lets the listing succeed even though the embed fails to
|
|
||||||
# resolve; the grep then drops the broken package by path. Without -e,
|
|
||||||
# go list aborts with empty stdout and `go test` falls back to the repo
|
|
||||||
# root, which has no Go files.
|
|
||||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged)
|
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||||
|
|||||||
17
.github/workflows/golang-test-linux.yml
vendored
17
.github/workflows/golang-test-linux.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||||
|
|
||||||
- name: Install 32-bit libpcap
|
- name: Install 32-bit libpcap
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
@@ -145,7 +145,7 @@ jobs:
|
|||||||
${{ runner.os }}-gotest-cache-
|
${{ runner.os }}-gotest-cache-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||||
|
|
||||||
- name: Install 32-bit libpcap
|
- name: Install 32-bit libpcap
|
||||||
if: matrix.arch == '386'
|
if: matrix.arch == '386'
|
||||||
@@ -158,15 +158,7 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,CGO_ENABLED' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged)
|
||||||
# which fails to compile until the frontend has been built. The Wails UI
|
|
||||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
|
||||||
# before goreleaser.
|
|
||||||
# `go list -e` lets the listing succeed even though the embed fails to
|
|
||||||
# resolve; the grep then drops the broken package by path. Without -e,
|
|
||||||
# go list aborts with empty stdout and `go test` falls back to the repo
|
|
||||||
# root, which has no Go files.
|
|
||||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,CGO_ENABLED' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged)
|
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
@@ -176,6 +168,7 @@ jobs:
|
|||||||
slug: netbirdio/netbird
|
slug: netbirdio/netbird
|
||||||
flags: unit,client
|
flags: unit,client
|
||||||
|
|
||||||
|
|
||||||
test_client_on_docker:
|
test_client_on_docker:
|
||||||
name: "Client (Docker) / Unit"
|
name: "Client (Docker) / Unit"
|
||||||
needs: [build-cache]
|
needs: [build-cache]
|
||||||
@@ -236,7 +229,7 @@ jobs:
|
|||||||
sh -c ' \
|
sh -c ' \
|
||||||
apk update; apk add --no-cache \
|
apk update; apk add --no-cache \
|
||||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
||||||
go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged)
|
go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged)
|
||||||
'
|
'
|
||||||
|
|
||||||
test_relay:
|
test_relay:
|
||||||
|
|||||||
9
.github/workflows/golang-test-windows.yml
vendored
9
.github/workflows/golang-test-windows.yml
vendored
@@ -65,15 +65,8 @@ jobs:
|
|||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
||||||
- name: Generate test script
|
- name: Generate test script
|
||||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
|
||||||
# which fails to compile until the frontend has been built. The Wails UI
|
|
||||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
|
||||||
# before goreleaser.
|
|
||||||
# `go list -e` lets the listing succeed even though the embed fails to
|
|
||||||
# resolve; the Where-Object pipeline then drops the broken package by
|
|
||||||
# path. Without -e, go list aborts with empty stdout.
|
|
||||||
run: |
|
run: |
|
||||||
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' }
|
$packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
|
||||||
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
||||||
$cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
$cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
||||||
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
||||||
|
|||||||
25
.github/workflows/golangci-lint.yml
vendored
25
.github/workflows/golangci-lint.yml
vendored
@@ -22,15 +22,7 @@ jobs:
|
|||||||
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals,flate,recordin,unparseable
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals,flate,recordin,unparseable
|
||||||
# Non-English UI translations trip codespell on real foreign words
|
skip: go.mod,go.sum,**/proxy/web/**
|
||||||
# (de: "Sie", "oder", "ist"). Only en/common.json is the source of
|
|
||||||
# truth that should be spell-checked. List each translated locale
|
|
||||||
# dir below and add new ones as languages are added under
|
|
||||||
# client/ui/i18n/locales/. Single-star globs are matched per path
|
|
||||||
# segment by codespell and behave the same across versions; the
|
|
||||||
# recursive "**" form did not take effect with the codespell shipped
|
|
||||||
# by this action.
|
|
||||||
skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md
|
|
||||||
golangci:
|
golangci:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -45,7 +37,7 @@ jobs:
|
|||||||
display_name: Linux
|
display_name: Linux
|
||||||
name: ${{ matrix.display_name }}
|
name: ${{ matrix.display_name }}
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 25
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
@@ -62,16 +54,7 @@ jobs:
|
|||||||
cache: false
|
cache: false
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
- name: Stub Wails frontend bundle
|
|
||||||
# client/ui/main.go has //go:embed all:frontend/dist. The
|
|
||||||
# directory is produced by `pnpm run build` and is gitignored, so
|
|
||||||
# lint-only runs (no frontend toolchain) need a placeholder file
|
|
||||||
# for the embed pattern to match.
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir -p client/ui/frontend/dist
|
|
||||||
touch client/ui/frontend/dist/.embed-placeholder
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||||
with:
|
with:
|
||||||
@@ -79,4 +62,4 @@ jobs:
|
|||||||
skip-cache: true
|
skip-cache: true
|
||||||
skip-save-cache: true
|
skip-save-cache: true
|
||||||
cache-invalidation-interval: 0
|
cache-invalidation-interval: 0
|
||||||
args: --timeout=20m
|
args: --timeout=12m
|
||||||
|
|||||||
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.8"
|
SIGN_PIPE_VER: "v0.1.6"
|
||||||
GORELEASER_VER: "v2.16.0"
|
GORELEASER_VER: "v2.16.0"
|
||||||
PRODUCT_NAME: "NetBird"
|
PRODUCT_NAME: "NetBird"
|
||||||
COPYRIGHT: "NetBird GmbH"
|
COPYRIGHT: "NetBird GmbH"
|
||||||
@@ -216,9 +216,9 @@ jobs:
|
|||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||||
- name: Generate windows syso amd64
|
- name: Generate windows syso amd64
|
||||||
run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
run: goversioninfo -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
@@ -397,18 +397,8 @@ jobs:
|
|||||||
- name: check git status
|
- name: check git status
|
||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Set up pnpm
|
|
||||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
|
||||||
with:
|
|
||||||
version: 11
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc-mingw-w64-x86-64
|
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
- name: Decode GPG signing key
|
- name: Decode GPG signing key
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
@@ -427,16 +417,10 @@ jobs:
|
|||||||
echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
|
echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
|
||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||||
- name: Install wails3 CLI
|
|
||||||
# Version derived from go.mod so the binding generator always matches
|
|
||||||
# the wails runtime the binary links against.
|
|
||||||
run: |
|
|
||||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
|
||||||
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
|
||||||
- name: Generate windows syso amd64
|
- name: Generate windows syso amd64
|
||||||
run: goversioninfo -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso
|
run: goversioninfo -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
@@ -505,20 +489,6 @@ jobs:
|
|||||||
run: go mod tidy
|
run: go mod tidy
|
||||||
- name: check git status
|
- name: check git status
|
||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
- name: Set up pnpm
|
|
||||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
|
||||||
with:
|
|
||||||
version: 11
|
|
||||||
- name: Install wails3 CLI
|
|
||||||
# Version derived from go.mod so the binding generator always matches
|
|
||||||
# the wails runtime the binary links against.
|
|
||||||
run: |
|
|
||||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
|
||||||
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
@@ -606,6 +576,23 @@ jobs:
|
|||||||
- name: Move wintun.dll into dist
|
- name: Move wintun.dll into dist
|
||||||
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||||
|
|
||||||
|
- name: Download Mesa3D (amd64 only)
|
||||||
|
id: download-mesa3d
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
|
with:
|
||||||
|
url: https://pkgs.netbird.io/mesa3d/MesaForWindows-x64-20.1.8.7z
|
||||||
|
destination: ${{ env.downloadPath }}\mesa3d.7z
|
||||||
|
sha256: 71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9
|
||||||
|
|
||||||
|
- name: Extract Mesa3D driver (amd64 only)
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
run: 7z x -o"${{ env.downloadPath }}" "${{ env.downloadPath }}/mesa3d.7z"
|
||||||
|
|
||||||
|
- name: Move opengl32.dll into dist (amd64 only)
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||||
|
|
||||||
- name: Download EnVar plugin for NSIS
|
- name: Download EnVar plugin for NSIS
|
||||||
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
@@ -628,28 +615,6 @@ jobs:
|
|||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
||||||
|
|
||||||
- name: Set up Go for wails3 CLI
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: "go.mod"
|
|
||||||
cache: false
|
|
||||||
|
|
||||||
- name: Install wails3 CLI
|
|
||||||
# Version derived from go.mod so the bootstrapper payload always
|
|
||||||
# matches the wails runtime the binary links against.
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
|
||||||
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
|
||||||
|
|
||||||
- name: Stage WebView2 bootstrapper for installers
|
|
||||||
# Both client/installer.nsis and client/netbird.wxs reference
|
|
||||||
# client/MicrosoftEdgeWebview2Setup.exe. wails3 writes it there.
|
|
||||||
# The signing pipeline (netbirdio/sign-pipelines) does the same
|
|
||||||
# step for release builds; this mirrors it for PR sanity testing.
|
|
||||||
shell: bash
|
|
||||||
run: wails3 generate webview2bootstrapper -dir client
|
|
||||||
|
|
||||||
- name: Build NSIS installer
|
- name: Build NSIS installer
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
|
|||||||
2
.github/workflows/wasm-build-validation.yml
vendored
2
.github/workflows/wasm-build-validation.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
- name: Install golangci-lint
|
- name: Install golangci-lint
|
||||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -114,16 +114,6 @@ linters:
|
|||||||
- linters:
|
- linters:
|
||||||
- staticcheck
|
- staticcheck
|
||||||
text: "QF1012"
|
text: "QF1012"
|
||||||
# client/ui/main.go uses //go:embed all:frontend/dist; the
|
|
||||||
# directory is populated by `pnpm build` in the release pipeline
|
|
||||||
# and missing at lint time, so the embed parses to "no matching
|
|
||||||
# files found" — surfaced by golangci-lint's typecheck pre-pass.
|
|
||||||
# Suppress just that one diagnostic; the rest of the package
|
|
||||||
# (services/, tray.go, grpc.go, ...) still gets linted normally.
|
|
||||||
- linters:
|
|
||||||
- typecheck
|
|
||||||
path: client/ui/main\.go
|
|
||||||
text: "pattern all:frontend/dist"
|
|
||||||
paths:
|
paths:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
|
|||||||
@@ -212,7 +212,6 @@ nfpms:
|
|||||||
description: Netbird client.
|
description: Netbird client.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
license: BSD-3-Clause
|
license: BSD-3-Clause
|
||||||
vendor: NetBird
|
|
||||||
id: netbird_deb
|
id: netbird_deb
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
builds:
|
builds:
|
||||||
@@ -227,7 +226,6 @@ nfpms:
|
|||||||
description: Netbird client.
|
description: Netbird client.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
license: BSD-3-Clause
|
license: BSD-3-Clause
|
||||||
vendor: NetBird
|
|
||||||
id: netbird_rpm
|
id: netbird_rpm
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
builds:
|
builds:
|
||||||
@@ -273,8 +271,8 @@ dockers_v2:
|
|||||||
- netbirdio/netbird
|
- netbirdio/netbird
|
||||||
- ghcr.io/netbirdio/netbird
|
- ghcr.io/netbirdio/netbird
|
||||||
tags:
|
tags:
|
||||||
- "{{ .Version }}-rootless"
|
- "v{{ .Version }}-rootless"
|
||||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}"
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||||
dockerfile: client/Dockerfile-rootless
|
dockerfile: client/Dockerfile-rootless
|
||||||
extra_files:
|
extra_files:
|
||||||
- client/netbird-entrypoint.sh
|
- client/netbird-entrypoint.sh
|
||||||
|
|||||||
@@ -2,15 +2,6 @@ version: 2
|
|||||||
env:
|
env:
|
||||||
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
||||||
project_name: netbird-ui
|
project_name: netbird-ui
|
||||||
|
|
||||||
before:
|
|
||||||
hooks:
|
|
||||||
# Bindings are gitignored; regenerate before the frontend build so
|
|
||||||
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
|
||||||
# build without them).
|
|
||||||
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts'
|
|
||||||
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: netbird-ui
|
- id: netbird-ui
|
||||||
dir: client/ui
|
dir: client/ui
|
||||||
@@ -71,8 +62,6 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
license: BSD-3-Clause
|
|
||||||
vendor: NetBird
|
|
||||||
id: netbird_ui_deb
|
id: netbird_ui_deb
|
||||||
package_name: netbird-ui
|
package_name: netbird-ui
|
||||||
builds:
|
builds:
|
||||||
@@ -82,9 +71,9 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/linux/netbird.desktop
|
- src: client/ui/build/netbird.desktop
|
||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/assets/netbird.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
@@ -92,8 +81,6 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
license: BSD-3-Clause
|
|
||||||
vendor: NetBird
|
|
||||||
id: netbird_ui_rpm
|
id: netbird_ui_rpm
|
||||||
package_name: netbird-ui
|
package_name: netbird-ui
|
||||||
builds:
|
builds:
|
||||||
@@ -103,13 +90,12 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/linux/netbird.desktop
|
- src: client/ui/build/netbird.desktop
|
||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/assets/netbird.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
|
|
||||||
rpm:
|
rpm:
|
||||||
signature:
|
signature:
|
||||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
project_name: netbird-ui
|
project_name: netbird-ui
|
||||||
|
|
||||||
before:
|
|
||||||
hooks:
|
|
||||||
# Bindings are gitignored; regenerate before the frontend build so
|
|
||||||
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
|
||||||
# build without them).
|
|
||||||
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts'
|
|
||||||
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: netbird-ui-darwin
|
- id: netbird-ui-darwin
|
||||||
dir: client/ui
|
dir: client/ui
|
||||||
@@ -29,6 +20,8 @@ builds:
|
|||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
|
tags:
|
||||||
|
- load_wgnt_from_rsrc
|
||||||
|
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
- id: netbird-ui-darwin
|
- id: netbird-ui-darwin
|
||||||
|
|||||||
@@ -79,21 +79,13 @@ dependencies are installed. Here is a short guide on how that can be done.
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
#### Go 1.25
|
#### Go 1.21
|
||||||
|
|
||||||
Follow the installation guide from https://go.dev/
|
Follow the installation guide from https://go.dev/
|
||||||
|
|
||||||
#### UI client - Wails v3 + React
|
#### UI client - Fyne toolkit
|
||||||
|
|
||||||
The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need:
|
We use the fyne toolkit in our UI client. You can follow its requirement guide to have all its dependencies installed: https://developer.fyne.io/started/#prerequisites
|
||||||
|
|
||||||
- Go ≥ 1.25
|
|
||||||
- Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`)
|
|
||||||
- The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
|
|
||||||
- The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest`
|
|
||||||
- Linux only: `libwebkitgtk-6.0-dev`, `libgtk-4-dev`, `libsoup-3.0-dev`
|
|
||||||
|
|
||||||
All UI build, dev-loop, and cross-compile commands are described in the [UI client](#ui-client) section below.
|
|
||||||
|
|
||||||
#### gRPC
|
#### gRPC
|
||||||
You can follow the instructions from the quickstarter guide https://grpc.io/docs/languages/go/quickstart/#prerequisites and then run the `generate.sh` files located in each `proto` directory to generate changes.
|
You can follow the instructions from the quickstarter guide https://grpc.io/docs/languages/go/quickstart/#prerequisites and then run the `generate.sh` files located in each `proto` directory to generate changes.
|
||||||
@@ -222,39 +214,6 @@ To start NetBird the client in the foreground:
|
|||||||
sudo ./client up --log-level debug --log-file console
|
sudo ./client up --log-level debug --log-file console
|
||||||
```
|
```
|
||||||
> On Windows use a powershell with administrator privileges
|
> On Windows use a powershell with administrator privileges
|
||||||
|
|
||||||
#### UI client
|
|
||||||
|
|
||||||
The desktop UI lives in `client/ui` and is built with Wails v3 (see [Requirements](#ui-client---wails-v3--react)). All commands run from `client/ui`.
|
|
||||||
|
|
||||||
Live-reload development (Vite + Go binary + `*.go` watcher):
|
|
||||||
|
|
||||||
```
|
|
||||||
cd client/ui
|
|
||||||
task dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass daemon flags after `--`:
|
|
||||||
|
|
||||||
```
|
|
||||||
task dev -- --daemon-addr=tcp://127.0.0.1:41731
|
|
||||||
```
|
|
||||||
|
|
||||||
Production build (frontend assets embedded into the binary, output in `client/ui/bin/`):
|
|
||||||
|
|
||||||
```
|
|
||||||
cd client/ui
|
|
||||||
task build
|
|
||||||
```
|
|
||||||
|
|
||||||
Cross-compile the Windows binary from Linux (requires the mingw-w64 toolchain, e.g. `sudo apt install gcc-mingw-w64-x86-64`):
|
|
||||||
|
|
||||||
```
|
|
||||||
CGO_ENABLED=1 task windows:build
|
|
||||||
```
|
|
||||||
|
|
||||||
> macOS cross-compile from Linux is not supported (signing and notarization need a real Mac).
|
|
||||||
|
|
||||||
#### Signal service
|
#### Signal service
|
||||||
|
|
||||||
To start NetBird's signal, execute:
|
To start NetBird's signal, execute:
|
||||||
@@ -292,10 +251,10 @@ Create dist directory
|
|||||||
mkdir -p dist/netbird_windows_amd64
|
mkdir -p dist/netbird_windows_amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
UI client (built with Wails v3 — see the [UI client](#ui-client) section above)
|
UI client
|
||||||
```shell
|
```shell
|
||||||
(cd client/ui && CGO_ENABLED=1 task windows:build)
|
CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -o netbird-ui.exe -ldflags "-s -w -H windowsgui" ./client/ui
|
||||||
mv client/ui/bin/netbird-ui.exe ./dist/netbird_windows_amd64/
|
mv netbird-ui.exe ./dist/netbird_windows_amd64/
|
||||||
```
|
```
|
||||||
|
|
||||||
Client
|
Client
|
||||||
@@ -332,6 +291,8 @@ go test -exec sudo ./...
|
|||||||
```
|
```
|
||||||
> On Windows use a powershell with administrator privileges
|
> On Windows use a powershell with administrator privileges
|
||||||
|
|
||||||
|
> Non-GTK environments will need the `libayatana-appindicator3-dev` (debian/ubuntu) package installed
|
||||||
|
|
||||||
## Checklist before submitting a PR
|
## Checklist before submitting a PR
|
||||||
As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
|
As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
|
||||||
- Keep functions as simple as possible, with a single purpose
|
- Keep functions as simple as possible, with a single purpose
|
||||||
|
|||||||
@@ -1,47 +1,16 @@
|
|||||||
# NetBird Agent Network
|
# NetBird Agent Network
|
||||||
|
|
||||||
Agent Network is NetBird's access control layer for AI agents and the people who run them.
|
Agent Network is NetBird's access control layer for AI agents and the people who run
|
||||||
It gives every agent a real identity, tied to an identity provider (IdP), and governs what it can reach: LLM APIs and
|
them. It gives every agent a real identity, tied to your identity provider (IdP), and
|
||||||
AI gateways it can call, and the internal resources it can access. Traffic flows only over the encrypted NetBird tunnel,
|
governs what it can reach — the LLM APIs and AI gateways it can call, and the internal
|
||||||
scoped by policy, with no API keys or other credentials to leak. It also gives you control over cost and token usage.
|
resources it can access. Traffic flows only over the encrypted NetBird tunnel, scoped by
|
||||||
|
policy, with no API keys to leak.
|
||||||
|
|
||||||
Because every LLM request passes through an
|
> **Beta.** Agent Network is open source and can be self-hosted on your own
|
||||||
identity-aware proxy, you can:
|
> infrastructure.
|
||||||
|
|
||||||
- **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.
|
||||||
@@ -53,9 +22,6 @@ 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
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -76,24 +75,6 @@ type Client struct {
|
|||||||
connectClient *internal.ConnectClient
|
connectClient *internal.ConnectClient
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
cacheDir string
|
cacheDir string
|
||||||
|
|
||||||
stateChangeMu sync.Mutex
|
|
||||||
stateChangeSubID string
|
|
||||||
eventSub *peer.EventSubscription
|
|
||||||
// Closed to stop the watch goroutines from delivering buffered items to a
|
|
||||||
// listener that has been removed or replaced. See stopStateChangeWatchLocked.
|
|
||||||
stateChangeDone chan struct{}
|
|
||||||
|
|
||||||
// Latched "the server wants an interactive login": survives the engine
|
|
||||||
// restarts that replace the run loop's context state. See Client.Status.
|
|
||||||
// Guarded by loginRequiredMu together with loginCleared, which counts
|
|
||||||
// clears so a stale observation cannot re-latch over one.
|
|
||||||
loginRequiredMu sync.Mutex
|
|
||||||
loginRequired bool
|
|
||||||
loginCleared uint64
|
|
||||||
|
|
||||||
extendMu sync.Mutex
|
|
||||||
extendCancel context.CancelFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
||||||
@@ -167,16 +148,11 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
c.setState(cfg, cacheDir, connectClient)
|
c.setState(cfg, cacheDir, connectClient)
|
||||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
|
||||||
// is authenticated again — release the latch Status() reports from. Clear
|
|
||||||
// only once the fresh connect client is installed: until then Status()
|
|
||||||
// still reads the previous run's context state, which holds the NeedsLogin
|
|
||||||
// that prompted this login, and would re-latch what was just cleared.
|
|
||||||
c.clearLoginRequired()
|
|
||||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,9 +247,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -323,13 +296,6 @@ func (c *Client) SetInfoLogLevel() {
|
|||||||
// PeersList return with the list of the PeerInfos
|
// PeersList return with the list of the PeerInfos
|
||||||
func (c *Client) PeersList() *PeerInfoArray {
|
func (c *Client) PeersList() *PeerInfoArray {
|
||||||
|
|
||||||
// The recorder only caches transfer counters and handshake times; nothing
|
|
||||||
// refreshes them on its own, so without this they read as zero. The desktop
|
|
||||||
// daemon does the same before serving a full peer status.
|
|
||||||
if err := c.recorder.RefreshWireGuardStats(); err != nil {
|
|
||||||
log.Debugf("failed to refresh WireGuard stats: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fullStatus := c.recorder.GetFullStatus()
|
fullStatus := c.recorder.GetFullStatus()
|
||||||
|
|
||||||
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
|
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
|
||||||
@@ -340,20 +306,6 @@ func (c *Client) PeersList() *PeerInfoArray {
|
|||||||
FQDN: p.FQDN,
|
FQDN: p.FQDN,
|
||||||
ConnStatus: int(p.ConnStatus),
|
ConnStatus: int(p.ConnStatus),
|
||||||
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
|
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
|
||||||
|
|
||||||
PubKey: p.PubKey,
|
|
||||||
Latency: formatDuration(p.Latency),
|
|
||||||
LatencyMs: p.Latency.Milliseconds(),
|
|
||||||
BytesRx: p.BytesRx,
|
|
||||||
BytesTx: p.BytesTx,
|
|
||||||
ConnStatusUpdate: formatTime(p.ConnStatusUpdate),
|
|
||||||
Relayed: p.Relayed,
|
|
||||||
RosenpassEnabled: p.RosenpassEnabled,
|
|
||||||
LastWireguardHandshake: formatTime(p.LastWireguardHandshake),
|
|
||||||
LocalIceCandidateType: p.LocalIceCandidateType,
|
|
||||||
RemoteIceCandidateType: p.RemoteIceCandidateType,
|
|
||||||
LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint,
|
|
||||||
RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint,
|
|
||||||
}
|
}
|
||||||
peerInfos[n] = pi
|
peerInfos[n] = pi
|
||||||
}
|
}
|
||||||
@@ -484,6 +436,10 @@ func (c *Client) RemoveConnectionListener() {
|
|||||||
c.recorder.RemoveConnectionListener()
|
c.recorder.RemoveConnectionListener()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) toggleRoute(command routeCommand) error {
|
||||||
|
return command.toggleRoute()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) getRouteManager() (routemanager.Manager, error) {
|
func (c *Client) getRouteManager() (routemanager.Manager, error) {
|
||||||
client := c.getConnectClient()
|
client := c.getConnectClient()
|
||||||
if client == nil {
|
if client == nil {
|
||||||
@@ -503,22 +459,22 @@ func (c *Client) getRouteManager() (routemanager.Manager, error) {
|
|||||||
return manager, nil
|
return manager, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SelectRoute(id string) error {
|
func (c *Client) SelectRoute(route string) error {
|
||||||
manager, err := c.getRouteManager()
|
manager, err := c.getRouteManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true)
|
return c.toggleRoute(selectRouteCommand{route: route, manager: manager})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) DeselectRoute(id string) error {
|
func (c *Client) DeselectRoute(route string) error {
|
||||||
manager, err := c.getRouteManager()
|
manager, err := c.getRouteManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return manager.DeselectRoutes([]route.NetID{route.NetID(id)})
|
return c.toggleRoute(deselectRouteCommand{route: route, manager: manager})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getNetworkDomainsFromRoute extracts domains from a route and enriches each domain
|
// getNetworkDomainsFromRoute extracts domains from a route and enriches each domain
|
||||||
@@ -553,28 +509,3 @@ func exportEnvList(list *EnvList) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatDuration renders a duration for display, trimming the fractional part
|
|
||||||
// to two digits so latencies read as "12.34ms" rather than "12.345678ms".
|
|
||||||
func formatDuration(d time.Duration) string {
|
|
||||||
ds := d.String()
|
|
||||||
dotIndex := strings.Index(ds, ".")
|
|
||||||
if dotIndex == -1 {
|
|
||||||
return ds
|
|
||||||
}
|
|
||||||
|
|
||||||
endIndex := min(dotIndex+3, len(ds))
|
|
||||||
|
|
||||||
// Skip the remaining digits so only the unit suffix is appended back.
|
|
||||||
unitStart := endIndex
|
|
||||||
for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' {
|
|
||||||
unitStart++
|
|
||||||
}
|
|
||||||
return ds[:endIndex] + ds[unitStart:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatTime renders a timestamp in UTC using a fixed layout. The zero time is
|
|
||||||
// passed through as-is so the UI can recognise it and show "never" instead.
|
|
||||||
func formatTime(t time.Time) string {
|
|
||||||
return t.UTC().Format("2006-01-02 15:04:05")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,30 +12,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// PeerInfo describe information about the peers. It designed for the UI usage
|
// PeerInfo describe information about the peers. It designed for the UI usage
|
||||||
//
|
|
||||||
// The fields below ConnStatus back the peer detail screen. Durations and times
|
|
||||||
// are pre-formatted into strings so the UI does not have to know Go's layouts;
|
|
||||||
// Latency is additionally exposed as LatencyMs for colour coding.
|
|
||||||
type PeerInfo struct {
|
type PeerInfo struct {
|
||||||
IP string
|
IP string
|
||||||
IPv6 string
|
IPv6 string
|
||||||
FQDN string
|
FQDN string
|
||||||
ConnStatus int
|
ConnStatus int
|
||||||
Routes PeerRoutes
|
Routes PeerRoutes
|
||||||
|
|
||||||
PubKey string
|
|
||||||
Latency string
|
|
||||||
LatencyMs int64
|
|
||||||
BytesRx int64
|
|
||||||
BytesTx int64
|
|
||||||
ConnStatusUpdate string
|
|
||||||
Relayed bool
|
|
||||||
RosenpassEnabled bool
|
|
||||||
LastWireguardHandshake string
|
|
||||||
LocalIceCandidateType string
|
|
||||||
RemoteIceCandidateType string
|
|
||||||
LocalIceCandidateEndpoint string
|
|
||||||
RemoteIceCandidateEndpoint string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PeerInfo) GetPeerRoutes() *PeerRoutes {
|
func (p *PeerInfo) GetPeerRoutes() *PeerRoutes {
|
||||||
|
|||||||
@@ -189,19 +189,6 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenameProfile changes a profile's display name. The profile ID, and therefore
|
|
||||||
// its on-disk filename, is left untouched: only the "name" field of the config
|
|
||||||
// is rewritten. This works for the default profile too, whose config lives in
|
|
||||||
// netbird.cfg rather than under profiles/.
|
|
||||||
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
|
||||||
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil {
|
|
||||||
return fmt.Errorf("failed to rename profile: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("renamed profile %s to: %s", id, newName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveProfile deletes a profile
|
// RemoveProfile deletes a profile
|
||||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||||
// Use ServiceManager (removes profile from profiles/ directory)
|
// Use ServiceManager (removes profile from profiles/ directory)
|
||||||
|
|||||||
70
client/android/route_command.go
Normal file
70
client/android/route_command.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/exp/maps"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||||
|
"github.com/netbirdio/netbird/route"
|
||||||
|
)
|
||||||
|
|
||||||
|
func executeRouteToggle(id string, manager routemanager.Manager,
|
||||||
|
operationName string,
|
||||||
|
routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error {
|
||||||
|
netID := route.NetID(id)
|
||||||
|
routes := []route.NetID{netID}
|
||||||
|
|
||||||
|
routesMap := manager.GetClientRoutesWithNetID()
|
||||||
|
routes = route.ExpandV6ExitPairs(routes, routesMap)
|
||||||
|
|
||||||
|
log.Debugf("%s with ids: %v", operationName, routes)
|
||||||
|
|
||||||
|
if err := routeOperation(routes, maps.Keys(routesMap)); err != nil {
|
||||||
|
log.Debugf("error when %s: %s", operationName, err)
|
||||||
|
return fmt.Errorf("error %s: %w", operationName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.TriggerSelection(manager.GetClientRoutes())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeCommand interface {
|
||||||
|
toggleRoute() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type selectRouteCommand struct {
|
||||||
|
route string
|
||||||
|
manager routemanager.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s selectRouteCommand) toggleRoute() error {
|
||||||
|
routeSelector := s.manager.GetRouteSelector()
|
||||||
|
if routeSelector == nil {
|
||||||
|
return fmt.Errorf("no route selector available")
|
||||||
|
}
|
||||||
|
|
||||||
|
routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error {
|
||||||
|
return routeSelector.SelectRoutes(routes, true, allRoutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation)
|
||||||
|
}
|
||||||
|
|
||||||
|
type deselectRouteCommand struct {
|
||||||
|
route string
|
||||||
|
manager routemanager.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d deselectRouteCommand) toggleRoute() error {
|
||||||
|
routeSelector := d.manager.GetRouteSelector()
|
||||||
|
if routeSelector == nil {
|
||||||
|
return fmt.Errorf("no route selector available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes)
|
||||||
|
}
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
//go:build android
|
|
||||||
|
|
||||||
package android
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StateChangeListener receives client state notifications.
|
|
||||||
//
|
|
||||||
// OnStateChanged is a payload-free wake-up whenever the state snapshot
|
|
||||||
// changed: connection state, the run-loop status label (e.g. NeedsLogin) or
|
|
||||||
// the session deadline. It mirrors the daemon's SubscribeStatus stream
|
|
||||||
// trigger — on each signal the consumer pulls the fresh values via
|
|
||||||
// Status() / SessionExpiresAtUnix().
|
|
||||||
//
|
|
||||||
// OnSessionExpiring forwards the engine's session-expiry warnings, fired at
|
|
||||||
// sessionwatch.WarningLead before the deadline and again at FinalWarningLead
|
|
||||||
// (finalWarning true). The second one is suppressed when the user dismissed
|
|
||||||
// the first via DismissSessionWarning. The daemon turns the same events into
|
|
||||||
// its tray notification.
|
|
||||||
type StateChangeListener interface {
|
|
||||||
OnStateChanged()
|
|
||||||
OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status returns the connect run-loop's status label — the same value the
|
|
||||||
// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the
|
|
||||||
// management server rejected the peer and an interactive login is required.
|
|
||||||
//
|
|
||||||
// The label is latched: the run loop keeps its status in a per-run context
|
|
||||||
// state, which a restart replaces with a fresh Idle one, so an engine restart
|
|
||||||
// (network change, always-on) would otherwise erase the fact that the peer
|
|
||||||
// still needs to log in. Only a successful interactive login or extend clears
|
|
||||||
// it — see clearLoginRequired.
|
|
||||||
func (c *Client) Status() string {
|
|
||||||
latched, generation := c.loginRequiredState()
|
|
||||||
if latched {
|
|
||||||
return string(internal.StatusNeedsLogin)
|
|
||||||
}
|
|
||||||
cc := c.getConnectClient()
|
|
||||||
if cc == nil {
|
|
||||||
return string(internal.StatusIdle)
|
|
||||||
}
|
|
||||||
status := cc.Status()
|
|
||||||
if status == internal.StatusNeedsLogin {
|
|
||||||
c.latchLoginRequired(generation)
|
|
||||||
}
|
|
||||||
return string(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) loginRequiredState() (bool, uint64) {
|
|
||||||
c.loginRequiredMu.Lock()
|
|
||||||
defer c.loginRequiredMu.Unlock()
|
|
||||||
return c.loginRequired, c.loginCleared
|
|
||||||
}
|
|
||||||
|
|
||||||
// latchLoginRequired records a NeedsLogin observation, unless a clear landed
|
|
||||||
// while the caller was reading the run loop's status: cc.Status() is read
|
|
||||||
// outside the lock, so a login or extend completing in that window would
|
|
||||||
// otherwise be undone by this stale observation, stranding the UI on
|
|
||||||
// "login required" over a healthy session.
|
|
||||||
func (c *Client) latchLoginRequired(observedGeneration uint64) {
|
|
||||||
c.loginRequiredMu.Lock()
|
|
||||||
defer c.loginRequiredMu.Unlock()
|
|
||||||
if c.loginCleared != observedGeneration {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.loginRequired = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearLoginRequired releases the latch after a successful interactive login
|
|
||||||
// or session extend, and invalidates any observation already in flight.
|
|
||||||
func (c *Client) clearLoginRequired() {
|
|
||||||
c.loginRequiredMu.Lock()
|
|
||||||
defer c.loginRequiredMu.Unlock()
|
|
||||||
c.loginRequired = false
|
|
||||||
c.loginCleared++
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0
|
|
||||||
// when no deadline is known (not SSO-registered, expiry disabled, or the
|
|
||||||
// engine has not received one yet). A past value means the session expired.
|
|
||||||
// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon.
|
|
||||||
func (c *Client) SessionExpiresAtUnix() int64 {
|
|
||||||
deadline := c.recorder.GetSessionExpiresAt()
|
|
||||||
if deadline.IsZero() {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return deadline.Unix()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetStateChangeListener registers the state notification listener.
|
|
||||||
// Replaces any previously registered listener; remove it with
|
|
||||||
// RemoveStateChangeListener.
|
|
||||||
func (c *Client) SetStateChangeListener(listener StateChangeListener) {
|
|
||||||
c.stateChangeMu.Lock()
|
|
||||||
defer c.stateChangeMu.Unlock()
|
|
||||||
c.stopStateChangeWatchLocked()
|
|
||||||
if listener == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both subscriptions are buffered (one pending tick, ten pending events),
|
|
||||||
// so unsubscribing is not enough to stop callbacks: the loops would drain
|
|
||||||
// what is already queued and deliver it to a listener the caller has
|
|
||||||
// already removed or replaced. Gate every callback on this registration's
|
|
||||||
// own signal, which is closed before unsubscribing.
|
|
||||||
done := make(chan struct{})
|
|
||||||
c.stateChangeDone = done
|
|
||||||
|
|
||||||
id, ch := c.recorder.SubscribeToStateChanges()
|
|
||||||
c.stateChangeSubID = id
|
|
||||||
// The channel is closed by UnsubscribeFromStateChanges, which ends the
|
|
||||||
// goroutine. Ticks are coalesced (buffer of one), so a burst of changes
|
|
||||||
// wakes the listener once.
|
|
||||||
go func() {
|
|
||||||
for range ch {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
listener.OnStateChanged()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
c.eventSub = c.recorder.SubscribeToEvents()
|
|
||||||
go watchSessionWarnings(c.eventSub, listener, done)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveStateChangeListener unregisters the state notification listener.
|
|
||||||
func (c *Client) RemoveStateChangeListener() {
|
|
||||||
c.stateChangeMu.Lock()
|
|
||||||
defer c.stateChangeMu.Unlock()
|
|
||||||
c.stopStateChangeWatchLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DismissSessionWarning records the user's "Dismiss" on the first expiry
|
|
||||||
// warning and suppresses the final one for the current deadline. A refreshed
|
|
||||||
// deadline re-arms both. No-op while the engine is not running.
|
|
||||||
func (c *Client) DismissSessionWarning() {
|
|
||||||
cc := c.getConnectClient()
|
|
||||||
if cc == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
engine := cc.Engine()
|
|
||||||
if engine == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
engine.DismissSessionWarning()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and
|
|
||||||
// asks the management server to extend the session deadline. The tunnel is
|
|
||||||
// untouched: no resync, no reconnect. Async; the result arrives on the
|
|
||||||
// listener. Mirror of the daemon's RequestExtendAuthSession /
|
|
||||||
// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the
|
|
||||||
// browser" role.
|
|
||||||
//
|
|
||||||
// Only one flow may be in flight: the PKCE step binds a fixed loopback port,
|
|
||||||
// so a second concurrent flow would fail on that bind. Call
|
|
||||||
// CancelExtendAuthSession when the user abandons the browser.
|
|
||||||
func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) {
|
|
||||||
ctx, err := c.beginExtend()
|
|
||||||
if err != nil {
|
|
||||||
resultListener.OnError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer c.endExtend()
|
|
||||||
if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil {
|
|
||||||
resultListener.OnError(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resultListener.OnSuccess()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is
|
|
||||||
// left alone — unlike the login flow, which cancels the whole client context
|
|
||||||
// by stopping the engine. Without this the abandoned PKCE wait keeps its
|
|
||||||
// loopback port for the full flow timeout and blocks every later attempt.
|
|
||||||
// No-op when no flow is running.
|
|
||||||
func (c *Client) CancelExtendAuthSession() {
|
|
||||||
c.extendMu.Lock()
|
|
||||||
defer c.extendMu.Unlock()
|
|
||||||
if c.extendCancel != nil {
|
|
||||||
c.extendCancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) stopStateChangeWatchLocked() {
|
|
||||||
// Signal first, unsubscribe second: closing the channels only stops new
|
|
||||||
// items, and the loops would still hand whatever is buffered to a listener
|
|
||||||
// that is no longer registered.
|
|
||||||
if c.stateChangeDone != nil {
|
|
||||||
close(c.stateChangeDone)
|
|
||||||
c.stateChangeDone = nil
|
|
||||||
}
|
|
||||||
if c.stateChangeSubID != "" {
|
|
||||||
c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID)
|
|
||||||
c.stateChangeSubID = ""
|
|
||||||
}
|
|
||||||
if c.eventSub != nil {
|
|
||||||
// Closes the channel, which ends watchSessionWarnings.
|
|
||||||
c.recorder.UnsubscribeFromEvents(c.eventSub)
|
|
||||||
c.eventSub = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// watchSessionWarnings forwards the engine's session-expiry warnings to the
|
|
||||||
// listener. The event stream also carries unrelated traffic — network-map
|
|
||||||
// updates on every sync, DNS and route errors — so everything but an
|
|
||||||
// AUTHENTICATION event carrying the session-warning marker is dropped. Exits
|
|
||||||
// when the subscription is closed by UnsubscribeFromEvents, or earlier when
|
|
||||||
// done is closed — the stream buffers up to ten events, and a deregistered
|
|
||||||
// listener must not receive the ones already queued.
|
|
||||||
func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) {
|
|
||||||
for ev := range sub.Events() {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
meta := ev.GetMetadata()
|
|
||||||
if meta[sessionwatch.MetaSessionWarning] != "true" {
|
|
||||||
// Other AUTHENTICATION events exist (e.g. a deadline rejected as
|
|
||||||
// out of range); they carry no warning marker.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt])
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("session warning event with unparsable deadline: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes])
|
|
||||||
if err != nil {
|
|
||||||
// Informational only — the deadline above is what drives the UI.
|
|
||||||
lead = 0
|
|
||||||
}
|
|
||||||
listener.OnSessionExpiring(deadline.Unix(), int64(lead),
|
|
||||||
meta[sessionwatch.MetaSessionFinal] == "true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) beginExtend() (context.Context, error) {
|
|
||||||
c.extendMu.Lock()
|
|
||||||
defer c.extendMu.Unlock()
|
|
||||||
if c.extendCancel != nil {
|
|
||||||
return nil, fmt.Errorf("session extend already in progress")
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
c.extendCancel = cancel
|
|
||||||
return ctx, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) endExtend() {
|
|
||||||
c.extendMu.Lock()
|
|
||||||
defer c.extendMu.Unlock()
|
|
||||||
if c.extendCancel != nil {
|
|
||||||
c.extendCancel()
|
|
||||||
c.extendCancel = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
|
||||||
cfg, _, cc := c.stateSnapshot()
|
|
||||||
if cfg == nil || cc == nil {
|
|
||||||
return fmt.Errorf("engine is not running")
|
|
||||||
}
|
|
||||||
engine := cc.Engine()
|
|
||||||
if engine == nil {
|
|
||||||
return fmt.Errorf("engine is not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create auth client: %v", err)
|
|
||||||
}
|
|
||||||
defer authClient.Close()
|
|
||||||
|
|
||||||
a := &Auth{ctx: ctx, config: cfg}
|
|
||||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.clearLoginRequired()
|
|
||||||
|
|
||||||
go urlOpener.OnLoginSuccess()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -17,26 +17,16 @@ 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{
|
||||||
@@ -71,16 +61,6 @@ var loginCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if extendSessionFlag {
|
|
||||||
if providedSetupKey != "" {
|
|
||||||
return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers")
|
|
||||||
}
|
|
||||||
if err := doExtendSession(ctx, cmd); err != nil {
|
|
||||||
return fmt.Errorf("extend session failed: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// workaround to run without service
|
// workaround to run without service
|
||||||
if util.FindFirstLogPath(logFiles) == "" {
|
if util.FindFirstLogPath(logFiles) == "" {
|
||||||
if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil {
|
if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil {
|
||||||
@@ -172,65 +152,6 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// doExtendSession drives the daemon's RequestExtendAuthSession /
|
|
||||||
// WaitExtendAuthSession pair. The user is sent through a regular SSO flow
|
|
||||||
// (browser + verification URL) and the resulting JWT is forwarded to the
|
|
||||||
// management server's ExtendAuthSession RPC. The tunnel stays up
|
|
||||||
// throughout — no Down/Up, no network-map resync.
|
|
||||||
func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
|
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
|
||||||
if err != nil {
|
|
||||||
//nolint
|
|
||||||
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
|
||||||
"If the daemon is not running please run: "+
|
|
||||||
"\nnetbird service install \nnetbird service start\n", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
client := proto.NewDaemonServiceClient(conn)
|
|
||||||
|
|
||||||
req := &proto.RequestExtendAuthSessionRequest{}
|
|
||||||
// Pre-fill the IdP login hint from the active profile so the user
|
|
||||||
// doesn't have to retype their email. Best-effort: we still proceed
|
|
||||||
// without a hint if the lookup fails.
|
|
||||||
pm := profilemanager.NewProfileManager()
|
|
||||||
if active, perr := pm.GetActiveProfile(); perr == nil {
|
|
||||||
if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" {
|
|
||||||
req.Hint = &profState.Email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startResp, err := client.RequestExtendAuthSession(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("start extend session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
uri := startResp.GetVerificationURIComplete()
|
|
||||||
if uri == "" {
|
|
||||||
uri = startResp.GetVerificationURI()
|
|
||||||
}
|
|
||||||
openURL(cmd, uri, startResp.GetUserCode(), noBrowser, showQR)
|
|
||||||
|
|
||||||
waitResp, err := client.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{
|
|
||||||
DeviceCode: startResp.GetDeviceCode(),
|
|
||||||
UserCode: startResp.GetUserCode(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("wait for extend session: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ts := waitResp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
|
|
||||||
deadline := ts.AsTime().Local()
|
|
||||||
cmd.Printf("Session extended. New expiry: %s\n", deadline.Format("2006-01-02 15:04:05 MST"))
|
|
||||||
} else {
|
|
||||||
// Management reported the peer is not eligible (e.g. login
|
|
||||||
// expiration disabled on the account). Surface that fact
|
|
||||||
// instead of pretending the call succeeded.
|
|
||||||
cmd.Println("Session extension call completed, but the management server did not return a new deadline (peer may not be SSO-tracked or login expiration is disabled).")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) (*profilemanager.Profile, error) {
|
func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) (*profilemanager.Profile, error) {
|
||||||
// switch profile if provided
|
// switch profile if provided
|
||||||
|
|
||||||
@@ -333,14 +254,6 @@ 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,7 +5,6 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -23,21 +22,15 @@ 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
|
||||||
}
|
}
|
||||||
@@ -53,8 +46,6 @@ 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,6 +5,9 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
@@ -19,56 +22,41 @@ 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()
|
||||||
|
|
||||||
daemonListener, err := listenOnAddress(daemonAddr)
|
split := strings.Split(daemonAddr, "://")
|
||||||
|
switch split[0] {
|
||||||
|
case "unix":
|
||||||
|
// cleanup failed close
|
||||||
|
stat, err := os.Stat(split[1])
|
||||||
|
if err == nil && !stat.IsDir() {
|
||||||
|
if err := os.Remove(split[1]); err != nil {
|
||||||
|
log.Debugf("remove socket file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "tcp":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported daemon address protocol: %v", split[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
listen, err := net.Listen(split[0], split[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listen daemon interface: %w", err)
|
return fmt.Errorf("listen daemon interface: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var jsonListener *socketListener
|
|
||||||
if enableJSONSocket {
|
|
||||||
jsonListener, err = listenOnAddress(jsonSocket)
|
|
||||||
if err != nil {
|
|
||||||
_ = daemonListener.Close()
|
|
||||||
return fmt.Errorf("listen daemon JSON interface: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
removeStaleUnixSocketForAddress(jsonSocket)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer daemonListener.Close()
|
defer listen.Close()
|
||||||
if jsonListener != nil {
|
|
||||||
defer jsonListener.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
|
if split[0] == "unix" {
|
||||||
log.Error(err)
|
if err := os.Chmod(split[1], 0666); err != nil {
|
||||||
return
|
log.Errorf("failed setting daemon permissions: %v", split[1])
|
||||||
}
|
|
||||||
if jsonListener != nil {
|
|
||||||
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,16 +71,8 @@ func (p *program) Start(svc service.Service) error {
|
|||||||
p.serverInstance = serverInstance
|
p.serverInstance = serverInstance
|
||||||
p.serverInstanceMu.Unlock()
|
p.serverInstanceMu.Unlock()
|
||||||
|
|
||||||
if jsonListener != nil {
|
log.Printf("started daemon server: %v", split[1])
|
||||||
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
if err := p.serv.Serve(listen); 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)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -112,20 +92,6 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -182,9 +148,6 @@ 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()
|
||||||
},
|
},
|
||||||
@@ -199,9 +162,6 @@ 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)
|
||||||
@@ -238,9 +198,6 @@ 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,10 +67,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,10 +106,6 @@ 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)
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
//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,7 +23,6 @@ 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"`
|
||||||
@@ -31,7 +30,6 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +75,6 @@ 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,
|
||||||
@@ -85,7 +82,6 @@ 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 {
|
||||||
@@ -117,8 +113,9 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// For fields with non-empty defaults, keep the != "" guard so that an older
|
// For fields with non-empty defaults (log-level, daemon-addr), keep the
|
||||||
// service.json missing the field doesn't clobber the default with an empty string.
|
// != "" guard so that an older service.json missing the field doesn't
|
||||||
|
// clobber the default with an empty string.
|
||||||
if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" {
|
if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" {
|
||||||
logLevel = params.LogLevel
|
logLevel = params.LogLevel
|
||||||
}
|
}
|
||||||
@@ -127,14 +124,6 @@ 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,8 +41,6 @@ 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"},
|
||||||
@@ -65,8 +63,6 @@ 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)
|
||||||
@@ -105,8 +101,6 @@ 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
|
||||||
@@ -116,8 +110,6 @@ 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
|
||||||
@@ -128,8 +120,6 @@ 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"}
|
||||||
@@ -141,8 +131,6 @@ 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)
|
||||||
@@ -154,8 +142,6 @@ 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
|
||||||
@@ -165,8 +151,6 @@ 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
|
||||||
@@ -178,8 +162,6 @@ 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"}
|
||||||
@@ -202,8 +184,6 @@ 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"},
|
||||||
@@ -221,8 +201,6 @@ 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)
|
||||||
@@ -234,17 +212,14 @@ 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) {
|
||||||
@@ -263,7 +238,6 @@ 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) {
|
||||||
@@ -556,7 +530,6 @@ 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",
|
||||||
@@ -564,7 +537,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
//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,7 +6,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -116,11 +115,6 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
// manager only knows the active profile ID, not its display name.
|
// manager only knows the active profile ID, not its display name.
|
||||||
profName := getActiveProfileName(ctx)
|
profName := getActiveProfileName(ctx)
|
||||||
|
|
||||||
var sessionExpiresAt time.Time
|
|
||||||
if ts := resp.GetSessionExpiresAt(); ts.IsValid() {
|
|
||||||
sessionExpiresAt = ts.AsTime().UTC()
|
|
||||||
}
|
|
||||||
|
|
||||||
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||||
Anonymize: anonymizeFlag,
|
Anonymize: anonymizeFlag,
|
||||||
DaemonVersion: resp.GetDaemonVersion(),
|
DaemonVersion: resp.GetDaemonVersion(),
|
||||||
@@ -131,7 +125,6 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
IPsFilter: ipsFilterMap,
|
IPsFilter: ipsFilterMap,
|
||||||
ConnectionTypeFilter: connectionTypeFilter,
|
ConnectionTypeFilter: connectionTypeFilter,
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
SessionExpiresAt: sessionExpiresAt,
|
|
||||||
})
|
})
|
||||||
var statusOutputString string
|
var statusOutputString string
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ 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"
|
||||||
@@ -231,24 +229,6 @@ 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(context.Background(), false)
|
_ = engine.RunHealthProbes(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ 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
|
||||||
@@ -184,41 +183,6 @@ 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)
|
||||||
@@ -322,8 +286,6 @@ 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 {
|
||||||
@@ -337,7 +299,6 @@ 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
|
||||||
@@ -733,10 +694,6 @@ 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()
|
||||||
@@ -1089,20 +1046,19 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// gopacket does not decode the transport header of any IP fragment, so
|
// TODO: pass fragments of routed packets to forwarder
|
||||||
// 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 {
|
||||||
return m.filterInboundFragment(d, srcIP, dstIP, size)
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
if d.decoded[0] == layers.LayerTypeIPv4 {
|
||||||
|
m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
|
||||||
|
srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
|
||||||
|
} else {
|
||||||
|
m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.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
|
||||||
@@ -1133,226 +1089,33 @@ func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, pack
|
|||||||
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 {
|
||||||
m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
pnum := getProtocolFromPacket(d)
|
||||||
d, srcIP, dstIP, ruleID, size)
|
srcPort, dstPort := getPortsFromPacket(d)
|
||||||
|
|
||||||
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||||
|
ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.flowLogger.StoreEvent(nftypes.EventFields{
|
||||||
|
FlowID: uuid.New(),
|
||||||
|
Type: nftypes.TypeDrop,
|
||||||
|
RuleID: ruleID,
|
||||||
|
Direction: nftypes.Ingress,
|
||||||
|
Protocol: pnum,
|
||||||
|
SourceIP: srcIP,
|
||||||
|
DestIP: dstIP,
|
||||||
|
SourcePort: srcPort,
|
||||||
|
DestPort: dstPort,
|
||||||
|
// TODO: icmp type/code
|
||||||
|
RxPackets: 1,
|
||||||
|
RxBytes: uint64(size),
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1405,8 +1168,27 @@ 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 {
|
||||||
m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
proto := getProtocolFromPacket(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,9 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,11 +31,6 @@ 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 {
|
||||||
@@ -159,8 +152,6 @@ 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)
|
||||||
|
|
||||||
@@ -475,31 +466,3 @@ 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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,554 +0,0 @@
|
|||||||
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,8 +464,6 @@ 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()
|
||||||
@@ -507,8 +505,12 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
localIP1 := "127.0.0.1"
|
localIP, err := getLocalIP()
|
||||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
|
if err != nil {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -544,8 +546,7 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
localIP2 := "127.0.0.1"
|
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
|
||||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -568,17 +569,17 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// The peers use userspace WireGuard (stdnet transport). A tight busy-loop
|
// todo: investigate why in some tests execution we need 30s
|
||||||
// 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)
|
||||||
@@ -587,12 +588,6 @@ 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:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -620,3 +615,28 @@ 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,31 +3,14 @@
|
|||||||
package netstack
|
package netstack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
|
||||||
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 {
|
||||||
@@ -35,40 +18,24 @@ func IsEnabled() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListenAddr() string {
|
func ListenAddr() string {
|
||||||
return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort()))
|
sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT")
|
||||||
}
|
|
||||||
|
|
||||||
// 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 DefaultSocks5Port
|
return listenAddr(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 DefaultSocks5Port
|
return listenAddr(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 DefaultSocks5Port
|
return listenAddr(DefaultSocks5Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
return port
|
return listenAddr(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenAddr(port int) string {
|
||||||
|
return fmt.Sprintf("0.0.0.0:%d", port)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
//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\\build\\windows\\icon.ico"
|
!define ICON "ui\\assets\\netbird.ico"
|
||||||
!define BANNER "ui\\build\\banner.bmp"
|
!define BANNER "ui\\build\\banner.bmp"
|
||||||
!define LICENSE_DATA "..\\LICENSE"
|
!define LICENSE_DATA "..\\LICENSE"
|
||||||
|
|
||||||
@@ -79,6 +79,8 @@ ShowInstDetails Show
|
|||||||
|
|
||||||
!insertmacro MUI_PAGE_DIRECTORY
|
!insertmacro MUI_PAGE_DIRECTORY
|
||||||
|
|
||||||
|
Page custom AutostartPage AutostartPageLeave
|
||||||
|
|
||||||
!insertmacro MUI_PAGE_INSTFILES
|
!insertmacro MUI_PAGE_INSTFILES
|
||||||
|
|
||||||
!insertmacro MUI_PAGE_FINISH
|
!insertmacro MUI_PAGE_FINISH
|
||||||
@@ -95,12 +97,40 @@ UninstPage custom un.DeleteDataPage un.DeleteDataPageLeave
|
|||||||
|
|
||||||
!insertmacro MUI_LANGUAGE "English"
|
!insertmacro MUI_LANGUAGE "English"
|
||||||
|
|
||||||
|
; Variables for autostart option
|
||||||
|
Var AutostartCheckbox
|
||||||
|
Var AutostartEnabled
|
||||||
|
|
||||||
; Variables for uninstall data deletion option
|
; Variables for uninstall data deletion option
|
||||||
Var DeleteDataCheckbox
|
Var DeleteDataCheckbox
|
||||||
Var DeleteDataEnabled
|
Var DeleteDataEnabled
|
||||||
|
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
|
; Function to create the autostart options page
|
||||||
|
Function AutostartPage
|
||||||
|
!insertmacro MUI_HEADER_TEXT "Startup Options" "Configure how ${APP_NAME} launches with Windows."
|
||||||
|
|
||||||
|
nsDialogs::Create 1018
|
||||||
|
Pop $0
|
||||||
|
|
||||||
|
${If} $0 == error
|
||||||
|
Abort
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
${NSD_CreateCheckbox} 0 20u 100% 10u "Start ${APP_NAME} UI automatically when Windows starts"
|
||||||
|
Pop $AutostartCheckbox
|
||||||
|
${NSD_Check} $AutostartCheckbox
|
||||||
|
StrCpy $AutostartEnabled "1"
|
||||||
|
|
||||||
|
nsDialogs::Show
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
; Function to handle leaving the autostart page
|
||||||
|
Function AutostartPageLeave
|
||||||
|
${NSD_GetState} $AutostartCheckbox $AutostartEnabled
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
; Function to create the uninstall data deletion page
|
; Function to create the uninstall data deletion page
|
||||||
Function un.DeleteDataPage
|
Function un.DeleteDataPage
|
||||||
!insertmacro MUI_HEADER_TEXT "Uninstall Options" "Choose whether to delete ${APP_NAME} data."
|
!insertmacro MUI_HEADER_TEXT "Uninstall Options" "Choose whether to delete ${APP_NAME} data."
|
||||||
@@ -171,6 +201,8 @@ Pop $0
|
|||||||
|
|
||||||
Function .onInit
|
Function .onInit
|
||||||
StrCpy $INSTDIR "${INSTALL_DIR}"
|
StrCpy $INSTDIR "${INSTALL_DIR}"
|
||||||
|
; Default autostart to enabled so silent installs (/S) match the interactive default
|
||||||
|
StrCpy $AutostartEnabled "1"
|
||||||
|
|
||||||
; Pre-0.70.1 installers ran without SetRegView, so their uninstall keys live
|
; Pre-0.70.1 installers ran without SetRegView, so their uninstall keys live
|
||||||
; in the 32-bit view. Fall back to it so upgrades still find them.
|
; in the 32-bit view. Fall back to it so upgrades still find them.
|
||||||
@@ -228,12 +260,17 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
|
|||||||
|
|
||||||
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
||||||
|
|
||||||
; Autostart is owned by the UI's per-user setting (HKCU\...\Run via Wails),
|
; Create autostart registry entry based on checkbox
|
||||||
; not the installer. Drop the machine-wide entry older installers wrote so the
|
DetailPrint "Autostart enabled: $AutostartEnabled"
|
||||||
; toggle is the single source of truth. HKCU is left untouched -- it may hold
|
${If} $AutostartEnabled == "1"
|
||||||
; the user's own toggle state, which must survive upgrades.
|
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
||||||
DetailPrint "Removing installer-managed autostart registry entry if present..."
|
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
${Else}
|
||||||
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||||
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
DetailPrint "Autostart not enabled by user"
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
EnVar::SetHKLM
|
EnVar::SetHKLM
|
||||||
EnVar::AddValueEx "path" "$INSTDIR"
|
EnVar::AddValueEx "path" "$INSTDIR"
|
||||||
@@ -243,43 +280,6 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
|||||||
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
# Install the Microsoft Edge WebView2 runtime if it isn't already present.
|
|
||||||
# Macro adapted from Wails3's NSIS template (wails_tools.nsh): a registry
|
|
||||||
# probe followed by a silent install of the embedded evergreen bootstrapper.
|
|
||||||
# The MicrosoftEdgeWebview2Setup.exe payload is staged next to this script
|
|
||||||
# by the sign-pipelines build step (`wails3 generate webview2bootstrapper`).
|
|
||||||
!macro nb.webview2runtime
|
|
||||||
SetRegView 64
|
|
||||||
# Per-machine install marker — populated when the runtime ships with
|
|
||||||
# Edge or has been installed by an admin previously.
|
|
||||||
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
|
||||||
${If} $0 != ""
|
|
||||||
Goto webview2_ok
|
|
||||||
${EndIf}
|
|
||||||
# Per-user fallback for HKCU installs.
|
|
||||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
|
||||||
${If} $0 != ""
|
|
||||||
Goto webview2_ok
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
SetDetailsPrint both
|
|
||||||
DetailPrint "Installing: WebView2 Runtime"
|
|
||||||
SetDetailsPrint listonly
|
|
||||||
|
|
||||||
InitPluginsDir
|
|
||||||
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
|
||||||
SetOutPath "$pluginsdir\webview2bootstrapper"
|
|
||||||
File "MicrosoftEdgeWebview2Setup.exe"
|
|
||||||
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
|
||||||
|
|
||||||
SetDetailsPrint both
|
|
||||||
webview2_ok:
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
Section -WebView2
|
|
||||||
!insertmacro nb.webview2runtime
|
|
||||||
SectionEnd
|
|
||||||
|
|
||||||
Section -Post
|
Section -Post
|
||||||
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
|
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
|
||||||
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
|
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
|
||||||
@@ -299,14 +299,11 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
|
|||||||
DetailPrint "Terminating Netbird UI process..."
|
DetailPrint "Terminating Netbird UI process..."
|
||||||
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
||||||
|
|
||||||
; Remove autostart registry entries
|
; Remove autostart registry entry
|
||||||
DetailPrint "Removing autostart registry entries if they exist..."
|
DetailPrint "Removing autostart registry entry if exists..."
|
||||||
; Legacy machine-wide entry written by older installers.
|
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
; Per-user entry the UI toggle writes via Wails (value name is the lowercase
|
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||||
; app-name slug). Uninstall removes the app, so drop it too.
|
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "netbird"
|
|
||||||
|
|
||||||
; Handle data deletion based on checkbox
|
; Handle data deletion based on checkbox
|
||||||
DetailPrint "Checking if user requested data deletion..."
|
DetailPrint "Checking if user requested data deletion..."
|
||||||
@@ -329,9 +326,9 @@ DetailPrint "Deleting application files..."
|
|||||||
Delete "$INSTDIR\${UI_APP_EXE}"
|
Delete "$INSTDIR\${UI_APP_EXE}"
|
||||||
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
||||||
Delete "$INSTDIR\wintun.dll"
|
Delete "$INSTDIR\wintun.dll"
|
||||||
# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove
|
!if ${ARCH} == "amd64"
|
||||||
# any leftover copy on uninstall so old upgrades don't leave it behind.
|
|
||||||
Delete "$INSTDIR\opengl32.dll"
|
Delete "$INSTDIR\opengl32.dll"
|
||||||
|
!endif
|
||||||
DetailPrint "Removing application directory..."
|
DetailPrint "Removing application directory..."
|
||||||
RmDir /r "$INSTDIR"
|
RmDir /r "$INSTDIR"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,25 +21,6 @@ import (
|
|||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// peerLoginExpiredMsg is the exact phrase the management server returns
|
|
||||||
// when a previously SSO-enrolled peer's login has expired. Sourced from
|
|
||||||
// shared/management/status/error.go (NewPeerLoginExpiredError). Matched
|
|
||||||
// by substring so a future server-side rewording that keeps the phrase
|
|
||||||
// still triggers the friendly fallback in Login().
|
|
||||||
const peerLoginExpiredMsg = "peer login has expired"
|
|
||||||
|
|
||||||
// errSetupKeyOnSSOExpiredPeer replaces the raw management error when the
|
|
||||||
// user runs `netbird login -k <setup-key>` against a peer that was
|
|
||||||
// originally enrolled via SSO. Wrapped in a PermissionDenied gRPC status
|
|
||||||
// so callers' existing isPermissionDenied / isAuthError checks still
|
|
||||||
// classify it correctly (early-exit from retry backoff, StatusNeedsLogin
|
|
||||||
// in the server state machine).
|
|
||||||
var errSetupKeyOnSSOExpiredPeer = status.Error(
|
|
||||||
codes.PermissionDenied,
|
|
||||||
"this peer was originally enrolled via SSO and its session has expired. "+
|
|
||||||
"Setup keys can only enrol new peers — run `netbird up` (interactive SSO) to re-login.",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auth manages authentication operations with the management server
|
// Auth manages authentication operations with the management server
|
||||||
// It maintains a long-lived connection and automatically handles reconnection with backoff
|
// It maintains a long-lived connection and automatically handles reconnection with backoff
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
@@ -204,15 +184,6 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
|||||||
log.Debugf("peer registration required")
|
log.Debugf("peer registration required")
|
||||||
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
|
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The peer pub-key is already on file with the management
|
|
||||||
// server (originally enrolled via SSO) and the session has
|
|
||||||
// expired. The setup-key path can only enrol new peers, so
|
|
||||||
// retrying with -k will keep failing. Replace the raw mgm
|
|
||||||
// message with an actionable hint that tells the user to
|
|
||||||
// re-authenticate via SSO instead.
|
|
||||||
if setupKey != "" && jwtToken == "" && isPeerLoginExpired(err) {
|
|
||||||
err = errSetupKeyOnSSOExpiredPeer
|
|
||||||
}
|
|
||||||
isAuthError = isPermissionDenied(err)
|
isAuthError = isPermissionDenied(err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -351,7 +322,6 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
|||||||
a.config.BlockLANAccess,
|
a.config.BlockLANAccess,
|
||||||
a.config.BlockInbound,
|
a.config.BlockInbound,
|
||||||
a.config.DisableIPv6,
|
a.config.DisableIPv6,
|
||||||
a.config.SyncMessageVersion,
|
|
||||||
a.config.EnableSSHRoot,
|
a.config.EnableSSHRoot,
|
||||||
a.config.EnableSSHSFTP,
|
a.config.EnableSSHSFTP,
|
||||||
a.config.EnableSSHLocalPortForwarding,
|
a.config.EnableSSHLocalPortForwarding,
|
||||||
@@ -503,16 +473,3 @@ func isLoginNeeded(err error) bool {
|
|||||||
func isRegistrationNeeded(err error) bool {
|
func isRegistrationNeeded(err error) bool {
|
||||||
return isPermissionDenied(err)
|
return isPermissionDenied(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPeerLoginExpired reports whether err is the management server's
|
|
||||||
// "peer login has expired" PermissionDenied response. Used by Login to
|
|
||||||
// detect the case where the caller passed a setup-key but the peer is
|
|
||||||
// actually an SSO-enrolled record whose session needs refreshing — the
|
|
||||||
// setup-key path cannot help there.
|
|
||||||
func isPeerLoginExpired(err error) bool {
|
|
||||||
if !isPermissionDenied(err) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
s, _ := status.FromError(err)
|
|
||||||
return strings.Contains(s.Message(), peerLoginExpiredMsg)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIsPeerLoginExpired(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil",
|
|
||||||
err: nil,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plain error (not a gRPC status)",
|
|
||||||
err: errors.New("network read: connection reset"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PermissionDenied with different message",
|
|
||||||
err: status.Error(codes.PermissionDenied, "user is blocked"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Unauthenticated with the expected phrase",
|
|
||||||
// Wrong status code — must still return false.
|
|
||||||
err: status.Error(codes.Unauthenticated, "peer login has expired, please log in once more"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact server message",
|
|
||||||
err: status.Error(codes.PermissionDenied, "peer login has expired, please log in once more"),
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "phrase as substring",
|
|
||||||
// Future-proofing: if mgm reworords but keeps the phrase,
|
|
||||||
// the friendly fallback must still kick in.
|
|
||||||
err: status.Error(codes.PermissionDenied, "session refused: peer login has expired (account=foo)"),
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := isPeerLoginExpired(tc.err); got != tc.want {
|
|
||||||
t.Fatalf("isPeerLoginExpired(%v) = %v, want %v", tc.err, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrSetupKeyOnSSOExpiredPeer(t *testing.T) {
|
|
||||||
// Sentinel must surface as PermissionDenied so the upstream
|
|
||||||
// isPermissionDenied / isAuthError checks classify it correctly
|
|
||||||
// (short-circuit retry backoff, set StatusNeedsLogin).
|
|
||||||
if !isPermissionDenied(errSetupKeyOnSSOExpiredPeer) {
|
|
||||||
t.Fatalf("errSetupKeyOnSSOExpiredPeer must be a PermissionDenied gRPC error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message must actually mention SSO and `netbird up` so it is
|
|
||||||
// actionable for the end user. Loose substring checks keep the
|
|
||||||
// test resilient to copy edits.
|
|
||||||
s, _ := status.FromError(errSetupKeyOnSSOExpiredPeer)
|
|
||||||
msg := strings.ToLower(s.Message())
|
|
||||||
for _, want := range []string{"sso", "netbird up"} {
|
|
||||||
if !strings.Contains(msg, want) {
|
|
||||||
t.Errorf("sentinel message should contain %q, got %q", want, s.Message())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -259,18 +259,12 @@ 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)
|
||||||
@@ -278,12 +272,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,12 +291,11 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
|||||||
UseIDToken: d.providerConfig.UseIDToken,
|
UseIDToken: d.providerConfig.UseIDToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
err = isValidAccessToken(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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PendingFlow stores an in-progress OAuth flow between the RPC that
|
|
||||||
// initiates it (returns the verification URI to the UI) and the RPC
|
|
||||||
// that waits for the user to complete it. The flow handle, the
|
|
||||||
// device-code info, and the absolute expiry are kept together so the
|
|
||||||
// waiting RPC can validate the device code and reuse the same flow.
|
|
||||||
//
|
|
||||||
// PendingFlow is safe for concurrent use; callers must not access the
|
|
||||||
// stored fields directly.
|
|
||||||
type PendingFlow struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
flow OAuthFlow
|
|
||||||
info AuthFlowInfo
|
|
||||||
expiresAt time.Time
|
|
||||||
waitCancel context.CancelFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPendingFlow returns an empty PendingFlow ready to be populated by Set.
|
|
||||||
func NewPendingFlow() *PendingFlow {
|
|
||||||
return &PendingFlow{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set stores the flow and its authorization info, computing the absolute
|
|
||||||
// expiry from info.ExpiresIn (seconds, as returned by the IdP).
|
|
||||||
func (p *PendingFlow) Set(flow OAuthFlow, info AuthFlowInfo) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
p.flow = flow
|
|
||||||
p.info = info
|
|
||||||
p.expiresAt = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns the stored flow, info, and whether a flow is currently
|
|
||||||
// pending. Returns (nil, zero, false) after Clear or before Set.
|
|
||||||
func (p *PendingFlow) Get() (OAuthFlow, AuthFlowInfo, bool) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
if p.flow == nil {
|
|
||||||
return nil, AuthFlowInfo{}, false
|
|
||||||
}
|
|
||||||
return p.flow, p.info, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExpiresAt returns the absolute expiry of the pending flow. Returns
|
|
||||||
// the zero time when no flow is pending.
|
|
||||||
func (p *PendingFlow) ExpiresAt() time.Time {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
return p.expiresAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetWaitCancel records the cancel function for the goroutine currently
|
|
||||||
// blocked in WaitToken so a new RequestAuth can preempt it.
|
|
||||||
func (p *PendingFlow) SetWaitCancel(cancel context.CancelFunc) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
p.waitCancel = cancel
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelWait invokes and clears the stored wait-cancel, if any. Safe to
|
|
||||||
// call when no wait is in progress.
|
|
||||||
func (p *PendingFlow) CancelWait() {
|
|
||||||
p.mu.Lock()
|
|
||||||
cancel := p.waitCancel
|
|
||||||
p.waitCancel = nil
|
|
||||||
p.mu.Unlock()
|
|
||||||
if cancel != nil {
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear resets the pending flow to empty. Any stored wait-cancel is
|
|
||||||
// dropped without being invoked — call CancelWait first if the waiting
|
|
||||||
// goroutine must be stopped.
|
|
||||||
func (p *PendingFlow) Clear() {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
p.flow = nil
|
|
||||||
p.info = AuthFlowInfo{}
|
|
||||||
p.expiresAt = time.Time{}
|
|
||||||
p.waitCancel = nil
|
|
||||||
}
|
|
||||||
@@ -188,8 +188,6 @@ 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)
|
||||||
|
|
||||||
@@ -223,7 +221,6 @@ 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{
|
||||||
@@ -274,18 +271,11 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
exchangeStart := time.Now()
|
return p.oAuthConfig.Exchange(
|
||||||
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) {
|
||||||
@@ -306,7 +296,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
|||||||
audience = p.providerConfig.ClientID
|
audience = p.providerConfig.ClientID
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil {
|
if err := isValidAccessToken(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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,11 +310,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
package sessionwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// internal event kinds are no longer exposed: the watcher drives the Sink
|
|
||||||
// directly (NotifyStateChange on deadline change/clear, PublishEvent at
|
|
||||||
// each warning lead). Tests use a mock Sink to observe what the watcher
|
|
||||||
// emits.
|
|
||||||
|
|
||||||
// Metadata keys attached by the daemon to session-warning SystemEvents.
|
|
||||||
// The UI tray reads these to build a locale-aware notification without
|
|
||||||
// relying on the daemon's locale-less UserMessage string, and to
|
|
||||||
// disambiguate the T-WarningLead notification from the T-FinalWarningLead
|
|
||||||
// fallback that auto-opens the SessionAboutToExpire dialog.
|
|
||||||
const (
|
|
||||||
// MetaSessionWarning is set to "true" on both warning events (T-10 and
|
|
||||||
// T-2) so the UI can detect a session-warning SystemEvent without
|
|
||||||
// matching on the message text. Use MetaSessionFinal to distinguish
|
|
||||||
// the two.
|
|
||||||
MetaSessionWarning = "session_warning"
|
|
||||||
// MetaSessionFinal is set to "true" on the T-FinalWarningLead event
|
|
||||||
// only. Consumers that need to auto-open the SessionAboutToExpire
|
|
||||||
// dialog gate on this; T-WarningLead events leave the field unset.
|
|
||||||
MetaSessionFinal = "session_final_warning"
|
|
||||||
// MetaSessionExpiresAt carries the absolute UTC deadline encoded with
|
|
||||||
// FormatExpiresAt; consumers must decode with ParseExpiresAt so a
|
|
||||||
// future format change stays a single edit.
|
|
||||||
MetaSessionExpiresAt = "session_expires_at"
|
|
||||||
// MetaSessionLeadMinutes carries the lead in whole minutes (WarningLead
|
|
||||||
// for the T-10 event, FinalWarningLead for the T-2 event) so the UI
|
|
||||||
// can show "expires in ~N minutes" without hardcoding either constant.
|
|
||||||
MetaSessionLeadMinutes = "lead_minutes"
|
|
||||||
// MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION
|
|
||||||
// SystemEvent the daemon emits when it discards a deadline from the
|
|
||||||
// management server (pre-epoch, too far in the future, or past the
|
|
||||||
// clock-skew tolerance). The value is the rejection reason string.
|
|
||||||
// userMessage is left empty; the UI detects the event via this key
|
|
||||||
// and builds a localized notification — same pattern as the session
|
|
||||||
// warnings above.
|
|
||||||
MetaSessionDeadlineRejected = "session_deadline_rejected"
|
|
||||||
)
|
|
||||||
|
|
||||||
// expiresAtLayout is the wire format used for MetaSessionExpiresAt.
|
|
||||||
// Producer and consumers both go through FormatExpiresAt/ParseExpiresAt
|
|
||||||
// so this layout stays a single source of truth.
|
|
||||||
const expiresAtLayout = time.RFC3339
|
|
||||||
|
|
||||||
// FormatExpiresAt encodes a deadline for MetaSessionExpiresAt. Always
|
|
||||||
// emits UTC so a consumer in another timezone reads the same wall-clock
|
|
||||||
// deadline.
|
|
||||||
func FormatExpiresAt(t time.Time) string {
|
|
||||||
return t.UTC().Format(expiresAtLayout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseExpiresAt decodes the MetaSessionExpiresAt value back to a UTC
|
|
||||||
// time. Returns an error when the field is empty or malformed; the
|
|
||||||
// caller decides whether to fall back (zero value) or propagate.
|
|
||||||
func ParseExpiresAt(s string) (time.Time, error) {
|
|
||||||
t, err := time.Parse(expiresAtLayout, s)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, err
|
|
||||||
}
|
|
||||||
return t.UTC(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatLeadMinutes encodes a lead duration for MetaSessionLeadMinutes
|
|
||||||
// as the integer count of whole minutes. Sub-minute residuals are
|
|
||||||
// truncated — the field is informational ("expires in ~N minutes") and
|
|
||||||
// fractional minutes don't change what the UI displays.
|
|
||||||
func FormatLeadMinutes(d time.Duration) string {
|
|
||||||
return strconv.Itoa(int(d / time.Minute))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseLeadMinutes decodes a MetaSessionLeadMinutes value. Returns 0
|
|
||||||
// and the parse error for malformed input; consumers that prefer a
|
|
||||||
// silent fallback can simply ignore the error.
|
|
||||||
func ParseLeadMinutes(s string) (int, error) {
|
|
||||||
return strconv.Atoi(s)
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
// Package sessionwatch tracks the SSO session expiry deadline that the
|
|
||||||
// management server publishes via LoginResponse / SyncResponse and fires
|
|
||||||
// two warning events at fixed lead times before expiry: an interactive
|
|
||||||
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
|
|
||||||
// fallback dialog.
|
|
||||||
//
|
|
||||||
// The watcher is idempotent: Update may be called as often as the network
|
|
||||||
// map snapshots arrive. Repeating the same deadline is a no-op; a new
|
|
||||||
// deadline reschedules the timers and arms a fresh warning cycle.
|
|
||||||
//
|
|
||||||
// Warning firing is edge-detected. Each unique deadline value fires each
|
|
||||||
// warning callback at most once.
|
|
||||||
package sessionwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
package sessionwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeRecorder satisfies StatusRecorder and records every call so tests
|
|
||||||
// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent
|
|
||||||
// land in the same ordered events slice (with the Kind distinguishing
|
|
||||||
// them) so tests that care about ordering still work. lastDeadline holds
|
|
||||||
// the most recent value passed to SetSessionExpiresAt so tests can assert
|
|
||||||
// the recorder ended up cleared/set as expected.
|
|
||||||
type fakeRecorder struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
events []event
|
|
||||||
lastDeadline time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type eventKind int
|
|
||||||
|
|
||||||
const (
|
|
||||||
stateChange eventKind = iota
|
|
||||||
publish
|
|
||||||
)
|
|
||||||
|
|
||||||
type event struct {
|
|
||||||
kind eventKind
|
|
||||||
// Set only for publish events.
|
|
||||||
severity cProto.SystemEvent_Severity
|
|
||||||
category cProto.SystemEvent_Category
|
|
||||||
message string
|
|
||||||
meta map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op,
|
|
||||||
// a real change records the new value and fans out a state-change (the
|
|
||||||
// production recorder calls notifyStateChange internally). The baseline
|
|
||||||
// is the zero time, so an initial clear before any deadline is set emits
|
|
||||||
// nothing — matching the real recorder.
|
|
||||||
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if r.lastDeadline.Equal(deadline) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.lastDeadline = deadline
|
|
||||||
r.events = append(r.events, event{kind: stateChange})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *fakeRecorder) deadline() time.Time {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
return r.lastDeadline
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *fakeRecorder) PublishEvent(
|
|
||||||
severity cProto.SystemEvent_Severity,
|
|
||||||
category cProto.SystemEvent_Category,
|
|
||||||
message string,
|
|
||||||
_ string,
|
|
||||||
metadata map[string]string,
|
|
||||||
) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
r.events = append(r.events, event{
|
|
||||||
kind: publish,
|
|
||||||
severity: severity,
|
|
||||||
category: category,
|
|
||||||
message: message,
|
|
||||||
meta: metadata,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *fakeRecorder) snapshot() []event {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
out := make([]event, len(r.events))
|
|
||||||
copy(out, r.events)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e event) isFinalWarning() bool {
|
|
||||||
return e.kind == publish && e.meta[MetaSessionFinal] == "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e event) isWarning() bool {
|
|
||||||
return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
func countWhere(events []event, pred func(event) bool) int {
|
|
||||||
n := 0
|
|
||||||
for _, e := range events {
|
|
||||||
if pred(e) {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(500 * time.Millisecond)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if got := r.snapshot(); len(got) >= want {
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
got := r.snapshot()
|
|
||||||
t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
|
|
||||||
// matching the lead-only behaviour the pre-final-warning tests assume.
|
|
||||||
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
|
||||||
return NewWithLeads(lead, 0, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
_ = w.Update(time.Time{})
|
|
||||||
|
|
||||||
if got := r.snapshot(); len(got) != 0 {
|
|
||||||
t.Fatalf("expected no events on initial zero, got %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateNonZeroFiresStateChange(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
d := time.Now().Add(time.Hour)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
events := waitForEvents(t, r, 1)
|
|
||||||
if events[0].kind != stateChange {
|
|
||||||
t.Fatalf("expected stateChange, got %+v", events[0])
|
|
||||||
}
|
|
||||||
if !w.Deadline().Equal(d) {
|
|
||||||
t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSameDeadlineIsNoop(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
d := time.Now().Add(time.Hour)
|
|
||||||
_ = w.Update(d)
|
|
||||||
_ = w.Update(d)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
events := waitForEvents(t, r, 1)
|
|
||||||
if len(events) != 1 {
|
|
||||||
t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
lead := 50 * time.Millisecond
|
|
||||||
w := newWatcher(lead, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
// Deadline 80ms out — warning should fire after ~30ms.
|
|
||||||
d := time.Now().Add(80 * time.Millisecond)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
events := waitForEvents(t, r, 2)
|
|
||||||
if events[0].kind != stateChange {
|
|
||||||
t.Fatalf("event[0] should be stateChange, got %+v", events[0])
|
|
||||||
}
|
|
||||||
if !events[1].isWarning() {
|
|
||||||
t.Fatalf("event[1] should be a warning publish, got %+v", events[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(time.Hour, r) // lead > delta => fire immediately
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
d := time.Now().Add(10 * time.Millisecond)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
events := waitForEvents(t, r, 2)
|
|
||||||
if !events[1].isWarning() {
|
|
||||||
t.Fatalf("expected immediate warning publish, got %+v", events[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
lead := 50 * time.Millisecond
|
|
||||||
w := newWatcher(lead, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
|
|
||||||
_ = w.Update(first)
|
|
||||||
|
|
||||||
// Replace with a far-future deadline before the warning fires.
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
second := time.Now().Add(time.Hour)
|
|
||||||
_ = w.Update(second)
|
|
||||||
|
|
||||||
// Wait past when first's warning would have fired.
|
|
||||||
time.Sleep(80 * time.Millisecond)
|
|
||||||
|
|
||||||
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
|
|
||||||
t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
lead := 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,26 +20,14 @@ func randomBytesInHex(count int) (string, error) {
|
|||||||
return hex.EncodeToString(buf), nil
|
return hex.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateTokenAudience checks that the token is a well-formed JWT whose
|
// isValidAccessToken is a simple validation of the access token
|
||||||
// audience claim matches the expected audience.
|
func isValidAccessToken(token string, audience string) error {
|
||||||
//
|
|
||||||
// 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(token, ".")
|
encodedClaims := strings.Split(token, ".")[1]
|
||||||
if len(parts) != 3 {
|
claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
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,8 +34,6 @@ 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
|
||||||
@@ -44,26 +42,12 @@ 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,
|
||||||
@@ -125,7 +109,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("lazy connection manager is enabled by the management feature flag")
|
log.Warnf("lazy connection manager is enabled by management feature flag")
|
||||||
e.initLazyManager(ctx)
|
e.initLazyManager(ctx)
|
||||||
e.statusRecorder.UpdateLazyConnection(true)
|
e.statusRecorder.UpdateLazyConnection(true)
|
||||||
return e.addPeersToLazyConnManager()
|
return e.addPeersToLazyConnManager()
|
||||||
@@ -254,20 +238,12 @@ 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) {
|
||||||
e.lazyConnMgrMu.RLock()
|
if !e.isStartedWithLazyMgr() {
|
||||||
lazyConnMgr := e.lazyConnMgr
|
|
||||||
started := lazyConnMgr != nil && e.lazyCtxCancel != nil
|
|
||||||
e.lazyConnMgrMu.RUnlock()
|
|
||||||
if !started {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
if found := e.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)
|
||||||
}
|
}
|
||||||
@@ -292,22 +268,16 @@ 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.lazyConnMgrMu.Lock()
|
|
||||||
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
|
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() {
|
||||||
@@ -346,10 +316,7 @@ 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,21 +1,10 @@
|
|||||||
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) {
|
||||||
@@ -49,58 +38,3 @@ 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,10 +257,7 @@ 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 func() {
|
defer c.statusRecorder.ClientStop()
|
||||||
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 {
|
||||||
@@ -280,15 +277,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
||||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// On daemon shutdown / Down() the parent context is cancelled
|
|
||||||
// and the dial fails with "context canceled". Wrapping that
|
|
||||||
// into state would leave the snapshot stuck at Connecting+err
|
|
||||||
// until the backoff loop wakes up — instead let the operation
|
|
||||||
// return cleanly so the deferred state.Set(StatusIdle) takes
|
|
||||||
// effect on the next iteration.
|
|
||||||
if c.ctx.Err() != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err))
|
return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err))
|
||||||
}
|
}
|
||||||
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
|
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
|
||||||
@@ -427,10 +415,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed the session-expiry deadline from the LoginResponse. Subsequent
|
|
||||||
// changes flow in through SyncResponse and are applied in handleSync.
|
|
||||||
engine.ApplySessionDeadline(loginResp.GetSessionExpiresAt())
|
|
||||||
|
|
||||||
log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress())
|
log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress())
|
||||||
state.Set(StatusConnected)
|
state.Set(StatusConnected)
|
||||||
|
|
||||||
@@ -467,10 +451,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.statusRecorder.ClientStart()
|
c.statusRecorder.ClientStart()
|
||||||
// Wrap the backoff with c.ctx so Down()/actCancel propagates into the
|
|
||||||
// inter-attempt sleep — otherwise a 15s MaxInterval can keep the retry
|
|
||||||
// loop alive long after the caller asked to give up, leaving the
|
|
||||||
// status stream stuck at Connecting.
|
|
||||||
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
|
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
||||||
@@ -621,7 +601,6 @@ 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),
|
||||||
|
|
||||||
@@ -697,7 +676,6 @@ 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,16 +229,9 @@ scutil_dns.txt (macOS only):
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
clientLogFile = "client.log"
|
clientLogFile = "client.log"
|
||||||
uiLogFile = "gui-client.log"
|
|
||||||
errorLogFile = "netbird.err"
|
errorLogFile = "netbird.err"
|
||||||
stdoutLogFile = "netbird.out"
|
stdoutLogFile = "netbird.out"
|
||||||
|
|
||||||
// Rotated-log glob prefixes (base log name without extension) passed to
|
|
||||||
// addRotatedLogFiles. The daemon's own log and the GUI log live in the same
|
|
||||||
// dir, so the prefixes must be disjoint to keep their rotated siblings apart.
|
|
||||||
clientLogPrefix = "client"
|
|
||||||
uiLogPrefix = "gui-client"
|
|
||||||
|
|
||||||
darwinErrorLogPath = "/var/log/netbird.out.log"
|
darwinErrorLogPath = "/var/log/netbird.out.log"
|
||||||
darwinStdoutLogPath = "/var/log/netbird.err.log"
|
darwinStdoutLogPath = "/var/log/netbird.err.log"
|
||||||
)
|
)
|
||||||
@@ -256,7 +249,6 @@ type BundleGenerator struct {
|
|||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
syncResponse *mgmProto.SyncResponse
|
syncResponse *mgmProto.SyncResponse
|
||||||
logPath string
|
logPath string
|
||||||
uiLogPath string
|
|
||||||
tempDir string
|
tempDir string
|
||||||
statePath string
|
statePath string
|
||||||
cpuProfile []byte
|
cpuProfile []byte
|
||||||
@@ -284,7 +276,6 @@ type GeneratorDependencies struct {
|
|||||||
StatusRecorder *peer.Status
|
StatusRecorder *peer.Status
|
||||||
SyncResponse *mgmProto.SyncResponse
|
SyncResponse *mgmProto.SyncResponse
|
||||||
LogPath string
|
LogPath string
|
||||||
UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one.
|
|
||||||
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
|
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
|
||||||
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
|
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
|
||||||
CPUProfile []byte
|
CPUProfile []byte
|
||||||
@@ -309,7 +300,6 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
|||||||
statusRecorder: deps.StatusRecorder,
|
statusRecorder: deps.StatusRecorder,
|
||||||
syncResponse: deps.SyncResponse,
|
syncResponse: deps.SyncResponse,
|
||||||
logPath: deps.LogPath,
|
logPath: deps.LogPath,
|
||||||
uiLogPath: deps.UILogPath,
|
|
||||||
tempDir: deps.TempDir,
|
tempDir: deps.TempDir,
|
||||||
statePath: deps.StatePath,
|
statePath: deps.StatePath,
|
||||||
cpuProfile: deps.CPUProfile,
|
cpuProfile: deps.CPUProfile,
|
||||||
@@ -421,10 +411,6 @@ func (g *BundleGenerator) createArchive() error {
|
|||||||
log.Errorf("failed to add logs to debug bundle: %v", err)
|
log.Errorf("failed to add logs to debug bundle: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.addUILog(); err != nil {
|
|
||||||
log.Errorf("failed to add UI log to debug bundle: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := g.addUpdateLogs(); err != nil {
|
if err := g.addUpdateLogs(); err != nil {
|
||||||
log.Errorf("failed to add updater logs: %v", err)
|
log.Errorf("failed to add updater logs: %v", err)
|
||||||
}
|
}
|
||||||
@@ -480,6 +466,7 @@ 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,
|
||||||
@@ -676,7 +663,6 @@ 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))
|
||||||
@@ -1000,7 +986,7 @@ func (g *BundleGenerator) addLogfile() error {
|
|||||||
return fmt.Errorf("add client log file to zip: %w", err)
|
return fmt.Errorf("add client log file to zip: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
g.addRotatedLogFiles(logDir, clientLogPrefix)
|
g.addRotatedLogFiles(logDir)
|
||||||
|
|
||||||
stdErrLogPath := filepath.Join(logDir, errorLogFile)
|
stdErrLogPath := filepath.Join(logDir, errorLogFile)
|
||||||
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
|
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
|
||||||
@@ -1020,25 +1006,6 @@ func (g *BundleGenerator) addLogfile() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addUILog adds the desktop UI's gui-client.log (and its rotated siblings) to
|
|
||||||
// the bundle. The path is reported by the UI via RegisterUILog; empty when no
|
|
||||||
// UI registered one (e.g. headless / server). Missing file is non-fatal — the
|
|
||||||
// UI only writes it while the daemon is in debug, so it's often absent.
|
|
||||||
func (g *BundleGenerator) addUILog() error {
|
|
||||||
if g.uiLogPath == "" {
|
|
||||||
log.Debugf("no UI log path registered, skipping in debug bundle")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := g.addSingleLogfile(g.uiLogPath, uiLogFile); err != nil {
|
|
||||||
return fmt.Errorf("add UI log file to zip: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
g.addRotatedLogFiles(filepath.Dir(g.uiLogPath), uiLogPrefix)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addSingleLogfile adds a single log file to the archive
|
// addSingleLogfile adds a single log file to the archive
|
||||||
func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
|
func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
|
||||||
logFile, err := os.Open(logPath)
|
logFile, err := os.Open(logPath)
|
||||||
@@ -1111,16 +1078,14 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount.
|
// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount
|
||||||
// prefix is the base log name without extension (e.g. "client", "gui-client");
|
func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
||||||
// the glob matches both files rotated by us and by logrotate on linux.
|
|
||||||
func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) {
|
|
||||||
if g.logFileCount == 0 {
|
if g.logFileCount == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// This pattern matches both logs rotated by us and logrotate on linux
|
// This regex will match both logs rotated by us and logrotate on linux
|
||||||
pattern := filepath.Join(logDir, prefix+"*.log.*")
|
pattern := filepath.Join(logDir, "client*.log.*")
|
||||||
files, err := filepath.Glob(pattern)
|
files, err := filepath.Glob(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("failed to glob rotated logs: %v", err)
|
log.Warnf("failed to glob rotated logs: %v", err)
|
||||||
|
|||||||
@@ -40,25 +40,6 @@ func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
|
|||||||
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
|
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddRotatedLogFiles_GUIPrefix asserts the prefix parameter scopes the glob
|
|
||||||
// to the GUI log: gui-client.log.* rotated siblings are picked up and the
|
|
||||||
// daemon's own client.log.* are not (and vice versa, covered above). This is
|
|
||||||
// the load-bearing check for the gui-client.log bundle collection — the old
|
|
||||||
// "client*.log.*" glob would have missed gui-client rotations.
|
|
||||||
func TestAddRotatedLogFiles_GUIPrefix(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
writeFile(t, filepath.Join(dir, "gui-client.log.1"), "gui rotated\n")
|
|
||||||
writeGzFile(t, filepath.Join(dir, "gui-client.log.2.gz"), "gui rotated gz\n")
|
|
||||||
writeFile(t, filepath.Join(dir, "client.log.1"), "daemon rotated\n")
|
|
||||||
|
|
||||||
names := runAddRotatedLogFilesPrefix(t, dir, "gui-client", 10)
|
|
||||||
|
|
||||||
require.Contains(t, names, "gui-client.log.1", "gui-client rotated file should be in bundle")
|
|
||||||
require.Contains(t, names, "gui-client.log.2.gz", "gui-client gz rotated file should be in bundle")
|
|
||||||
require.NotContains(t, names, "client.log.1", "daemon rotated file must not match the gui-client prefix")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
|
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
|
||||||
// logFileCount rotated files are bundled, ordered by mtime.
|
// logFileCount rotated files are bundled, ordered by mtime.
|
||||||
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
|
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
|
||||||
@@ -86,10 +67,6 @@ func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
|
|||||||
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
|
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
|
||||||
// zip writer and returns the set of entry names that ended up in the archive.
|
// zip writer and returns the set of entry names that ended up in the archive.
|
||||||
func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
|
func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
|
||||||
return runAddRotatedLogFilesPrefix(t, dir, "client", logFileCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount uint32) map[string]struct{} {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -97,7 +74,7 @@ func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount
|
|||||||
archive: zip.NewWriter(&buf),
|
archive: zip.NewWriter(&buf),
|
||||||
logFileCount: logFileCount,
|
logFileCount: logFileCount,
|
||||||
}
|
}
|
||||||
g.addRotatedLogFiles(dir, prefix)
|
g.addRotatedLogFiles(dir)
|
||||||
require.NoError(t, g.archive.Close())
|
require.NoError(t, g.archive.Close())
|
||||||
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||||
|
|||||||
@@ -887,8 +887,6 @@ 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,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -37,43 +36,7 @@ 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 netip.Addr) (known, connected bool)
|
IsConnectedByIP(ip string) (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 {
|
||||||
@@ -88,12 +51,6 @@ 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
|
||||||
@@ -102,12 +59,11 @@ 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),
|
||||||
warmupTimeout: lazyWarmupTimeoutFromEnv(),
|
ctx: ctx,
|
||||||
ctx: ctx,
|
cancel: cancel,
|
||||||
cancel: cancel,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,14 +76,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -174,9 +122,6 @@ 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
|
||||||
@@ -550,8 +495,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, ok := extractRecordAddr(rr)
|
ip := extractRecordIP(rr)
|
||||||
if !ok {
|
if ip == "" {
|
||||||
kept = append(kept, rr)
|
kept = append(kept, rr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -573,57 +518,22 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
|||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved
|
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
|
||||||
// answer points at and waits briefly for one to connect, so the caller's first
|
// an A or AAAA record, or "" for any other record type.
|
||||||
// request doesn't race the connection establishment. Warm-up is scoped to
|
func extractRecordIP(rr dns.RR) string {
|
||||||
// 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:
|
||||||
addr, ok := netip.AddrFromSlice(r.A)
|
if r.A == nil {
|
||||||
return addr.Unmap(), ok
|
return ""
|
||||||
|
}
|
||||||
|
return r.A.String()
|
||||||
case *dns.AAAA:
|
case *dns.AAAA:
|
||||||
addr, ok := netip.AddrFromSlice(r.AAAA)
|
if r.AAAA == nil {
|
||||||
return addr.Unmap(), ok
|
return ""
|
||||||
|
}
|
||||||
|
return r.AAAA.String()
|
||||||
}
|
}
|
||||||
return netip.Addr{}, false
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 netip.Addr) (known, connected bool) {
|
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||||
v, ok := m.byIP[ip.String()]
|
v, ok := m.byIP[ip]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
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,7 +8,6 @@ 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"
|
||||||
@@ -93,11 +92,6 @@ 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,7 +82,6 @@ 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 {
|
||||||
@@ -492,13 +491,6 @@ 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()
|
||||||
@@ -1443,11 +1435,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 netip.Addr) (known, connected bool) {
|
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||||
if l.status == nil {
|
if l.status == nil {
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
state, ok := l.status.PeerStateByIP(ip.String())
|
state, ok := l.status.PeerStateByIP(ip)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,16 +292,18 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) {
|
|||||||
return customPort, nil
|
return customPort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0"))
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
|
addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect
|
||||||
if err = probeListener.Close(); err != nil {
|
err = probeListener.Close()
|
||||||
|
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 port, nil
|
return addrPort.Port(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
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:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
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,14 +52,11 @@ 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,11 +50,5 @@ 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,10 +64,7 @@ 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"
|
||||||
@@ -150,7 +147,6 @@ 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.
|
||||||
@@ -224,12 +220,11 @@ 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
|
// forwardingRules holds the ingress forward rules applied for the current target.
|
||||||
// a NetworkMapEnvelope (capability=3 peers only). Held alongside the
|
// Wholesale sections (incl. forward rules) run only on the first pass of a target;
|
||||||
// NetworkMap that Calculate() produced from it so future incremental
|
// it is stashed here so the final, peer-converged pass can build the lazy-connection
|
||||||
// updates have a base to apply changes against. nil for legacy-format
|
// exclude list without recomputing them on every bounded peer pass.
|
||||||
// peers. Guarded by syncMsgMux.
|
forwardingRules []firewallManager.ForwardRule
|
||||||
latestComponents *types.NetworkMapComponents
|
|
||||||
|
|
||||||
networkMonitor *networkmonitor.NetworkMonitor
|
networkMonitor *networkmonitor.NetworkMonitor
|
||||||
|
|
||||||
@@ -285,20 +280,6 @@ type Engine struct {
|
|||||||
jobExecutorWG sync.WaitGroup
|
jobExecutorWG sync.WaitGroup
|
||||||
|
|
||||||
exposeManager *expose.Manager
|
exposeManager *expose.Manager
|
||||||
|
|
||||||
sessionWatcher sessionDeadlineWatcher
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionDeadlineWatcher is the engine-facing surface of the SSO session
|
|
||||||
// expiry watcher. The concrete implementation (sessionwatch.Watcher) is wired
|
|
||||||
// in via newSessionWatcher, which is build-tagged so the js/wasm build links a
|
|
||||||
// no-op stub instead of pulling the full sessionwatch package (and its timer
|
|
||||||
// machinery) into the binary — the wasm client never runs the engine's
|
|
||||||
// session-warning flow.
|
|
||||||
type sessionDeadlineWatcher interface {
|
|
||||||
Update(deadline time.Time) error
|
|
||||||
Dismiss()
|
|
||||||
Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer is an instance of the Connection Peer
|
// Peer is an instance of the Connection Peer
|
||||||
@@ -350,17 +331,6 @@ func NewEngine(
|
|||||||
updateManager: services.UpdateManager,
|
updateManager: services.UpdateManager,
|
||||||
syncStoreDir: config.StateDir,
|
syncStoreDir: config.StateDir,
|
||||||
}
|
}
|
||||||
// sessionWatcher keeps the SubscribeStatus consumers in sync with the
|
|
||||||
// session expiry deadline. Deadline-change ticks come for free via
|
|
||||||
// Status.SetSessionExpiresAt; the watcher exists to push a wake-up at
|
|
||||||
// T-WarningLead and T-FinalWarningLead so the UI repaints the remaining
|
|
||||||
// time / warning state even when nothing else changed, and to publish
|
|
||||||
// two SystemEvents (the warning composition lives in sessionwatch so
|
|
||||||
// the wire format stays owned by one package):
|
|
||||||
// - T-WarningLead → interactive "Extend now / Dismiss" notification
|
|
||||||
// - T-FinalWarningLead → auto-opened SessionAboutToExpire dialog,
|
|
||||||
// suppressed when the user dismissed the earlier warning
|
|
||||||
engine.sessionWatcher = newSessionWatcher(engine.statusRecorder)
|
|
||||||
|
|
||||||
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
||||||
return engine
|
return engine
|
||||||
@@ -427,10 +397,6 @@ func (e *Engine) stopLocked() {
|
|||||||
e.srWatcher.Close()
|
e.srWatcher.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
if e.sessionWatcher != nil {
|
|
||||||
e.sessionWatcher.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.updateManager != nil {
|
if e.updateManager != nil {
|
||||||
e.updateManager.SetDownloadOnly()
|
e.updateManager.SetDownloadOnly()
|
||||||
}
|
}
|
||||||
@@ -562,7 +528,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, publicKey)
|
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create rosenpass manager: %w", err)
|
return fmt.Errorf("create rosenpass manager: %w", err)
|
||||||
}
|
}
|
||||||
@@ -663,24 +629,8 @@ 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())
|
||||||
|
|
||||||
@@ -830,7 +780,15 @@ func (e *Engine) blockLanAccess() {
|
|||||||
|
|
||||||
// modifyPeers updates peers that have been modified (e.g. IP address has been changed).
|
// modifyPeers updates peers that have been modified (e.g. IP address has been changed).
|
||||||
// It closes the existing connection, removes it from the peerConns map, and creates a new one.
|
// It closes the existing connection, removes it from the peerConns map, and creates a new one.
|
||||||
func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
// maxPeersPerSyncPass is the default per-pass cap on how many peers each of
|
||||||
|
// removePeers/modifyPeers/addNewPeers applies, so syncMsgMux is held only for a
|
||||||
|
// batch at a time and other subsystems can interleave between passes. It is
|
||||||
|
// passed in (not read globally) so tests can exercise the multi-pass path.
|
||||||
|
const maxPeersPerSyncPass = 300
|
||||||
|
|
||||||
|
// modifyPeers re-applies up to maxBatch changed peers per call. It returns true
|
||||||
|
// when more changed peers remained than the cap, so the caller re-runs.
|
||||||
|
func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig, maxBatch int) (bool, error) {
|
||||||
|
|
||||||
// first, check if peers have been modified
|
// first, check if peers have been modified
|
||||||
var modified []*mgmProto.RemotePeerConfig
|
var modified []*mgmProto.RemotePeerConfig
|
||||||
@@ -860,26 +818,32 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
more := false
|
||||||
|
if len(modified) > maxBatch {
|
||||||
|
modified = modified[:maxBatch]
|
||||||
|
more = true
|
||||||
|
}
|
||||||
|
|
||||||
// second, close all modified connections and remove them from the state map
|
// second, close all modified connections and remove them from the state map
|
||||||
for _, p := range modified {
|
for _, p := range modified {
|
||||||
err := e.removePeer(p.GetWgPubKey())
|
if err := e.removePeer(p.GetWgPubKey()); err != nil {
|
||||||
if err != nil {
|
return false, err
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// third, add the peer connections again
|
// third, add the peer connections again
|
||||||
for _, p := range modified {
|
for _, p := range modified {
|
||||||
err := e.addNewPeer(p)
|
if err := e.addNewPeer(p); err != nil {
|
||||||
if err != nil {
|
return false, err
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return more, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// removePeers finds and removes peers that do not exist anymore in the network map received from the Management Service.
|
// removePeers finds and removes peers that do not exist anymore in the network map received from the Management Service.
|
||||||
// It also removes peers that have been modified (e.g. change of IP address). They will be added again in addPeers method.
|
// It also removes peers that have been modified (e.g. change of IP address). They will be added again in addPeers method.
|
||||||
func (e *Engine) removePeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
// removePeers removes up to maxBatch peers per call. It returns true when more
|
||||||
|
// peers remained to remove than the cap, so the caller re-runs.
|
||||||
|
func (e *Engine) removePeers(peersUpdate []*mgmProto.RemotePeerConfig, maxBatch int) (bool, error) {
|
||||||
newPeers := make([]string, 0, len(peersUpdate))
|
newPeers := make([]string, 0, len(peersUpdate))
|
||||||
for _, p := range peersUpdate {
|
for _, p := range peersUpdate {
|
||||||
newPeers = append(newPeers, p.GetWgPubKey())
|
newPeers = append(newPeers, p.GetWgPubKey())
|
||||||
@@ -887,14 +851,19 @@ func (e *Engine) removePeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
|||||||
|
|
||||||
toRemove := util.SliceDiff(e.peerStore.PeersPubKey(), newPeers)
|
toRemove := util.SliceDiff(e.peerStore.PeersPubKey(), newPeers)
|
||||||
|
|
||||||
|
more := false
|
||||||
|
if len(toRemove) > maxBatch {
|
||||||
|
toRemove = toRemove[:maxBatch]
|
||||||
|
more = true
|
||||||
|
}
|
||||||
|
|
||||||
for _, p := range toRemove {
|
for _, p := range toRemove {
|
||||||
err := e.removePeer(p)
|
if err := e.removePeer(p); err != nil {
|
||||||
if err != nil {
|
return false, err
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
log.Infof("removed peer %s", p)
|
log.Infof("removed peer %s", p)
|
||||||
}
|
}
|
||||||
return nil
|
return more, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) removeAllPeers() error {
|
func (e *Engine) removeAllPeers() error {
|
||||||
@@ -973,72 +942,28 @@ func (e *Engine) phase(name string) func() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
// applySyncPass applies one bounded pass of the sync update under syncMsgMux and
|
||||||
started := time.Now()
|
// returns true if more peers remained than the per-pass cap. It is driven by the
|
||||||
defer func() {
|
// mapStateManager, which re-invokes it (releasing the lock between passes) until
|
||||||
duration := time.Since(started)
|
// the update is fully applied.
|
||||||
log.Infof("sync finished in %s", duration)
|
func (e *Engine) applySyncPass(update *mgmProto.SyncResponse, firstPass bool) (bool, error) {
|
||||||
e.clientMetrics.RecordSyncDuration(e.ctx, duration)
|
|
||||||
}()
|
|
||||||
e.syncMsgMux.Lock()
|
e.syncMsgMux.Lock()
|
||||||
defer e.syncMsgMux.Unlock()
|
defer e.syncMsgMux.Unlock()
|
||||||
|
|
||||||
// Check context INSIDE lock to ensure atomicity with shutdown
|
// Check context INSIDE lock to ensure atomicity with shutdown
|
||||||
if e.ctx.Err() != nil {
|
if e.ctx.Err() != nil {
|
||||||
return e.ctx.Err()
|
return false, e.ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
e.ApplySessionDeadline(update.GetSessionExpiresAt())
|
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
||||||
|
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")
|
||||||
err := e.updateNetbirdConfig(update.GetNetbirdConfig())
|
err := e.updateNetbirdConfig(update.GetNetbirdConfig())
|
||||||
done()
|
done()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, 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:
|
||||||
@@ -1046,50 +971,27 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
// 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 false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
done = e.phase("checks")
|
done = e.phase("checks")
|
||||||
err = e.updateChecksIfNew(update.Checks)
|
err = e.updateChecksIfNew(update.Checks)
|
||||||
done()
|
done()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
done = e.phase("persist")
|
|
||||||
// Only retain the components view when the server sent the envelope
|
|
||||||
// path. A legacy proto.NetworkMap means components == nil; writing it
|
|
||||||
// here would clobber a previously-cached snapshot, breaking the
|
|
||||||
// incremental-delta base on a future envelope sync.
|
|
||||||
if components != nil {
|
|
||||||
e.latestComponents = components
|
|
||||||
}
|
|
||||||
|
|
||||||
e.persistSyncResponse(update)
|
|
||||||
done()
|
|
||||||
|
|
||||||
// only apply new changes and ignore old ones
|
// only apply new changes and ignore old ones
|
||||||
if err := e.updateNetworkMap(nm); err != nil {
|
more, err := e.updateNetworkMap(nm, maxPeersPerSyncPass, firstPass)
|
||||||
return err
|
if err != nil {
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", "", nil)
|
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", "", nil)
|
||||||
|
|
||||||
return nil
|
return more, 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:
|
||||||
@@ -1137,6 +1039,13 @@ func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error {
|
|||||||
// (not syncMsgMux) is held for the whole Set so the store cannot be cleared (disabled /
|
// (not syncMsgMux) is held for the whole Set so the store cannot be cleared (disabled /
|
||||||
// engine close) mid-call and have this write resurrect a file that was just removed.
|
// engine close) mid-call and have this write resurrect a file that was just removed.
|
||||||
func (e *Engine) persistSyncResponse(update *mgmProto.SyncResponse) {
|
func (e *Engine) persistSyncResponse(update *mgmProto.SyncResponse) {
|
||||||
|
// Only persist updates that carry a network map. Config-only updates (e.g. relay
|
||||||
|
// token rotation, STUN/TURN) have a nil NetworkMap; persisting them would overwrite
|
||||||
|
// the last full map on disk and break restore-on-restart.
|
||||||
|
if update.GetNetworkMap() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
e.syncRespMux.RLock()
|
e.syncRespMux.RLock()
|
||||||
defer e.syncRespMux.RUnlock()
|
defer e.syncRespMux.RUnlock()
|
||||||
|
|
||||||
@@ -1251,7 +1160,6 @@ 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,
|
||||||
@@ -1386,7 +1294,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
|||||||
ClientMetrics: e.clientMetrics,
|
ClientMetrics: e.clientMetrics,
|
||||||
DaemonVersion: version.NetbirdVersion(),
|
DaemonVersion: version.NetbirdVersion(),
|
||||||
RefreshStatus: func() {
|
RefreshStatus: func() {
|
||||||
e.RunHealthProbes(e.ctx, true)
|
e.RunHealthProbes(true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1425,7 +1333,24 @@ func (e *Engine) receiveManagementEvents() {
|
|||||||
}
|
}
|
||||||
e.applyInfoFlags(info)
|
e.applyInfoFlags(info)
|
||||||
|
|
||||||
err := e.mgmClient.Sync(e.ctx, info, e.handleSync)
|
// The map-state manager converges the latest update in the background in
|
||||||
|
// bounded passes; the stream callback only hands it the newest target.
|
||||||
|
persist := func(u *mgmProto.SyncResponse) {
|
||||||
|
done := e.phase("persist")
|
||||||
|
e.persistSyncResponse(u)
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
manager := newMapStateManager(e.applySyncPass, persist, func(d time.Duration) {
|
||||||
|
log.Infof("sync finished in %s", d)
|
||||||
|
e.clientMetrics.RecordSyncDuration(e.ctx, d)
|
||||||
|
})
|
||||||
|
e.shutdownWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer e.shutdownWg.Done()
|
||||||
|
manager.run(e.ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := e.mgmClient.Sync(e.ctx, info, manager.SetTarget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// happens if management is unavailable for a long time.
|
// happens if management is unavailable for a long time.
|
||||||
// We want to cancel the operation of the whole client
|
// We want to cancel the operation of the whole client
|
||||||
@@ -1476,21 +1401,107 @@ func (e *Engine) updateTURNs(turns []*mgmProto.ProtectedHostConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
// updateNetworkMap applies the wholesale parts (config, routes, ACL, DNS) in full
|
||||||
|
// and up to maxBatch peers per phase. It returns true when more peers remained
|
||||||
|
// than the cap, so the caller re-runs until convergence.
|
||||||
|
func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap, maxBatch int, firstPass bool) (bool, error) {
|
||||||
// intentionally leave it before checking serial because for now it can happen that peer IP changed but serial didn't
|
// intentionally leave it before checking serial because for now it can happen that peer IP changed but serial didn't
|
||||||
if networkMap.GetPeerConfig() != nil {
|
if networkMap.GetPeerConfig() != nil {
|
||||||
err := e.updateConfig(networkMap.GetPeerConfig())
|
err := e.updateConfig(networkMap.GetPeerConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
serial := networkMap.GetSerial()
|
serial := networkMap.GetSerial()
|
||||||
if e.networkSerial > serial {
|
if e.networkSerial > serial {
|
||||||
log.Debugf("received outdated NetworkMap with serial %d, ignoring", serial)
|
log.Debugf("received outdated NetworkMap with serial %d, ignoring", serial)
|
||||||
return nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wholesale sections (firewall/ACL, DNS, routes, forward rules) are applied
|
||||||
|
// up-front and only once per target: they are cheap, local, idempotent and must
|
||||||
|
// be in place before peers come up (fail-closed). On the bounded re-runs that only
|
||||||
|
// drain the remaining peer batches they are skipped — the applied forward rules are
|
||||||
|
// reused from e.forwardingRules for the lazy-exclude finalize.
|
||||||
|
if firstPass {
|
||||||
|
e.applyWholesale(networkMap, serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers()))
|
||||||
|
|
||||||
|
doneOffline := e.phase("offline_peers")
|
||||||
|
e.updateOfflinePeers(networkMap.GetOfflinePeers())
|
||||||
|
doneOffline()
|
||||||
|
|
||||||
|
// Filter out own peer from the remote peers list
|
||||||
|
localPubKey := e.config.WgPrivateKey.PublicKey().String()
|
||||||
|
remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers()))
|
||||||
|
for _, p := range networkMap.GetRemotePeers() {
|
||||||
|
if p.GetWgPubKey() != localPubKey {
|
||||||
|
remotePeers = append(remotePeers, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No special case for cleanup: when management signals RemotePeersIsEmpty (e.g. our
|
||||||
|
// peer was deleted), remotePeers is already empty, so the bounded diff below removes
|
||||||
|
// every peer in batches — same path as a normal update, no unbounded removeAllPeers
|
||||||
|
// held under syncMsgMux in one shot.
|
||||||
|
doneRemoved := e.phase("removed_peers")
|
||||||
|
removeMore, err := e.removePeers(remotePeers, maxBatch)
|
||||||
|
doneRemoved()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
doneModified := e.phase("modified_peers")
|
||||||
|
modifyMore, err := e.modifyPeers(remotePeers, maxBatch)
|
||||||
|
doneModified()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
doneAdded := e.phase("added_peers")
|
||||||
|
addMore, err := e.addNewPeers(remotePeers, maxBatch)
|
||||||
|
doneAdded()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// needMore signals the caller to re-run when a peer phase hit its per-pass cap.
|
||||||
|
needMore := removeMore || modifyMore || addMore
|
||||||
|
|
||||||
|
e.statusRecorder.FinishPeerListModifications()
|
||||||
|
|
||||||
|
e.updatePeerSSHHostKeys(remotePeers)
|
||||||
|
|
||||||
|
if err := e.updateSSHClientConfig(remotePeers); err != nil {
|
||||||
|
log.Warnf("failed to update SSH client config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.updateSSHServerAuth(networkMap.GetSshAuth())
|
||||||
|
|
||||||
|
// Set the exclude list only once peers have fully converged (this pass added
|
||||||
|
// the last batch). It needs all target peers present in the store, and
|
||||||
|
// ExcludePeer has replace-semantics — a partial set mid-convergence would be wrong.
|
||||||
|
if !needMore {
|
||||||
|
doneLazy := e.phase("lazy_exclude")
|
||||||
|
excludedLazyPeers := e.toExcludedLazyPeers(e.forwardingRules, remotePeers)
|
||||||
|
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
||||||
|
doneLazy()
|
||||||
|
}
|
||||||
|
|
||||||
|
e.networkSerial = serial
|
||||||
|
|
||||||
|
return needMore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWholesale applies the cheap, local, idempotent map sections — lazy feature
|
||||||
|
// flag, firewall/legacy management, DNS, routes, ACL filtering, DNS forwarder and
|
||||||
|
// ingress forward rules — that must be in place before peers come up. It runs once
|
||||||
|
// per target (first pass only); the resulting forward rules are stashed in
|
||||||
|
// e.forwardingRules for the lazy-exclude finalize on the peer-converged pass.
|
||||||
|
func (e *Engine) applyWholesale(networkMap *mgmProto.NetworkMap, serial uint64) {
|
||||||
if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, networkMap.GetPeerConfig().GetLazyConnectionEnabled()); err != nil {
|
if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, networkMap.GetPeerConfig().GetLazyConnectionEnabled()); err != nil {
|
||||||
log.Errorf("failed to update lazy connection feature flag: %v", err)
|
log.Errorf("failed to update lazy connection feature flag: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1563,84 +1574,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
|||||||
log.Errorf("failed to update forward rules, err: %v", err)
|
log.Errorf("failed to update forward rules, err: %v", err)
|
||||||
}
|
}
|
||||||
done()
|
done()
|
||||||
|
e.forwardingRules = forwardingRules
|
||||||
log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers()))
|
|
||||||
|
|
||||||
done = e.phase("offline_peers")
|
|
||||||
e.updateOfflinePeers(networkMap.GetOfflinePeers())
|
|
||||||
done()
|
|
||||||
|
|
||||||
remotePeers, err := e.reconcilePeers(networkMap)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
|
|
||||||
done = e.phase("lazy_exclude")
|
|
||||||
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
|
|
||||||
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
|
||||||
done()
|
|
||||||
|
|
||||||
e.networkSerial = serial
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconcilePeers applies the remote peer list from the network map (removing,
|
|
||||||
// modifying and adding peers, then updating SSH config) and returns the remote
|
|
||||||
// peers with our own peer filtered out, for use by later sync steps.
|
|
||||||
func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) {
|
|
||||||
// Filter out own peer from the remote peers list
|
|
||||||
localPubKey := e.config.WgPrivateKey.PublicKey().String()
|
|
||||||
remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers()))
|
|
||||||
for _, p := range networkMap.GetRemotePeers() {
|
|
||||||
if p.GetWgPubKey() != localPubKey {
|
|
||||||
remotePeers = append(remotePeers, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanup request, most likely our peer has been deleted
|
|
||||||
if networkMap.GetRemotePeersIsEmpty() {
|
|
||||||
err := e.removeAllPeers()
|
|
||||||
e.statusRecorder.FinishPeerListModifications()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return remotePeers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
done := e.phase("removed_peers")
|
|
||||||
err := e.removePeers(remotePeers)
|
|
||||||
done()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
done = e.phase("modified_peers")
|
|
||||||
err = e.modifyPeers(remotePeers)
|
|
||||||
done()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
done = e.phase("added_peers")
|
|
||||||
err = e.addNewPeers(remotePeers)
|
|
||||||
done()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
e.statusRecorder.FinishPeerListModifications()
|
|
||||||
|
|
||||||
e.updatePeerSSHHostKeys(remotePeers)
|
|
||||||
|
|
||||||
if err := e.updateSSHClientConfig(remotePeers); err != nil {
|
|
||||||
log.Warnf("failed to update SSH client config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
e.updateSSHServerAuth(networkMap.GetSshAuth())
|
|
||||||
|
|
||||||
return remotePeers, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toDNSFeatureFlag(networkMap *mgmProto.NetworkMap) bool {
|
func toDNSFeatureFlag(networkMap *mgmProto.NetworkMap) bool {
|
||||||
@@ -1820,14 +1754,23 @@ func addrToString(addr netip.Addr) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addNewPeers adds peers that were not know before but arrived from the Management service with the update
|
// addNewPeers adds peers that were not know before but arrived from the Management service with the update
|
||||||
func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
// addNewPeers adds up to maxBatch not-yet-present peers per call. It returns true
|
||||||
|
// when more new peers remained than the cap, so the caller re-runs.
|
||||||
|
func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig, maxBatch int) (bool, error) {
|
||||||
|
added := 0
|
||||||
for _, p := range peersUpdate {
|
for _, p := range peersUpdate {
|
||||||
err := e.addNewPeer(p)
|
if _, ok := e.peerStore.PeerConn(p.GetWgPubKey()); ok {
|
||||||
if err != nil {
|
continue // already present (cheap skip), does not count toward the cap
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
if added >= maxBatch {
|
||||||
|
return true, nil // at least one more new peer remains
|
||||||
|
}
|
||||||
|
if err := e.addNewPeer(p); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
added++
|
||||||
}
|
}
|
||||||
return nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addNewPeer add peer if connection doesn't exist
|
// addNewPeer add peer if connection doesn't exist
|
||||||
@@ -2120,7 +2063,6 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
|||||||
e.config.BlockLANAccess,
|
e.config.BlockLANAccess,
|
||||||
e.config.BlockInbound,
|
e.config.BlockInbound,
|
||||||
e.config.DisableIPv6,
|
e.config.DisableIPv6,
|
||||||
e.config.SyncMessageVersion,
|
|
||||||
e.config.EnableSSHRoot,
|
e.config.EnableSSHRoot,
|
||||||
e.config.EnableSSHSFTP,
|
e.config.EnableSSHSFTP,
|
||||||
e.config.EnableSSHLocalPortForwarding,
|
e.config.EnableSSHLocalPortForwarding,
|
||||||
@@ -2313,20 +2255,7 @@ func (e *Engine) getRosenpassAddr() string {
|
|||||||
|
|
||||||
// RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services
|
// RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services
|
||||||
// and updates the status recorder with the latest states.
|
// and updates the status recorder with the latest states.
|
||||||
//
|
func (e *Engine) RunHealthProbes(waitForResult bool) bool {
|
||||||
// ctx scopes the (potentially slow) STUN/TURN probing: a caller that gives up —
|
|
||||||
// e.g. a Status RPC whose client disconnected — cancels its ctx and the probe
|
|
||||||
// returns instead of running to its per-component timeout. The engine's own
|
|
||||||
// lifetime ctx still applies independently, so an engine shutdown aborts the
|
|
||||||
// probe even if the caller's ctx is context.Background().
|
|
||||||
func (e *Engine) RunHealthProbes(ctx context.Context, waitForResult bool) bool {
|
|
||||||
// Tie the caller's ctx to the engine lifetime: either cancelling aborts
|
|
||||||
// the probe below.
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
|
||||||
defer cancel()
|
|
||||||
stop := context.AfterFunc(e.ctx, cancel)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
e.syncMsgMux.Lock()
|
e.syncMsgMux.Lock()
|
||||||
|
|
||||||
signalHealthy := e.signal.IsHealthy()
|
signalHealthy := e.signal.IsHealthy()
|
||||||
@@ -2349,9 +2278,9 @@ func (e *Engine) RunHealthProbes(ctx context.Context, waitForResult bool) bool {
|
|||||||
if runtime.GOOS != "js" {
|
if runtime.GOOS != "js" {
|
||||||
var results []relay.ProbeResult
|
var results []relay.ProbeResult
|
||||||
if waitForResult {
|
if waitForResult {
|
||||||
results = e.probeStunTurn.ProbeAllWaitResult(ctx, stuns, turns)
|
results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns)
|
||||||
} else {
|
} else {
|
||||||
results = e.probeStunTurn.ProbeAll(ctx, stuns, turns)
|
results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns)
|
||||||
}
|
}
|
||||||
e.statusRecorder.UpdateRelayStates(results)
|
e.statusRecorder.UpdateRelayStates(results)
|
||||||
|
|
||||||
@@ -2694,14 +2623,13 @@ 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 {
|
||||||
if e.peerRoutesAddr(p, r.TranslatedAddress) {
|
for _, allowedIP := range p.GetAllowedIps() {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -2711,27 +2639,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
|
||||||
"github.com/netbirdio/netbird/client/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ApplySessionDeadline propagates the absolute SSO session deadline carried on
|
|
||||||
// LoginResponse / SyncResponse to both the watcher (for the edge-triggered
|
|
||||||
// warning) and the status recorder (for the SubscribeStatus / Status RPC
|
|
||||||
// snapshot the UI consumes).
|
|
||||||
//
|
|
||||||
// The wire field is 3-state:
|
|
||||||
// - nil → snapshot carries no info; keep the
|
|
||||||
// previously-anchored deadline (no-op)
|
|
||||||
// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is
|
|
||||||
// disabled; clear both sinks
|
|
||||||
// - valid timestamp → new deadline; arm watcher, expose on
|
|
||||||
// status recorder
|
|
||||||
//
|
|
||||||
// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected
|
|
||||||
// value is treated as a clear on both sinks: the alternative — leaving the
|
|
||||||
// previously-known deadline in place — risks the UI confidently displaying
|
|
||||||
// a stale "expires in X" while the server has actually invalidated it.
|
|
||||||
func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
|
|
||||||
if ts == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var deadline time.Time
|
|
||||||
// Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled".
|
|
||||||
// Everything else flows through Watcher.Update, whose sanity-checks
|
|
||||||
// reject out-of-range / pre-epoch / far-future / too-stale values and
|
|
||||||
// clear on rejection.
|
|
||||||
if ts.GetSeconds() != 0 || ts.GetNanos() != 0 {
|
|
||||||
deadline = ts.AsTime().UTC()
|
|
||||||
}
|
|
||||||
if e.sessionWatcher == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Watcher.Update owns the propagation to the status recorder (the
|
|
||||||
// SubscribeStatus / Status snapshot the UI reads): a set writes the
|
|
||||||
// deadline, a clear or a sanity-check rejection writes the zero value.
|
|
||||||
// Keeping a single writer is what stops the recorder from drifting out
|
|
||||||
// of sync with the warning timers.
|
|
||||||
if err := e.sessionWatcher.Update(deadline); err != nil {
|
|
||||||
log.Errorf("auth session deadline rejected: %v, clearing", err)
|
|
||||||
e.statusRecorder.PublishEvent(
|
|
||||||
cProto.SystemEvent_ERROR,
|
|
||||||
cProto.SystemEvent_AUTHENTICATION,
|
|
||||||
"session deadline rejected",
|
|
||||||
"",
|
|
||||||
map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DismissSessionWarning records the user's "Dismiss" click on the
|
|
||||||
// T-WarningLead interactive notification and suppresses the upcoming
|
|
||||||
// T-FinalWarningLead fallback for the current deadline. No-op when the
|
|
||||||
// watcher is not running or holds no deadline.
|
|
||||||
func (e *Engine) DismissSessionWarning() {
|
|
||||||
if e.sessionWatcher == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.sessionWatcher.Dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtendAuthSession asks the management server to refresh the SSO session
|
|
||||||
// expiry deadline using the supplied JWT, then mirrors the new deadline into
|
|
||||||
// the daemon's state. The tunnel is untouched; no resync, no reconnect.
|
|
||||||
//
|
|
||||||
// Returns the new absolute UTC deadline (or zero time when the server
|
|
||||||
// reports the peer is not eligible for extension).
|
|
||||||
func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) {
|
|
||||||
if jwtToken == "" {
|
|
||||||
return time.Time{}, errors.New("jwt token is required")
|
|
||||||
}
|
|
||||||
if e.mgmClient == nil {
|
|
||||||
return time.Time{}, errors.New("management client is not initialised")
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := system.GetInfoWithChecks(ctx, e.checks)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("failed to collect system info for session extend: %v", err)
|
|
||||||
info = system.GetInfo(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf("extend auth session on management: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
e.ApplySessionDeadline(resp.GetSessionExpiresAt())
|
|
||||||
|
|
||||||
if resp.GetSessionExpiresAt().IsValid() {
|
|
||||||
return resp.GetSessionExpiresAt().AsTime().UTC(), nil
|
|
||||||
}
|
|
||||||
return time.Time{}, nil
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -124,7 +124,7 @@ func TestEngine_SSH(t *testing.T) {
|
|||||||
RemotePeersIsEmpty: false,
|
RemotePeersIsEmpty: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = engine.updateNetworkMap(networkMap)
|
_, err = engine.updateNetworkMap(networkMap, maxPeersPerSyncPass, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Nil(t, engine.sshServer)
|
assert.Nil(t, engine.sshServer)
|
||||||
@@ -146,7 +146,7 @@ func TestEngine_SSH(t *testing.T) {
|
|||||||
RemotePeersIsEmpty: false,
|
RemotePeersIsEmpty: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = engine.updateNetworkMap(networkMap)
|
_, err = engine.updateNetworkMap(networkMap, maxPeersPerSyncPass, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
time.Sleep(250 * time.Millisecond)
|
time.Sleep(250 * time.Millisecond)
|
||||||
@@ -159,7 +159,7 @@ func TestEngine_SSH(t *testing.T) {
|
|||||||
RemotePeersIsEmpty: false,
|
RemotePeersIsEmpty: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = engine.updateNetworkMap(networkMap)
|
_, err = engine.updateNetworkMap(networkMap, maxPeersPerSyncPass, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// time.Sleep(250 * time.Millisecond)
|
// time.Sleep(250 * time.Millisecond)
|
||||||
@@ -174,7 +174,7 @@ func TestEngine_SSH(t *testing.T) {
|
|||||||
RemotePeersIsEmpty: false,
|
RemotePeersIsEmpty: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = engine.updateNetworkMap(networkMap)
|
_, err = engine.updateNetworkMap(networkMap, maxPeersPerSyncPass, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Nil(t, engine.sshServer)
|
assert.Nil(t, engine.sshServer)
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the
|
|
||||||
// wire field carried on LoginResponse / SyncResponse:
|
|
||||||
//
|
|
||||||
// - nil pointer → no info; previously-anchored deadline survives
|
|
||||||
// - explicit zero value → "expiry disabled" sentinel; both sinks cleared
|
|
||||||
// - valid future timestamp → new deadline propagated to both sinks
|
|
||||||
func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
|
||||||
newEngine := func() *Engine {
|
|
||||||
recorder := peer.NewRecorder("")
|
|
||||||
return &Engine{
|
|
||||||
statusRecorder: recorder,
|
|
||||||
sessionWatcher: sessionwatch.New(recorder),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) {
|
|
||||||
e := newEngine()
|
|
||||||
deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
|
||||||
|
|
||||||
e.ApplySessionDeadline(timestamppb.New(deadline))
|
|
||||||
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline),
|
|
||||||
"status recorder should hold the new deadline")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) {
|
|
||||||
e := newEngine()
|
|
||||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
|
||||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
|
||||||
|
|
||||||
e.ApplySessionDeadline(nil)
|
|
||||||
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded),
|
|
||||||
"nil snapshot must not disturb the existing deadline")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) {
|
|
||||||
e := newEngine()
|
|
||||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
|
||||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
|
||||||
|
|
||||||
// Explicit zero Timestamp{} (seconds=0, nanos=0) is the
|
|
||||||
// "expiry disabled / not SSO" sentinel.
|
|
||||||
e.ApplySessionDeadline(×tamppb.Timestamp{})
|
|
||||||
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
|
||||||
"explicit zero sentinel must clear the deadline")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("invalid timestamp clears the deadline", func(t *testing.T) {
|
|
||||||
e := newEngine()
|
|
||||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
|
||||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
|
||||||
|
|
||||||
// Out-of-range nanos → IsValid()==false; same-meaning as the
|
|
||||||
// disabled sentinel for downstream sinks.
|
|
||||||
e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1})
|
|
||||||
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
|
||||||
"invalid timestamp must clear the deadline")
|
|
||||||
})
|
|
||||||
|
|
||||||
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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//go:build !js
|
|
||||||
|
|
||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newSessionWatcher returns the real SSO session expiry watcher for every
|
|
||||||
// non-wasm build. The js/wasm build gets a no-op stub from
|
|
||||||
// engine_sessionwatch_js.go so the sessionwatch package (and its timer
|
|
||||||
// machinery) never links into the wasm binary.
|
|
||||||
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
|
||||||
return sessionwatch.New(recorder)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
//go:build js
|
|
||||||
|
|
||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The
|
|
||||||
// wasm client never runs the engine's session-warning flow (the interactive
|
|
||||||
// T-WarningLead notification and the T-FinalWarningLead fallback dialog live
|
|
||||||
// in the desktop UI), so linking the full sessionwatch package (timers, event
|
|
||||||
// composition) would only bloat the binary.
|
|
||||||
//
|
|
||||||
// It still mirrors the deadline into the status recorder so the SubscribeStatus
|
|
||||||
// / Status snapshot the UI consumes stays correct — only the timer-driven
|
|
||||||
// warnings are dropped.
|
|
||||||
type noopSessionWatcher struct {
|
|
||||||
recorder *peer.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
|
||||||
return noopSessionWatcher{recorder: recorder}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update mirrors the real watcher's recorder propagation without the timers or
|
|
||||||
// sanity-check sentinels: a valid deadline is exposed on the status snapshot,
|
|
||||||
// the zero time clears it.
|
|
||||||
func (w noopSessionWatcher) Update(deadline time.Time) error {
|
|
||||||
if w.recorder != nil {
|
|
||||||
w.recorder.SetSessionExpiresAt(deadline)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (noopSessionWatcher) Dismiss() {
|
|
||||||
// No-op: only suppresses the timer-driven final-warning, which this stub never arms.
|
|
||||||
}
|
|
||||||
|
|
||||||
func (noopSessionWatcher) Close() {
|
|
||||||
// No-op: no timers to stop and no state to unwind; the recorder is cleared via Update(zero).
|
|
||||||
}
|
|
||||||
@@ -437,7 +437,7 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
|
|||||||
|
|
||||||
for _, c := range []testCase{case1, case2, case3, case4, case5, case6} {
|
for _, c := range []testCase{case1, case2, case3, case4, case5, case6} {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
err = engine.updateNetworkMap(c.networkMap)
|
_, err = engine.updateNetworkMap(c.networkMap, maxPeersPerSyncPass, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
return
|
return
|
||||||
@@ -464,6 +464,47 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chunked apply: with a per-pass cap smaller than the number of peers, a
|
||||||
|
// single updateNetworkMap applies one batch and reports more==true; the
|
||||||
|
// caller re-runs until convergence. (engine currently holds 0 peers.)
|
||||||
|
t.Run("chunked add converges over multiple passes", func(t *testing.T) {
|
||||||
|
nm := &mgmtProto.NetworkMap{
|
||||||
|
Serial: 6,
|
||||||
|
RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3},
|
||||||
|
}
|
||||||
|
|
||||||
|
more, err := engine.updateNetworkMap(nm, 1, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, more, "pass 1 should signal more")
|
||||||
|
require.Len(t, engine.peerStore.PeersPubKey(), 1)
|
||||||
|
|
||||||
|
more, err = engine.updateNetworkMap(nm, 1, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, more, "pass 2 should signal more")
|
||||||
|
require.Len(t, engine.peerStore.PeersPubKey(), 2)
|
||||||
|
|
||||||
|
more, err = engine.updateNetworkMap(nm, 1, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, more, "pass 3 should converge")
|
||||||
|
require.Len(t, engine.peerStore.PeersPubKey(), 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("chunked remove converges over multiple passes", func(t *testing.T) {
|
||||||
|
nm := &mgmtProto.NetworkMap{
|
||||||
|
Serial: 7,
|
||||||
|
RemotePeers: []*mgmtProto.RemotePeerConfig{peer1}, // remove peer2, peer3
|
||||||
|
}
|
||||||
|
|
||||||
|
more, err := engine.updateNetworkMap(nm, 1, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, more, "pass 1 should signal more (2 to remove, cap 1)")
|
||||||
|
|
||||||
|
more, err = engine.updateNetworkMap(nm, 1, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, more, "pass 2 should converge")
|
||||||
|
require.Len(t, engine.peerStore.PeersPubKey(), 1)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
|
func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
|
||||||
@@ -634,7 +675,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err = engine.updateNetworkMap(testCase.networkMap)
|
_, err = engine.updateNetworkMap(testCase.networkMap, maxPeersPerSyncPass, true)
|
||||||
assert.NoError(t, err, "shouldn't return error")
|
assert.NoError(t, err, "shouldn't return error")
|
||||||
assert.Equal(t, testCase.expectedSerial, input.inputSerial, "serial should match")
|
assert.Equal(t, testCase.expectedSerial, input.inputSerial, "serial should match")
|
||||||
assert.Len(t, input.clientRoutes, testCase.expectedLen, "clientRoutes len should match")
|
assert.Len(t, input.clientRoutes, testCase.expectedLen, "clientRoutes len should match")
|
||||||
@@ -838,7 +879,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err = engine.updateNetworkMap(testCase.networkMap)
|
_, err = engine.updateNetworkMap(testCase.networkMap, maxPeersPerSyncPass, true)
|
||||||
assert.NoError(t, err, "shouldn't return error")
|
assert.NoError(t, err, "shouldn't return error")
|
||||||
assert.Equal(t, testCase.expectedSerial, input.inputSerial, "serial should match")
|
assert.Equal(t, testCase.expectedSerial, input.inputSerial, "serial should match")
|
||||||
assert.Len(t, input.inputNSGroups, testCase.expectedZonesLen, "zones len should match")
|
assert.Len(t, input.inputNSGroups, testCase.expectedZonesLen, "zones len should match")
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ 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
|
||||||
@@ -61,9 +56,6 @@ 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
|
||||||
@@ -81,7 +73,6 @@ 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() {
|
||||||
@@ -210,7 +201,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) {
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.armActivityListener(peerCfg); err != nil {
|
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +288,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) {
|
|||||||
|
|
||||||
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
|
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
|
||||||
|
|
||||||
if err := m.armActivityListener(*mp.peerCfg); err != nil {
|
if err := m.activityManager.MonitorPeerActivity(*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
|
||||||
}
|
}
|
||||||
@@ -474,31 +465,6 @@ 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()
|
||||||
@@ -611,7 +577,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.armActivityListener(*mp.peerCfg); err != nil {
|
if err := m.activityManager.MonitorPeerActivity(*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
|
||||||
}
|
}
|
||||||
|
|||||||
214
client/internal/mapsync.go
Normal file
214
client/internal/mapsync.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mapStateManager is the single read/write point between the management stream
|
||||||
|
// (writes) and the convergence loop (reads/applies).
|
||||||
|
//
|
||||||
|
// The stream calls SetTarget with the latest full SyncResponse — the complete
|
||||||
|
// desired state. A single background goroutine (run) applies it to the engine in
|
||||||
|
// bounded passes via apply() until converged, releasing syncMsgMux between passes
|
||||||
|
// so other subsystems interleave. If a newer update arrives mid-flight, the loop
|
||||||
|
// coalesces: it keeps converging toward the latest target and the intermediate one
|
||||||
|
// is SKIPPED — never applied on its own (logged, no onConverged).
|
||||||
|
//
|
||||||
|
// Convergence is a single comparison: appliedGen == targetGen. targetGen
|
||||||
|
// increments on every SetTarget (an internal generation counter, so it also covers
|
||||||
|
// config-only updates that carry no network-map serial).
|
||||||
|
//
|
||||||
|
// onConverged fires once for each — and only each — map that is actually processed
|
||||||
|
// (i.e. converged as the target). Skipped/superseded maps and dropped-on-error maps
|
||||||
|
// do NOT fire it. So "sync finished in X" / RecordSyncDuration always corresponds
|
||||||
|
// to a real, completed alignment.
|
||||||
|
type mapStateManager struct {
|
||||||
|
// apply performs one bounded apply pass and reports whether more passes are needed.
|
||||||
|
// firstPass is true on the first pass of a given target, so the caller can run
|
||||||
|
// wholesale (firewall/routes/DNS/forward-rules) once per target and skip it on the
|
||||||
|
// re-runs that only drain the bounded peer batches. The manager owns this signal
|
||||||
|
// because it owns the convergence boundary; the engine need not track serials for it.
|
||||||
|
apply func(update *mgmProto.SyncResponse, firstPass bool) (bool, error)
|
||||||
|
// onConverged is called once per processed map, with the elapsed time since that
|
||||||
|
// map was received (for the sync-duration metric / "sync finished" log).
|
||||||
|
onConverged func(time.Duration)
|
||||||
|
// persist snapshots an update to disk for restore-on-restart. Called once per
|
||||||
|
// update received from management (in SetTarget), including ones later coalesced
|
||||||
|
// or skipped from apply, so the on-disk state mirrors what management last sent.
|
||||||
|
// The impl skips config-only updates (nil NetworkMap). May be nil.
|
||||||
|
persist func(*mgmProto.SyncResponse)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
target *mgmProto.SyncResponse
|
||||||
|
targetGen uint64
|
||||||
|
appliedGen uint64
|
||||||
|
targetSetAt time.Time
|
||||||
|
|
||||||
|
wake chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMapStateManager(apply func(update *mgmProto.SyncResponse, firstPass bool) (bool, error), persist func(*mgmProto.SyncResponse), onConverged func(time.Duration)) *mapStateManager {
|
||||||
|
return &mapStateManager{
|
||||||
|
apply: apply,
|
||||||
|
persist: persist,
|
||||||
|
onConverged: onConverged,
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTarget records the latest update as the desired state and wakes the loop.
|
||||||
|
// It returns immediately; convergence happens in the background. Serial-based
|
||||||
|
// staleness of the network map is still enforced inside apply (updateNetworkMap).
|
||||||
|
func (m *mapStateManager) SetTarget(update *mgmProto.SyncResponse) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
// A target that has not settled yet (targetGen > appliedGen) is being superseded
|
||||||
|
// before it converged: we coalesce to the latest map and never apply this one on
|
||||||
|
// its own. It is SKIPPED — logged here, and it will not fire onConverged.
|
||||||
|
if m.target != nil && m.targetGen > m.appliedGen {
|
||||||
|
log.Debugf("sync map (gen %d) superseded before convergence, skipping", m.targetGen)
|
||||||
|
}
|
||||||
|
m.target = m.mergeTarget(m.target, update)
|
||||||
|
// Bump an internal generation counter, NOT the map serial: config-only updates
|
||||||
|
// (relay token rotation, STUN/TURN) arrive with NetworkMap == nil and carry no
|
||||||
|
// serial, yet must still be applied. Every SetTarget is therefore a distinct
|
||||||
|
// target regardless of payload. Map-serial staleness is enforced separately
|
||||||
|
// inside apply (updateNetworkMap).
|
||||||
|
m.targetGen++
|
||||||
|
m.targetSetAt = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case m.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist every update received from management — once per update (not per apply
|
||||||
|
// pass), and including ones that get coalesced/skipped from apply, so the on-disk
|
||||||
|
// state always reflects the latest map management sent. Done after waking the loop
|
||||||
|
// so convergence can start in parallel with the disk write. The persist impl skips
|
||||||
|
// config-only updates (nil NetworkMap).
|
||||||
|
if m.persist != nil {
|
||||||
|
m.persist(update)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeTarget combines the currently pending target with a freshly received update
|
||||||
|
// and returns the new desired state. It is called under m.mu from SetTarget and is
|
||||||
|
// the single seam where the replace-vs-squash decision lives.
|
||||||
|
//
|
||||||
|
// Today management always sends a FULL map (the complete desired state), so the
|
||||||
|
// update simply replaces whatever was pending — prev is ignored. When management
|
||||||
|
// starts sending incremental/delta updates, squash `update` onto `prev` here; the
|
||||||
|
// rest of the manager (generation tracking, convergence, signaling) is unaffected
|
||||||
|
// because it already treats target as "the complete desired state, whatever it is".
|
||||||
|
func (m *mapStateManager) mergeTarget(prev, update *mgmProto.SyncResponse) *mgmProto.SyncResponse {
|
||||||
|
// Nothing pending to preserve (no prev, or prev already fully applied): plain replace.
|
||||||
|
if prev == nil || update == nil || m.targetGen == m.appliedGen {
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
// prev still has unapplied state (targetGen > appliedGen). In the sync protocol a
|
||||||
|
// nil component means "no change", so if `update` omits a component that prev
|
||||||
|
// carried, carry prev's forward — otherwise coalescing an update that superseded a
|
||||||
|
// not-yet-applied one would silently drop the map or config it uniquely brought.
|
||||||
|
// A present component in `update` is newer and wins. Management may send map-only
|
||||||
|
// updates (nil config) and config-only updates (nil map); both are handled here.
|
||||||
|
// A nil component in `update` means "no change", so fill it in from prev — otherwise
|
||||||
|
// coalescing an update that superseded a not-yet-applied one would drop the map or
|
||||||
|
// config it uniquely carried. A present component in `update` is newer and wins.
|
||||||
|
// We mutate `update` in place: it is a fresh per-message allocation from the sync
|
||||||
|
// stream (see receiveUpdatesEvents — not reused), and persisting this squashed target
|
||||||
|
// is correct, since it is the current full (superset) desired state.
|
||||||
|
if update.GetNetworkMap() == nil && prev.GetNetworkMap() != nil {
|
||||||
|
update.NetworkMap = prev.GetNetworkMap()
|
||||||
|
update.Checks = prev.Checks // checks travel with the map
|
||||||
|
}
|
||||||
|
if update.GetNetbirdConfig() == nil && prev.GetNetbirdConfig() != nil {
|
||||||
|
update.NetbirdConfig = prev.GetNetbirdConfig()
|
||||||
|
}
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
// run drives convergence until ctx is done. It is meant to run in its own goroutine.
|
||||||
|
func (m *mapStateManager) run(ctx context.Context) {
|
||||||
|
// passGen is the generation of the most recent apply() call (0 = none). A pass is
|
||||||
|
// the first for its target when its generation differs from the previous one —
|
||||||
|
// true on a fresh target and on a coalesced switch to a newer target mid-flight.
|
||||||
|
var passGen uint64
|
||||||
|
for {
|
||||||
|
m.mu.Lock()
|
||||||
|
target, tg, ag := m.target, m.targetGen, m.appliedGen
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
// Fully converged (or nothing yet): block until a new target arrives.
|
||||||
|
if target == nil || ag == tg {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-m.wake:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
firstPass := tg != passGen
|
||||||
|
passGen = tg
|
||||||
|
more, err := m.apply(target, firstPass)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Log and DROP this target — do not retry it. A deterministic failure
|
||||||
|
// (e.g. a malformed peer in the map) would otherwise spin every pass
|
||||||
|
// making no progress. Management is the source of truth and re-delivers
|
||||||
|
// the full map on the next sync, so dropping is safe; peers already
|
||||||
|
// applied this convergence stay (idempotent diffs) and the remainder is
|
||||||
|
// reconciled by the next target. Mirrors the legacy handleSync path,
|
||||||
|
// where the apply error was logged by the gRPC client and the update
|
||||||
|
// dropped. No onConverged: this target did not converge.
|
||||||
|
log.Errorf("apply sync pass, dropping update: %v", err)
|
||||||
|
m.settle(tg, false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if more {
|
||||||
|
// keep converging the current target; syncMsgMux was released by apply
|
||||||
|
// between passes so other subsystems interleave.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// This pass converged. Mark applied and signal this one map.
|
||||||
|
m.settle(tg, true)
|
||||||
|
// if a newer target arrived mid-pass, settle is a no-op (targetGen != tg) and
|
||||||
|
// ag<tg next iteration -> apply it; this generation was skipped (logged in
|
||||||
|
// SetTarget) and is not signaled.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle marks generation tg as processed so the loop goes idle instead of
|
||||||
|
// re-applying the same target. It is a no-op when a newer target arrived during the
|
||||||
|
// pass (targetGen != tg), leaving appliedGen behind so that target re-applies — the
|
||||||
|
// just-finished generation was already counted as skipped.
|
||||||
|
//
|
||||||
|
// When signal is true (the pass converged) it fires onConverged once for this map;
|
||||||
|
// when false (the target was dropped on error) it does not — the map did not converge.
|
||||||
|
func (m *mapStateManager) settle(tg uint64, signal bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.targetGen != tg {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.appliedGen = tg
|
||||||
|
setAt := m.targetSetAt
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if signal && m.onConverged != nil {
|
||||||
|
m.onConverged(time.Since(setAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
281
client/internal/mapsync_test.go
Normal file
281
client/internal/mapsync_test.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mergeTarget fills components missing from the incoming update with the pending
|
||||||
|
// (not-yet-applied) prev's, in place, so a coalesced/superseded update does not drop
|
||||||
|
// the map or config it uniquely carried.
|
||||||
|
func TestMapStateManager_MergeTargetPreservesPendingState(t *testing.T) {
|
||||||
|
m := newMapStateManager(nil, nil, nil)
|
||||||
|
|
||||||
|
// config-only update while a full map is still converging (targetGen > appliedGen):
|
||||||
|
// the pending map (+ checks) is filled into the update in place
|
||||||
|
m.targetGen, m.appliedGen = 5, 4
|
||||||
|
prev := &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 5}}
|
||||||
|
update := &mgmProto.SyncResponse{NetbirdConfig: &mgmProto.NetbirdConfig{}}
|
||||||
|
merged := m.mergeTarget(prev, update)
|
||||||
|
require.Same(t, update, merged, "merges in place, returns the update")
|
||||||
|
require.EqualValues(t, 5, merged.GetNetworkMap().GetSerial(), "pending map preserved")
|
||||||
|
require.NotNil(t, merged.GetNetbirdConfig(), "new config kept")
|
||||||
|
|
||||||
|
// symmetric: map-only update while a config-only update is pending -> keep the config
|
||||||
|
m.targetGen, m.appliedGen = 5, 4
|
||||||
|
prev = &mgmProto.SyncResponse{NetbirdConfig: &mgmProto.NetbirdConfig{}}
|
||||||
|
update = &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 7}}
|
||||||
|
merged = m.mergeTarget(prev, update)
|
||||||
|
require.EqualValues(t, 7, merged.GetNetworkMap().GetSerial(), "new map kept")
|
||||||
|
require.NotNil(t, merged.GetNetbirdConfig(), "pending config preserved")
|
||||||
|
|
||||||
|
// prev already applied (targetGen == appliedGen): plain replace, no fill-in
|
||||||
|
m.targetGen, m.appliedGen = 5, 5
|
||||||
|
prev = &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 5}}
|
||||||
|
update = &mgmProto.SyncResponse{NetbirdConfig: &mgmProto.NetbirdConfig{}}
|
||||||
|
merged = m.mergeTarget(prev, update)
|
||||||
|
require.Same(t, update, merged)
|
||||||
|
require.Nil(t, merged.GetNetworkMap(), "no map grafted when prev already applied")
|
||||||
|
|
||||||
|
// nothing to carry (update has a map, prev has no config): plain replace
|
||||||
|
m.targetGen, m.appliedGen = 5, 4
|
||||||
|
prev = &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 5}}
|
||||||
|
update = &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 6}}
|
||||||
|
require.Same(t, update, m.mergeTarget(prev, update))
|
||||||
|
}
|
||||||
|
|
||||||
|
// converges over the bounded passes (apply returns more until the 3rd pass),
|
||||||
|
// fires onConverged exactly once, then blocks (no further apply) until a new target.
|
||||||
|
func TestMapStateManager_ConvergesThenStops(t *testing.T) {
|
||||||
|
var passes int32
|
||||||
|
var firstPasses int32
|
||||||
|
converged := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
apply := func(_ *mgmProto.SyncResponse, firstPass bool) (bool, error) {
|
||||||
|
n := atomic.AddInt32(&passes, 1)
|
||||||
|
if firstPass {
|
||||||
|
atomic.AddInt32(&firstPasses, 1)
|
||||||
|
}
|
||||||
|
return n < 3, nil // more on pass 1 and 2, converge on pass 3
|
||||||
|
}
|
||||||
|
m := newMapStateManager(apply, nil, func(time.Duration) { converged <- struct{}{} })
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go m.run(ctx)
|
||||||
|
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-converged:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("manager did not converge")
|
||||||
|
}
|
||||||
|
require.EqualValues(t, 3, atomic.LoadInt32(&passes))
|
||||||
|
require.EqualValues(t, 1, atomic.LoadInt32(&firstPasses), "firstPass true only on pass 1, false on re-runs of the same target")
|
||||||
|
|
||||||
|
// once converged the loop blocks: no further apply calls
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
require.EqualValues(t, 3, atomic.LoadInt32(&passes), "apply must not run after convergence")
|
||||||
|
}
|
||||||
|
|
||||||
|
// persist runs once per received update (not per apply pass), regardless of how many
|
||||||
|
// bounded passes that target takes to converge.
|
||||||
|
func TestMapStateManager_PersistsOncePerUpdate(t *testing.T) {
|
||||||
|
var passes, persists int32
|
||||||
|
converged := make(chan struct{}, 1)
|
||||||
|
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
|
||||||
|
n := atomic.AddInt32(&passes, 1)
|
||||||
|
return n < 3, nil // 3 passes for one target
|
||||||
|
}
|
||||||
|
persist := func(*mgmProto.SyncResponse) { atomic.AddInt32(&persists, 1) }
|
||||||
|
m := newMapStateManager(apply, persist, func(time.Duration) { converged <- struct{}{} })
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go m.run(ctx)
|
||||||
|
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
|
||||||
|
select {
|
||||||
|
case <-converged:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("did not converge")
|
||||||
|
}
|
||||||
|
require.EqualValues(t, 3, atomic.LoadInt32(&passes))
|
||||||
|
require.EqualValues(t, 1, atomic.LoadInt32(&persists), "persist once per update, not per pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
// every update received from management is persisted — even one that is coalesced /
|
||||||
|
// skipped from apply before it ever converges.
|
||||||
|
func TestMapStateManager_PersistsEveryUpdateIncludingSkipped(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
var persists int32
|
||||||
|
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
|
||||||
|
<-release // hold the first apply so the second update coalesces/skips
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
persist := func(*mgmProto.SyncResponse) { atomic.AddInt32(&persists, 1) }
|
||||||
|
m := newMapStateManager(apply, persist, nil)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go m.run(ctx)
|
||||||
|
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{})) // map1 -> apply blocks
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{})) // map2 supersedes map1 (skipped from apply)
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
// both updates persisted even though map1 is skipped from apply
|
||||||
|
require.Eventually(t, func() bool { return atomic.LoadInt32(&persists) == 2 }, 2*time.Second, 10*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// each map that is actually processed (converged before the next arrives) fires
|
||||||
|
// onConverged exactly once — mirroring the legacy per-message handleSync timing.
|
||||||
|
func TestMapStateManager_SignalsEachProcessedMap(t *testing.T) {
|
||||||
|
converged := make(chan struct{}, 8)
|
||||||
|
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
|
||||||
|
return false, nil // converge in one pass
|
||||||
|
}
|
||||||
|
m := newMapStateManager(apply, nil, func(time.Duration) { converged <- struct{}{} })
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go m.run(ctx)
|
||||||
|
|
||||||
|
const maps = 3
|
||||||
|
for i := 0; i < maps; i++ {
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
|
||||||
|
select { // wait for this map to converge before sending the next (no coalescing)
|
||||||
|
case <-converged:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("map %d not signaled", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// no extra signals once the stream goes quiet
|
||||||
|
select {
|
||||||
|
case <-converged:
|
||||||
|
t.Fatal("unexpected extra onConverged")
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a map superseded before it converges is skipped: only the latest (processed) map
|
||||||
|
// fires onConverged, not the skipped one.
|
||||||
|
func TestMapStateManager_SkippedMapNotSignaled(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
var applies, converged atomic.Int32
|
||||||
|
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
|
||||||
|
applies.Add(1)
|
||||||
|
<-release // hold the first apply in-flight so we can queue a newer target
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m := newMapStateManager(apply, nil, func(time.Duration) { converged.Add(1) })
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go m.run(ctx)
|
||||||
|
|
||||||
|
// map1 is picked up; its apply blocks on release
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
|
||||||
|
require.Eventually(t, func() bool { return applies.Load() >= 1 }, 2*time.Second, 5*time.Millisecond)
|
||||||
|
|
||||||
|
// map2 supersedes map1 before it settled -> map1 is skipped
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
|
||||||
|
close(release) // let both applies proceed
|
||||||
|
|
||||||
|
// only the processed (latest) map signals; the skipped one does not
|
||||||
|
require.Eventually(t, func() bool { return converged.Load() == 1 }, 2*time.Second, 10*time.Millisecond)
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
require.EqualValues(t, 1, converged.Load(), "skipped map must not fire onConverged")
|
||||||
|
require.EqualValues(t, 2, applies.Load(), "both targets entered apply (map1 once, map2 once)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// an apply error drops the target: no retry of the same target, no onConverged,
|
||||||
|
// the loop goes idle — and a fresh target is still applied afterwards.
|
||||||
|
func TestMapStateManager_DropsTargetOnError(t *testing.T) {
|
||||||
|
applied := make(chan struct{}, 8)
|
||||||
|
var failNext atomic.Bool
|
||||||
|
failNext.Store(true)
|
||||||
|
|
||||||
|
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
|
||||||
|
applied <- struct{}{}
|
||||||
|
if failNext.Load() {
|
||||||
|
return false, errors.New("boom")
|
||||||
|
}
|
||||||
|
return false, nil // converge in one pass
|
||||||
|
}
|
||||||
|
var converged atomic.Int32
|
||||||
|
m := newMapStateManager(apply, nil, func(time.Duration) { converged.Add(1) })
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go m.run(ctx)
|
||||||
|
|
||||||
|
// first target errors -> applied once, then dropped (no retry, no onConverged)
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
|
||||||
|
select {
|
||||||
|
case <-applied:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("errored target not applied")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-applied:
|
||||||
|
t.Fatal("errored target must not be retried")
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
require.EqualValues(t, 0, converged.Load(), "onConverged must not fire on error")
|
||||||
|
|
||||||
|
// a new target is still processed normally and converges
|
||||||
|
failNext.Store(false)
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
|
||||||
|
select {
|
||||||
|
case <-applied:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("new target after error not applied")
|
||||||
|
}
|
||||||
|
require.Eventually(t, func() bool { return converged.Load() == 1 }, 2*time.Second, 10*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a new target after convergence triggers a fresh apply; an idle (converged)
|
||||||
|
// manager does not apply on its own.
|
||||||
|
func TestMapStateManager_ReappliesOnNewTarget(t *testing.T) {
|
||||||
|
applied := make(chan struct{}, 8)
|
||||||
|
apply := func(_ *mgmProto.SyncResponse, _ bool) (bool, error) {
|
||||||
|
applied <- struct{}{}
|
||||||
|
return false, nil // converge in one pass
|
||||||
|
}
|
||||||
|
m := newMapStateManager(apply, nil, nil)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go m.run(ctx)
|
||||||
|
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
|
||||||
|
select {
|
||||||
|
case <-applied:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("first target not applied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// converged → must stay idle (no spurious apply)
|
||||||
|
select {
|
||||||
|
case <-applied:
|
||||||
|
t.Fatal("unexpected apply while idle/converged")
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, m.SetTarget(&mgmProto.SyncResponse{}))
|
||||||
|
select {
|
||||||
|
case <-applied:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("new target not applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ type Logger struct {
|
|||||||
wgIfaceNetV6 netip.Prefix
|
wgIfaceNetV6 netip.Prefix
|
||||||
dnsCollection atomic.Bool
|
dnsCollection atomic.Bool
|
||||||
exitNodeCollection atomic.Bool
|
exitNodeCollection atomic.Bool
|
||||||
Store types.AggregatingStore
|
Store types.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix) *Logger {
|
func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix) *Logger {
|
||||||
@@ -35,7 +35,7 @@ func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix)
|
|||||||
statusRecorder: statusRecorder,
|
statusRecorder: statusRecorder,
|
||||||
wgIfaceNet: wgIfaceIPNet,
|
wgIfaceNet: wgIfaceIPNet,
|
||||||
wgIfaceNetV6: wgIfaceIPNetV6,
|
wgIfaceNetV6: wgIfaceIPNetV6,
|
||||||
Store: store.NewAggregatingMemoryStore(),
|
Store: store.NewMemoryStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +125,6 @@ func (l *Logger) stop() {
|
|||||||
l.mux.Unlock()
|
l.mux.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) ResetAggregationWindow() types.FlowEventAggregator {
|
|
||||||
return l.Store.ResetAggregationWindow()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Logger) GetEvents() []*types.Event {
|
func (l *Logger) GetEvents() []*types.Event {
|
||||||
return l.Store.GetEvents()
|
return l.Store.GetEvents()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow/conntrack"
|
"github.com/netbirdio/netbird/client/internal/netflow/conntrack"
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow/logger"
|
"github.com/netbirdio/netbird/client/internal/netflow/logger"
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow/store"
|
|
||||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/flow/client"
|
"github.com/netbirdio/netbird/flow/client"
|
||||||
@@ -25,16 +23,14 @@ import (
|
|||||||
|
|
||||||
// Manager handles netflow tracking and logging
|
// Manager handles netflow tracking and logging
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mux sync.Mutex
|
mux sync.Mutex
|
||||||
shutdownWg sync.WaitGroup
|
shutdownWg sync.WaitGroup
|
||||||
logger nftypes.FlowLogger
|
logger nftypes.FlowLogger
|
||||||
flowConfig *nftypes.FlowConfig
|
flowConfig *nftypes.FlowConfig
|
||||||
conntrack nftypes.ConnTracker
|
conntrack nftypes.ConnTracker
|
||||||
receiverClient *client.GRPCClient
|
receiverClient *client.GRPCClient
|
||||||
eventsWithoutAcks nftypes.Store
|
publicKey []byte
|
||||||
publicKey []byte
|
cancel context.CancelFunc
|
||||||
cancel context.CancelFunc
|
|
||||||
retryInterval time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new netflow manager
|
// NewManager creates a new netflow manager
|
||||||
@@ -52,11 +48,9 @@ func NewManager(iface nftypes.IFaceMapper, publicKey []byte, statusRecorder *pee
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &Manager{
|
return &Manager{
|
||||||
logger: flowLogger,
|
logger: flowLogger,
|
||||||
conntrack: ct,
|
conntrack: ct,
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
retryInterval: time.Second,
|
|
||||||
eventsWithoutAcks: store.NewMemoryStore(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +66,6 @@ func (m *Manager) needsNewClient(previous *nftypes.FlowConfig) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// enableFlow starts components for flow tracking
|
// enableFlow starts components for flow tracking
|
||||||
// must be called under m.mux lock
|
|
||||||
func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error {
|
func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error {
|
||||||
// first make sender ready so events don't pile up
|
// first make sender ready so events don't pile up
|
||||||
if m.needsNewClient(previous) {
|
if m.needsNewClient(previous) {
|
||||||
@@ -92,7 +85,6 @@ func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// must be called under m.mux lock
|
|
||||||
func (m *Manager) resetClient() error {
|
func (m *Manager) resetClient() error {
|
||||||
if m.receiverClient != nil {
|
if m.receiverClient != nil {
|
||||||
if err := m.receiverClient.Close(); err != nil {
|
if err := m.receiverClient.Close(); err != nil {
|
||||||
@@ -115,19 +107,14 @@ func (m *Manager) resetClient() error {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
m.cancel = cancel
|
m.cancel = cancel
|
||||||
|
|
||||||
m.shutdownWg.Add(3)
|
m.shutdownWg.Add(2)
|
||||||
flowConfigInterval := m.flowConfig.Interval
|
|
||||||
go func() {
|
go func() {
|
||||||
defer m.shutdownWg.Done()
|
defer m.shutdownWg.Done()
|
||||||
m.receiveACKs(ctx, flowClient, flowConfigInterval)
|
m.receiveACKs(ctx, flowClient)
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer m.shutdownWg.Done()
|
defer m.shutdownWg.Done()
|
||||||
m.startSender(ctx, flowConfigInterval)
|
m.startSender(ctx)
|
||||||
}()
|
|
||||||
go func() {
|
|
||||||
defer m.shutdownWg.Done()
|
|
||||||
m.startRetries(ctx, flowConfigInterval)
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -211,8 +198,8 @@ func (m *Manager) GetLogger() nftypes.FlowLogger {
|
|||||||
return m.logger
|
return m.logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) startSender(ctx context.Context, flowConfigInterval time.Duration) {
|
func (m *Manager) startSender(ctx context.Context) {
|
||||||
ticker := time.NewTicker(flowConfigInterval)
|
ticker := time.NewTicker(m.flowConfig.Interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -220,29 +207,27 @@ func (m *Manager) startSender(ctx context.Context, flowConfigInterval time.Durat
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
collectedEvents := m.logger.ResetAggregationWindow()
|
events := m.logger.GetEvents()
|
||||||
events := collectedEvents.GetAggregatedEvents()
|
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
m.eventsWithoutAcks.StoreEvent(event)
|
|
||||||
if err := m.send(event); err != nil {
|
if err := m.send(event); err != nil {
|
||||||
log.Errorf("failed to send flow event to server: %v", err)
|
log.Errorf("failed to send flow event to server: %v", err)
|
||||||
} else {
|
continue
|
||||||
log.Tracef("sent flow event: %s", event.ID)
|
|
||||||
}
|
}
|
||||||
|
log.Tracef("sent flow event: %s", event.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient, flowConfigInterval time.Duration) {
|
func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) {
|
||||||
err := client.Receive(ctx, flowConfigInterval, func(ack *proto.FlowEventAck) error {
|
err := client.Receive(ctx, m.flowConfig.Interval, func(ack *proto.FlowEventAck) error {
|
||||||
id, err := uuid.FromBytes(ack.EventId)
|
id, err := uuid.FromBytes(ack.EventId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("failed to convert ack event id to uuid: %v", err)
|
log.Warnf("failed to convert ack event id to uuid: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Tracef("received flow event ack: %s", id)
|
log.Tracef("received flow event ack: %s", id)
|
||||||
m.eventsWithoutAcks.DeleteEvents([]uuid.UUID{id})
|
m.logger.DeleteEvents([]uuid.UUID{id})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -251,51 +236,6 @@ func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient, fl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We effectively never drop events (see MaxInterval), which makes eventsWithoutAcks unbounded.
|
|
||||||
// We may want to limit the max size of the store, and start dropping oldest events when the threshold is reached.
|
|
||||||
func (m *Manager) startRetries(ctx context.Context, flowConfigInterval time.Duration) {
|
|
||||||
timer := time.NewTimer(m.retryInterval)
|
|
||||||
retryBackoff := backoff.WithContext(&backoff.ExponentialBackOff{
|
|
||||||
InitialInterval: 1 * time.Second,
|
|
||||||
RandomizationFactor: 0.5,
|
|
||||||
Multiplier: 1.7,
|
|
||||||
MaxInterval: flowConfigInterval / 2,
|
|
||||||
MaxElapsedTime: 3 * 30 * 24 * time.Hour, // 3 months
|
|
||||||
Stop: backoff.Stop,
|
|
||||||
Clock: backoff.SystemClock,
|
|
||||||
}, ctx)
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-timer.C:
|
|
||||||
resetBackoff := true
|
|
||||||
for _, e := range m.eventsWithoutAcks.GetEvents() {
|
|
||||||
if e.Timestamp.Add(time.Second).After(time.Now()) {
|
|
||||||
// grace period on retries to avoid early retries
|
|
||||||
// do not retry if the event is less than 1 sec old
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := m.send(e); err != nil {
|
|
||||||
if nextBackoff := retryBackoff.NextBackOff(); nextBackoff != backoff.Stop {
|
|
||||||
timer = time.NewTimer(nextBackoff)
|
|
||||||
resetBackoff = false
|
|
||||||
} else {
|
|
||||||
resetBackoff = true // we exhausted retries, reset retry loop
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if resetBackoff { // use regular retry interval in absence of network errors
|
|
||||||
retryBackoff.Reset()
|
|
||||||
timer = time.NewTimer(m.retryInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) send(event *nftypes.Event) error {
|
func (m *Manager) send(event *nftypes.Event) error {
|
||||||
m.mux.Lock()
|
m.mux.Lock()
|
||||||
client := m.receiverClient
|
client := m.receiverClient
|
||||||
@@ -310,11 +250,9 @@ func (m *Manager) send(event *nftypes.Event) error {
|
|||||||
|
|
||||||
func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent {
|
func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent {
|
||||||
protoEvent := &proto.FlowEvent{
|
protoEvent := &proto.FlowEvent{
|
||||||
EventId: event.ID[:],
|
EventId: event.ID[:],
|
||||||
Timestamp: timestamppb.New(event.Timestamp),
|
Timestamp: timestamppb.New(event.Timestamp),
|
||||||
PublicKey: publicKey,
|
PublicKey: publicKey,
|
||||||
WindowStart: timestamppb.New(event.WindowStart),
|
|
||||||
WindowEnd: timestamppb.New(event.WindowEnd),
|
|
||||||
FlowFields: &proto.FlowFields{
|
FlowFields: &proto.FlowFields{
|
||||||
FlowId: event.FlowID[:],
|
FlowId: event.FlowID[:],
|
||||||
RuleId: event.RuleID,
|
RuleId: event.RuleID,
|
||||||
@@ -329,9 +267,6 @@ func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent {
|
|||||||
TxBytes: event.TxBytes,
|
TxBytes: event.TxBytes,
|
||||||
SourceResourceId: event.SourceResourceID,
|
SourceResourceId: event.SourceResourceID,
|
||||||
DestResourceId: event.DestResourceID,
|
DestResourceId: event.DestResourceID,
|
||||||
NumOfStarts: event.NumOfStarts,
|
|
||||||
NumOfEnds: event.NumOfEnds,
|
|
||||||
NumOfDrops: event.NumOfDrops,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
package netflow
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/netip"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow/types"
|
|
||||||
"github.com/netbirdio/netbird/flow/proto"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testServer struct {
|
|
||||||
proto.UnimplementedFlowServiceServer
|
|
||||||
events chan *proto.FlowEvent
|
|
||||||
acks chan *proto.FlowEventAck
|
|
||||||
grpcSrv *grpc.Server
|
|
||||||
addr string
|
|
||||||
handlerDone chan struct{} // signaled each time Events() exits
|
|
||||||
handlerStarted chan struct{} // signaled each time Events() begins
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestServer(t *testing.T) *testServer {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
s := &testServer{
|
|
||||||
events: make(chan *proto.FlowEvent, 100),
|
|
||||||
acks: make(chan *proto.FlowEventAck, 100),
|
|
||||||
grpcSrv: grpc.NewServer(),
|
|
||||||
addr: listener.Addr().String(),
|
|
||||||
handlerDone: make(chan struct{}, 10),
|
|
||||||
handlerStarted: make(chan struct{}, 10),
|
|
||||||
}
|
|
||||||
|
|
||||||
proto.RegisterFlowServiceServer(s.grpcSrv, s)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if err := s.grpcSrv.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
|
||||||
t.Logf("server error: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
s.grpcSrv.Stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *testServer) Events(stream proto.FlowService_EventsServer) error {
|
|
||||||
defer func() {
|
|
||||||
select {
|
|
||||||
case s.handlerDone <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
err := stream.Send(&proto.FlowEventAck{IsInitiator: true})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case s.handlerStarted <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(stream.Context())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer cancel()
|
|
||||||
for {
|
|
||||||
event, err := stream.Recv()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !event.IsInitiator {
|
|
||||||
select {
|
|
||||||
case s.events <- event:
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case ack := <-s.acks:
|
|
||||||
if err := stream.Send(ack); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendEventReceiveAck(t *testing.T) {
|
|
||||||
_, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
t.Cleanup(cancel)
|
|
||||||
|
|
||||||
server := newTestServer(t)
|
|
||||||
manager := createManager(t, server.addr, 60*time.Second) // set high to prevent retries in this test
|
|
||||||
defer manager.Close()
|
|
||||||
|
|
||||||
assert.Eventually(t, func() bool {
|
|
||||||
select {
|
|
||||||
case <-server.handlerStarted:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}, 3*time.Second, 100*time.Millisecond)
|
|
||||||
|
|
||||||
event1 := types.EventFields{
|
|
||||||
FlowID: uuid.New(),
|
|
||||||
Type: types.TypeStart,
|
|
||||||
Direction: types.Ingress,
|
|
||||||
DestIP: ipAddr("172.16.1.2"),
|
|
||||||
DestPort: 2345,
|
|
||||||
Protocol: 6,
|
|
||||||
}
|
|
||||||
manager.logger.StoreEvent(event1)
|
|
||||||
event2 := types.EventFields{
|
|
||||||
FlowID: uuid.New(),
|
|
||||||
Type: types.TypeStart,
|
|
||||||
Direction: types.Ingress,
|
|
||||||
DestIP: ipAddr("172.16.1.1"),
|
|
||||||
DestPort: 1234,
|
|
||||||
Protocol: 6,
|
|
||||||
}
|
|
||||||
manager.logger.StoreEvent(event2)
|
|
||||||
|
|
||||||
// verify the server received logged events
|
|
||||||
serverSideEvents := make([]*proto.FlowEvent, 0)
|
|
||||||
assert.Eventually(t, func() bool {
|
|
||||||
select {
|
|
||||||
case event := <-server.events:
|
|
||||||
serverSideEvents = append(serverSideEvents, event)
|
|
||||||
if len(serverSideEvents) == 2 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if len(serverSideEvents) == 2 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}, 5*time.Second, 100*time.Millisecond)
|
|
||||||
|
|
||||||
serverSideFlowIds := make([]uuid.UUID, 0, 2)
|
|
||||||
slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool {
|
|
||||||
id, err := uuid.FromBytes(e.FlowFields.FlowId)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
serverSideFlowIds = append(serverSideFlowIds, id)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
assert.ElementsMatch(t, []uuid.UUID{event1.FlowID, event2.FlowID}, serverSideFlowIds)
|
|
||||||
|
|
||||||
// verify the manager tracks un-acked events
|
|
||||||
unackedEvents := manager.eventsWithoutAcks.GetEvents()
|
|
||||||
assert.Len(t, unackedEvents, 2)
|
|
||||||
flowIds := make([]uuid.UUID, 0)
|
|
||||||
slices.Values(unackedEvents)(func(e *types.Event) bool {
|
|
||||||
flowIds = append(flowIds, e.FlowID)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
assert.ElementsMatch(t, flowIds, []uuid.UUID{event1.FlowID, event2.FlowID})
|
|
||||||
}
|
|
||||||
|
|
||||||
// verify handling of retries:
|
|
||||||
// - unacked events are retried
|
|
||||||
// - when acks arrive, events are removed from the un-acked event tracker
|
|
||||||
func TestRetryEvents(t *testing.T) {
|
|
||||||
_, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
t.Cleanup(cancel)
|
|
||||||
|
|
||||||
server := newTestServer(t)
|
|
||||||
manager := createManager(t, server.addr, time.Second) // set low to start retries sooner
|
|
||||||
defer manager.Close()
|
|
||||||
|
|
||||||
assert.Eventually(t, func() bool {
|
|
||||||
select {
|
|
||||||
case <-server.handlerStarted:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}, 3*time.Second, 100*time.Millisecond)
|
|
||||||
|
|
||||||
event1 := types.EventFields{
|
|
||||||
FlowID: uuid.New(),
|
|
||||||
Type: types.TypeStart,
|
|
||||||
Direction: types.Ingress,
|
|
||||||
DestIP: ipAddr("172.16.1.2"),
|
|
||||||
DestPort: 2345,
|
|
||||||
Protocol: 6,
|
|
||||||
}
|
|
||||||
manager.logger.StoreEvent(event1)
|
|
||||||
event2 := types.EventFields{
|
|
||||||
FlowID: uuid.New(),
|
|
||||||
Type: types.TypeStart,
|
|
||||||
Direction: types.Ingress,
|
|
||||||
DestIP: ipAddr("172.16.1.1"),
|
|
||||||
DestPort: 1234,
|
|
||||||
Protocol: 6,
|
|
||||||
}
|
|
||||||
manager.logger.StoreEvent(event2)
|
|
||||||
|
|
||||||
// verify the server received retries of logged events
|
|
||||||
serverSideEvents := make([]*proto.FlowEvent, 0)
|
|
||||||
func() {
|
|
||||||
c := time.After(2500 * time.Millisecond)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case event := <-server.events:
|
|
||||||
serverSideEvents = append(serverSideEvents, event)
|
|
||||||
case <-c:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
assert.True(t, len(serverSideEvents) > 2) // must see retries
|
|
||||||
|
|
||||||
uniqueServerSideEvents := make(map[uuid.UUID]*proto.FlowEvent)
|
|
||||||
slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool {
|
|
||||||
id, err := uuid.FromBytes(e.FlowFields.FlowId)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
uniqueServerSideEvents[id] = e
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
assert.Contains(t, uniqueServerSideEvents, event1.FlowID)
|
|
||||||
assert.Contains(t, uniqueServerSideEvents, event2.FlowID)
|
|
||||||
|
|
||||||
// ack events
|
|
||||||
server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event1.FlowID].EventId}
|
|
||||||
server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event2.FlowID].EventId}
|
|
||||||
|
|
||||||
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
||||||
unackedEvents := manager.eventsWithoutAcks.GetEvents()
|
|
||||||
assert.Empty(c, unackedEvents)
|
|
||||||
|
|
||||||
}, 3*time.Second, 100*time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createManager(t *testing.T, serverAddr string, retryInterval time.Duration) *Manager {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
mockIFace := &mockIFaceMapper{
|
|
||||||
address: wgaddr.Address{
|
|
||||||
Network: netip.MustParsePrefix("192.168.1.1/32"),
|
|
||||||
},
|
|
||||||
isUserspaceBind: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
publicKey := []byte("test-public-key")
|
|
||||||
manager := NewManager(mockIFace, publicKey, nil)
|
|
||||||
manager.retryInterval = retryInterval
|
|
||||||
|
|
||||||
initialConfig := &types.FlowConfig{
|
|
||||||
Enabled: true,
|
|
||||||
URL: fmt.Sprintf("http://%s", serverAddr),
|
|
||||||
TokenPayload: "initial-payload",
|
|
||||||
TokenSignature: "initial-signature",
|
|
||||||
Interval: 500 * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := manager.Update(initialConfig)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return manager
|
|
||||||
}
|
|
||||||
|
|
||||||
func ipAddr(a string) netip.Addr {
|
|
||||||
addr, _ := netip.ParseAddr(a)
|
|
||||||
return addr
|
|
||||||
}
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow/types"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
var random = rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
||||||
|
|
||||||
func TestFlowAggregation(t *testing.T) {
|
|
||||||
var protocols = []types.Protocol{types.ICMP, types.ICMPv6, types.TCP, types.UDP}
|
|
||||||
var tests = []struct {
|
|
||||||
description string
|
|
||||||
addresses [][]netip.Addr
|
|
||||||
dstPort uint16
|
|
||||||
eventTypes []types.Type
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
description: "start and stop",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
dstPort: uint16(random.Uint32() >> 16),
|
|
||||||
eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "start and drop",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
dstPort: uint16(random.Uint32() >> 16),
|
|
||||||
eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "start only",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
dstPort: uint16(random.Uint32() >> 16),
|
|
||||||
eventTypes: []types.Type{types.TypeStart},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "drop only",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
dstPort: uint16(random.Uint32() >> 16),
|
|
||||||
eventTypes: []types.Type{types.TypeDrop},
|
|
||||||
}}
|
|
||||||
|
|
||||||
for _, protocol := range protocols {
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.description+" "+protocol.String(), func(t *testing.T) {
|
|
||||||
store := NewAggregatingMemoryStore()
|
|
||||||
store.WindowEnd = time.Now().Add(5 * time.Second)
|
|
||||||
|
|
||||||
allExpected := make([]*types.Event, 0)
|
|
||||||
|
|
||||||
for _, srcAndDst := range tt.addresses {
|
|
||||||
inEvents, expected := generateEvents(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, protocol, types.Ingress, 0, store.WindowStart, store.WindowEnd)
|
|
||||||
for _, e := range inEvents {
|
|
||||||
store.StoreEvent(e)
|
|
||||||
}
|
|
||||||
allExpected = append(allExpected, expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
events := store.GetAggregatedEvents()
|
|
||||||
assert.ElementsMatch(t, events, allExpected)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIcmpEventAggregation(t *testing.T) {
|
|
||||||
var protocols = []types.Protocol{types.ICMP, types.ICMPv6}
|
|
||||||
var icmpTypes = []uint8{1, 2, 3}
|
|
||||||
|
|
||||||
var tests = []struct {
|
|
||||||
description string
|
|
||||||
addresses [][]netip.Addr
|
|
||||||
eventTypes []types.Type
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
description: "start and stop",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "start and drop",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "start only",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
eventTypes: []types.Type{types.TypeStart},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "drop only",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
eventTypes: []types.Type{types.TypeDrop},
|
|
||||||
}}
|
|
||||||
|
|
||||||
for _, protocol := range protocols {
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.description+" "+protocol.String(), func(t *testing.T) {
|
|
||||||
store := NewAggregatingMemoryStore()
|
|
||||||
store.WindowEnd = time.Now().Add(5 * time.Second)
|
|
||||||
|
|
||||||
allExpected := make([]*types.Event, 0)
|
|
||||||
for _, icmpType := range icmpTypes {
|
|
||||||
events, expected := generateEvents(tt.addresses[0][0], tt.addresses[0][1], 0, tt.eventTypes, protocol, types.Ingress, icmpType, store.WindowStart, store.WindowEnd)
|
|
||||||
for _, e := range events {
|
|
||||||
store.StoreEvent(e)
|
|
||||||
}
|
|
||||||
allExpected = append(allExpected, expected)
|
|
||||||
}
|
|
||||||
aggregatedEvents := store.GetAggregatedEvents()
|
|
||||||
assert.Len(t, aggregatedEvents, len(allExpected))
|
|
||||||
assert.ElementsMatch(t, aggregatedEvents, allExpected)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFlowAggregationOfUnknownProtocols(t *testing.T) {
|
|
||||||
var tests = []struct {
|
|
||||||
description string
|
|
||||||
addresses [][]netip.Addr
|
|
||||||
dstPort uint16
|
|
||||||
eventTypes []types.Type
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
description: "start and stop",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
dstPort: uint16(random.Uint32() >> 16),
|
|
||||||
eventTypes: []types.Type{types.TypeStart, types.TypeEnd},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "start and drop",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
dstPort: uint16(random.Uint32() >> 16),
|
|
||||||
eventTypes: []types.Type{types.TypeStart, types.TypeDrop},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "start only",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
dstPort: uint16(random.Uint32() >> 16),
|
|
||||||
eventTypes: []types.Type{types.TypeStart},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "drop only",
|
|
||||||
addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}},
|
|
||||||
dstPort: uint16(random.Uint32() >> 16),
|
|
||||||
eventTypes: []types.Type{types.TypeDrop},
|
|
||||||
}}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.description+" "+types.ProtocolUnknown.String(), func(t *testing.T) {
|
|
||||||
store := NewAggregatingMemoryStore()
|
|
||||||
store.WindowEnd = time.Now().Add(5 * time.Second)
|
|
||||||
|
|
||||||
allExpected := make([]*types.Event, 0)
|
|
||||||
|
|
||||||
for _, srcAndDst := range tt.addresses {
|
|
||||||
inEvents, expected := generateEventsForUnknownProtocol(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, types.ProtocolUnknown, types.Ingress, store.WindowStart, store.WindowEnd)
|
|
||||||
for _, e := range inEvents {
|
|
||||||
store.StoreEvent(e)
|
|
||||||
}
|
|
||||||
allExpected = append(allExpected, expected...)
|
|
||||||
}
|
|
||||||
|
|
||||||
events := store.GetAggregatedEvents()
|
|
||||||
assert.ElementsMatch(t, events, allExpected)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResetAggregationWindow(t *testing.T) {
|
|
||||||
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,15 +1,10 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
|
||||||
"math/rand"
|
|
||||||
v2 "math/rand/v2"
|
|
||||||
"net/netip"
|
|
||||||
"slices"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow/types"
|
"github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,14 +19,6 @@ type Memory struct {
|
|||||||
events map[uuid.UUID]*types.Event
|
events map[uuid.UUID]*types.Event
|
||||||
}
|
}
|
||||||
|
|
||||||
type AggregatingMemory struct {
|
|
||||||
Memory
|
|
||||||
WindowStart time.Time
|
|
||||||
WindowEnd time.Time
|
|
||||||
rnd *v2.PCG
|
|
||||||
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()
|
||||||
@@ -61,104 +48,3 @@ 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,7 +2,6 @@ package types
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -70,10 +69,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
WindowStart time.Time
|
|
||||||
WindowEnd time.Time
|
|
||||||
EventFields
|
EventFields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,17 +92,6 @@ type EventFields struct {
|
|||||||
TxPackets uint64
|
TxPackets uint64
|
||||||
RxBytes uint64
|
RxBytes uint64
|
||||||
TxBytes uint64
|
TxBytes uint64
|
||||||
NumOfStarts uint64
|
|
||||||
NumOfEnds uint64
|
|
||||||
NumOfDrops uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) Clone() *Event {
|
|
||||||
toret := *e
|
|
||||||
toret.RuleID = slices.Clone(e.RuleID)
|
|
||||||
toret.SourceResourceID = slices.Clone(e.SourceResourceID)
|
|
||||||
toret.DestResourceID = slices.Clone(e.DestResourceID)
|
|
||||||
return &toret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlowConfig struct {
|
type FlowConfig struct {
|
||||||
@@ -128,15 +114,13 @@ type FlowManager interface {
|
|||||||
GetLogger() FlowLogger
|
GetLogger() FlowLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlowEventAggregator interface {
|
|
||||||
ResetAggregationWindow() FlowEventAggregator
|
|
||||||
GetAggregatedEvents() []*Event
|
|
||||||
}
|
|
||||||
|
|
||||||
type FlowLogger interface {
|
type FlowLogger interface {
|
||||||
ResetAggregationWindow() FlowEventAggregator
|
|
||||||
// StoreEvent stores a flow event
|
// StoreEvent stores a flow event
|
||||||
StoreEvent(flowEvent EventFields)
|
StoreEvent(flowEvent EventFields)
|
||||||
|
// GetEvents returns all stored events
|
||||||
|
GetEvents() []*Event
|
||||||
|
// DeleteEvents deletes events from the store
|
||||||
|
DeleteEvents([]uuid.UUID)
|
||||||
// Close closes the logger
|
// Close closes the logger
|
||||||
Close()
|
Close()
|
||||||
// Enable enables the flow logger receiver
|
// Enable enables the flow logger receiver
|
||||||
@@ -156,11 +140,6 @@ type Store interface {
|
|||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
type AggregatingStore interface {
|
|
||||||
FlowEventAggregator
|
|
||||||
Store
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConnTracker defines the interface for connection tracking functionality
|
// ConnTracker defines the interface for connection tracking functionality
|
||||||
type ConnTracker interface {
|
type ConnTracker interface {
|
||||||
// Start begins tracking connections by listening for conntrack events.
|
// Start begins tracking connections by listening for conntrack events.
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ 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(
|
||||||
@@ -123,9 +118,6 @@ 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
|
||||||
@@ -203,6 +195,7 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,12 +663,11 @@ func (conn *Conn) onGuardEvent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
|
func (conn *Conn) onWGDisconnected() {
|
||||||
conn.mu.Lock()
|
conn.mu.Lock()
|
||||||
defer conn.mu.Unlock()
|
defer conn.mu.Unlock()
|
||||||
|
|
||||||
// watcherCtx guards against a stale watcher tearing down a connection that already superseded it.
|
if conn.ctx.Err() != nil {
|
||||||
if conn.ctx.Err() != nil || watcherCtx.Err() != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,29 +683,6 @@ func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
|
|||||||
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) {
|
||||||
@@ -833,39 +802,25 @@ 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 != nil {
|
if !conn.wgWatcher.PrepareInitialHandshake() {
|
||||||
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()
|
||||||
onDisconnected := func() { conn.onWGDisconnected(wgWatcherCtx) }
|
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess)
|
||||||
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.wgWatcher == nil {
|
if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil {
|
||||||
return
|
conn.wgWatcherCancel()
|
||||||
|
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) {
|
||||||
@@ -888,9 +843,7 @@ func (conn *Conn) resetEndpoint() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
conn.Log.Infof("reset wg endpoint")
|
conn.Log.Infof("reset wg endpoint")
|
||||||
if conn.wgWatcher != nil {
|
conn.wgWatcher.Reset()
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -939,15 +892,6 @@ 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,7 +7,6 @@ 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"
|
||||||
@@ -305,84 +304,3 @@ 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,11 +85,7 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
|
|||||||
defer g.srWatcher.RemoveListener(srReconnectedChan)
|
defer g.srWatcher.RemoveListener(srReconnectedChan)
|
||||||
|
|
||||||
ticker := g.initialTicker(ctx)
|
ticker := g.initialTicker(ctx)
|
||||||
defer func() {
|
defer ticker.Stop()
|
||||||
// If backoff.Ticker.send is blocked, context.Done will not close the Ticker goroutine.
|
|
||||||
// We have to explicitly call Stop, even if we use backoff.WithContext.
|
|
||||||
ticker.Stop()
|
|
||||||
}()
|
|
||||||
|
|
||||||
tickerChannel := ticker.C
|
tickerChannel := ticker.C
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
package guard
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/peer/ice"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestGuard(status connStatusFunc) *Guard {
|
|
||||||
srw := NewSRWatcher(nil, nil, nil, ice.Config{})
|
|
||||||
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// countBackoffTickerGoroutines returns how many goroutines are currently sitting
|
|
||||||
// in backoff/v4.(*Ticker).run (a ticker goroutine that has not exited).
|
|
||||||
func countBackoffTickerGoroutines() int {
|
|
||||||
buf := make([]byte, 1<<25) // 32MB
|
|
||||||
n := runtime.Stack(buf, true)
|
|
||||||
return strings.Count(string(buf[:n]), "backoff/v4.(*Ticker).run")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown reproduces a observed
|
|
||||||
// leak: after a shutdown burst, ticker run/send goroutines stay parked
|
|
||||||
// forever even though every reconnect loop has exited.
|
|
||||||
func TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown(t *testing.T) {
|
|
||||||
before := countBackoffTickerGoroutines()
|
|
||||||
|
|
||||||
const peers = 6000
|
|
||||||
cancels := make([]context.CancelFunc, 0, peers)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
// A status check slower than the tick cadence. This models the real
|
|
||||||
// isConnectedOnAllWay/callback doing work: while the loop is busy in the
|
|
||||||
// handler, the ticker fires the next tick and parks in send(), because
|
|
||||||
// send() never selects on ctx.
|
|
||||||
slowStatus := func() ConnStatus {
|
|
||||||
time.Sleep(70 * time.Millisecond)
|
|
||||||
return ConnStatusConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
for range peers {
|
|
||||||
g := newTestGuard(slowStatus)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancels = append(cancels, cancel)
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
g.Start(ctx, func() {})
|
|
||||||
}()
|
|
||||||
// Force the live ticker to be a newReconnectTicker.
|
|
||||||
g.SetRelayedConnDisconnected()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let the replacement tickers get past their 800ms initial interval, so
|
|
||||||
// many are parked in send() waiting on the (slow) consumer when we tear
|
|
||||||
// everything down.
|
|
||||||
time.Sleep(1500 * time.Millisecond)
|
|
||||||
|
|
||||||
// Shutdown burst: cancel every peer at once, like engine teardown.
|
|
||||||
for _, c := range cancels {
|
|
||||||
c()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every reconnect loop must return
|
|
||||||
waitCh := make(chan struct{})
|
|
||||||
go func() { wg.Wait(); close(waitCh) }()
|
|
||||||
select {
|
|
||||||
case <-waitCh:
|
|
||||||
case <-time.After(30 * time.Second):
|
|
||||||
t.Fatal("not all reconnect loops returned after ctx cancel")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give any correctly-stopped ticker goroutines time to unwind.
|
|
||||||
for range 50 {
|
|
||||||
runtime.Gosched()
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
leaked := countBackoffTickerGoroutines() - before
|
|
||||||
t.Logf("backoff Ticker.run goroutines still parked after teardown of %d peers: %d", peers, leaked)
|
|
||||||
if leaked > 0 {
|
|
||||||
t.Errorf("LEAK: %d backoff ticker goroutines parked after all reconnect loops exited "+
|
|
||||||
"(defer ticker.Stop() stops the initial ticker, not the live replacement)", leaked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -192,30 +191,23 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
// every private-service request) don't contend against each other.
|
// every private-service request) don't contend against each other.
|
||||||
// Pure read methods take RLock; anything that mutates state takes Lock.
|
// Pure read methods take RLock; anything that mutates state takes Lock.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
mux sync.RWMutex
|
mux sync.RWMutex
|
||||||
muxRelays sync.RWMutex
|
muxRelays sync.RWMutex
|
||||||
peers map[string]State
|
peers map[string]State
|
||||||
ipToKey map[string]string
|
ipToKey map[string]string
|
||||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||||
signalState bool
|
signalState bool
|
||||||
signalError error
|
signalError error
|
||||||
managementState bool
|
managementState bool
|
||||||
managementError error
|
managementError error
|
||||||
relayStates []relay.ProbeResult
|
relayStates []relay.ProbeResult
|
||||||
localPeer LocalPeerState
|
localPeer LocalPeerState
|
||||||
offlinePeers []State
|
offlinePeers []State
|
||||||
mgmAddress string
|
mgmAddress string
|
||||||
signalAddress string
|
signalAddress string
|
||||||
notifier *notifier
|
notifier *notifier
|
||||||
rosenpassEnabled bool
|
rosenpassEnabled bool
|
||||||
rosenpassPermissive bool
|
rosenpassPermissive bool
|
||||||
// sessionExpiresAt is the absolute UTC instant at which the peer's SSO
|
|
||||||
// session expires. Zero when the peer is not SSO-tracked or login
|
|
||||||
// expiration is disabled. Populated from management LoginResponse /
|
|
||||||
// SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC
|
|
||||||
// so the UI can show remaining time without itself talking to mgm.
|
|
||||||
sessionExpiresAt time.Time
|
|
||||||
|
|
||||||
nsGroupStates []NSGroupState
|
nsGroupStates []NSGroupState
|
||||||
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
||||||
lazyConnectionEnabled bool
|
lazyConnectionEnabled bool
|
||||||
@@ -231,21 +223,6 @@ type Status struct {
|
|||||||
eventStreams map[string]chan *proto.SystemEvent
|
eventStreams map[string]chan *proto.SystemEvent
|
||||||
eventQueue *EventQueue
|
eventQueue *EventQueue
|
||||||
|
|
||||||
// stateChangeStreams fan-out connection-state changes (connected /
|
|
||||||
// disconnected / connecting / address change / peers list change) to
|
|
||||||
// every active SubscribeStatus gRPC stream. Each subscriber gets a
|
|
||||||
// buffered chan; the notifier non-blockingly pings them so a slow
|
|
||||||
// consumer can never stall the daemon.
|
|
||||||
stateChangeMux sync.Mutex
|
|
||||||
stateChangeStreams map[string]chan struct{}
|
|
||||||
|
|
||||||
// networksRevision bumps whenever the routed-networks set or their
|
|
||||||
// selected state changes (driven by the route manager). Surfaced in the
|
|
||||||
// status snapshot so the UI can fingerprint on it and re-fetch
|
|
||||||
// ListNetworks only on a real change. Atomic so the snapshot builder can
|
|
||||||
// read it without taking mux.
|
|
||||||
networksRevision atomic.Uint64
|
|
||||||
|
|
||||||
ingressGwMgr *ingressgw.Manager
|
ingressGwMgr *ingressgw.Manager
|
||||||
|
|
||||||
routeIDLookup routeIDLookup
|
routeIDLookup routeIDLookup
|
||||||
@@ -260,7 +237,6 @@ func NewRecorder(mgmAddress string) *Status {
|
|||||||
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
||||||
eventStreams: make(map[string]chan *proto.SystemEvent),
|
eventStreams: make(map[string]chan *proto.SystemEvent),
|
||||||
eventQueue: NewEventQueue(eventQueueSize),
|
eventQueue: NewEventQueue(eventQueueSize),
|
||||||
stateChangeStreams: make(map[string]chan struct{}),
|
|
||||||
offlinePeers: make([]State, 0),
|
offlinePeers: make([]State, 0),
|
||||||
notifier: newNotifier(),
|
notifier: newNotifier(),
|
||||||
mgmAddress: mgmAddress,
|
mgmAddress: mgmAddress,
|
||||||
@@ -425,7 +401,6 @@ func (d *Status) UpdatePeerState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +426,6 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R
|
|||||||
|
|
||||||
// todo: consider to make sense of this notification or not
|
// todo: consider to make sense of this notification or not
|
||||||
d.notifier.peerListChanged(numPeers)
|
d.notifier.peerListChanged(numPeers)
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +451,6 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error {
|
|||||||
|
|
||||||
// todo: consider to make sense of this notification or not
|
// todo: consider to make sense of this notification or not
|
||||||
d.notifier.peerListChanged(numPeers)
|
d.notifier.peerListChanged(numPeers)
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +500,6 @@ func (d *Status) UpdatePeerICEState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,7 +536,6 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +571,6 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,7 +609,6 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +702,6 @@ func (d *Status) FinishPeerListModifications() {
|
|||||||
for _, rd := range dispatches {
|
for _, rd := range dispatches {
|
||||||
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
|
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
|
||||||
}
|
}
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
|
func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
|
||||||
@@ -792,36 +760,6 @@ func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.localAddressChanged(fqdn, ip)
|
d.notifier.localAddressChanged(fqdn, ip)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSessionExpiresAt records the absolute UTC instant at which the peer's
|
|
||||||
// SSO session is set to expire. Pass the zero value to clear (e.g. when the
|
|
||||||
// management server stops publishing a deadline because login expiration was
|
|
||||||
// disabled or the peer is not SSO-tracked). Same-value updates are no-ops;
|
|
||||||
// real changes fan out via notifyStateChange so SubscribeStatus consumers
|
|
||||||
// pick up the new deadline on their next read.
|
|
||||||
func (d *Status) SetSessionExpiresAt(deadline time.Time) {
|
|
||||||
d.mux.Lock()
|
|
||||||
if d.sessionExpiresAt.Equal(deadline) {
|
|
||||||
d.mux.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.sessionExpiresAt = deadline
|
|
||||||
d.mux.Unlock()
|
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
|
|
||||||
// or the zero value when no deadline is tracked. A deadline 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
|
||||||
@@ -890,19 +828,11 @@ func (d *Status) CleanLocalPeerState() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.localAddressChanged(fqdn, ip)
|
d.notifier.localAddressChanged(fqdn, ip)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkManagementDisconnected sets ManagementState to disconnected
|
// MarkManagementDisconnected sets ManagementState to disconnected
|
||||||
func (d *Status) MarkManagementDisconnected(err error) {
|
func (d *Status) MarkManagementDisconnected(err error) {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
// Health checks re-mark the same state on every probe; skip the fan-out
|
|
||||||
// when nothing actually changed so we don't flood SubscribeStatus
|
|
||||||
// consumers with identical snapshots.
|
|
||||||
if !d.managementState && errors.Is(d.managementError, err) {
|
|
||||||
d.mux.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.managementState = false
|
d.managementState = false
|
||||||
d.managementError = err
|
d.managementError = err
|
||||||
mgm := d.managementState
|
mgm := d.managementState
|
||||||
@@ -910,16 +840,11 @@ func (d *Status) MarkManagementDisconnected(err error) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkManagementConnected sets ManagementState to connected
|
// MarkManagementConnected sets ManagementState to connected
|
||||||
func (d *Status) MarkManagementConnected() {
|
func (d *Status) MarkManagementConnected() {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
if d.managementState && d.managementError == nil {
|
|
||||||
d.mux.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.managementState = true
|
d.managementState = true
|
||||||
d.managementError = nil
|
d.managementError = nil
|
||||||
mgm := d.managementState
|
mgm := d.managementState
|
||||||
@@ -927,7 +852,6 @@ func (d *Status) MarkManagementConnected() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSignalAddress update the address of the signal server
|
// UpdateSignalAddress update the address of the signal server
|
||||||
@@ -961,10 +885,6 @@ func (d *Status) UpdateLazyConnection(enabled bool) {
|
|||||||
// MarkSignalDisconnected sets SignalState to disconnected
|
// MarkSignalDisconnected sets SignalState to disconnected
|
||||||
func (d *Status) MarkSignalDisconnected(err error) {
|
func (d *Status) MarkSignalDisconnected(err error) {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
if !d.signalState && errors.Is(d.signalError, err) {
|
|
||||||
d.mux.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.signalState = false
|
d.signalState = false
|
||||||
d.signalError = err
|
d.signalError = err
|
||||||
mgm := d.managementState
|
mgm := d.managementState
|
||||||
@@ -972,16 +892,11 @@ func (d *Status) MarkSignalDisconnected(err error) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkSignalConnected sets SignalState to connected
|
// MarkSignalConnected sets SignalState to connected
|
||||||
func (d *Status) MarkSignalConnected() {
|
func (d *Status) MarkSignalConnected() {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
if d.signalState && d.signalError == nil {
|
|
||||||
d.mux.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.signalState = true
|
d.signalState = true
|
||||||
d.signalError = nil
|
d.signalError = nil
|
||||||
mgm := d.managementState
|
mgm := d.managementState
|
||||||
@@ -989,7 +904,6 @@ func (d *Status) MarkSignalConnected() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
|
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
|
||||||
@@ -1196,19 +1110,16 @@ func (d *Status) GetFullStatus() FullStatus {
|
|||||||
// ClientStart will notify all listeners about the new service state
|
// ClientStart will notify all listeners about the new service state
|
||||||
func (d *Status) ClientStart() {
|
func (d *Status) ClientStart() {
|
||||||
d.notifier.clientStart()
|
d.notifier.clientStart()
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientStop will notify all listeners about the new service state
|
// ClientStop will notify all listeners about the new service state
|
||||||
func (d *Status) ClientStop() {
|
func (d *Status) ClientStop() {
|
||||||
d.notifier.clientStop()
|
d.notifier.clientStop()
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientTeardown will notify all listeners about the service is under teardown
|
// ClientTeardown will notify all listeners about the service is under teardown
|
||||||
func (d *Status) ClientTeardown() {
|
func (d *Status) ClientTeardown() {
|
||||||
d.notifier.clientTearDown()
|
d.notifier.clientTearDown()
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConnectionListener set a listener to the notifier
|
// SetConnectionListener set a listener to the notifier
|
||||||
@@ -1350,79 +1261,6 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent {
|
|||||||
return d.eventQueue.GetAll()
|
return d.eventQueue.GetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribeToStateChanges hands back a channel that receives a tick on
|
|
||||||
// every connection-state change (connected / disconnected / connecting /
|
|
||||||
// address change / peers-list change). The channel is buffered to one
|
|
||||||
// pending tick so a coalesced burst still wakes the consumer exactly
|
|
||||||
// once. Pass the returned id to UnsubscribeFromStateChanges to detach.
|
|
||||||
func (d *Status) SubscribeToStateChanges() (string, <-chan struct{}) {
|
|
||||||
d.stateChangeMux.Lock()
|
|
||||||
defer d.stateChangeMux.Unlock()
|
|
||||||
|
|
||||||
id := uuid.New().String()
|
|
||||||
ch := make(chan struct{}, 1)
|
|
||||||
d.stateChangeStreams[id] = ch
|
|
||||||
return id, ch
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel
|
|
||||||
// and closes it so any consumer goroutine selecting on the channel
|
|
||||||
// unblocks cleanly.
|
|
||||||
func (d *Status) UnsubscribeFromStateChanges(id string) {
|
|
||||||
d.stateChangeMux.Lock()
|
|
||||||
defer d.stateChangeMux.Unlock()
|
|
||||||
|
|
||||||
if ch, ok := d.stateChangeStreams[id]; ok {
|
|
||||||
close(ch)
|
|
||||||
delete(d.stateChangeStreams, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops
|
|
||||||
// the tick if a subscriber's buffer is full — by definition the consumer
|
|
||||||
// is already going to fetch the latest snapshot, so multiple pending ticks
|
|
||||||
// would be redundant.
|
|
||||||
func (d *Status) notifyStateChange() {
|
|
||||||
d.stateChangeMux.Lock()
|
|
||||||
defer d.stateChangeMux.Unlock()
|
|
||||||
|
|
||||||
for _, ch := range d.stateChangeStreams {
|
|
||||||
select {
|
|
||||||
case ch <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotifyStateChange is the public wake-the-subscribers entry point used by
|
|
||||||
// callers that mutate state outside the peer recorder — most importantly
|
|
||||||
// the connect-state machine, which writes StatusNeedsLogin into the
|
|
||||||
// shared contextState (client/internal/state.go) without touching any
|
|
||||||
// recorder field. Without this push the SubscribeStatus stream stays on
|
|
||||||
// the previous snapshot until an unrelated peer/management/signal
|
|
||||||
// change happens to fire notifyStateChange, leaving the UI's status
|
|
||||||
// out of sync with the daemon.
|
|
||||||
func (d *Status) NotifyStateChange() {
|
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
// BumpNetworksRevision increments the routed-networks revision and wakes every
|
|
||||||
// SubscribeStatus subscriber. The route manager calls it when a network map
|
|
||||||
// changes the available routes or when a selection is applied — the peer
|
|
||||||
// status itself only records actively-routed (chosen) networks, so without
|
|
||||||
// this bump a candidate route appearing/disappearing would never reach the UI.
|
|
||||||
func (d *Status) BumpNetworksRevision() {
|
|
||||||
d.networksRevision.Add(1)
|
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNetworksRevision returns the current routed-networks revision, surfaced in
|
|
||||||
// the status snapshot so the UI can detect route/selection changes (see
|
|
||||||
// BumpNetworksRevision).
|
|
||||||
func (d *Status) GetNetworksRevision() uint64 {
|
|
||||||
return d.networksRevision.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.Unlock()
|
||||||
|
|||||||
@@ -314,39 +314,3 @@ func TestGetFullStatus(t *testing.T) {
|
|||||||
assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal")
|
assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal")
|
||||||
assert.ElementsMatch(t, []State{peerState1, peerState2}, fullStatus.Peers, "peers states should match")
|
assert.ElementsMatch(t, []State{peerState1, peerState2}, fullStatus.Peers, "peers states should match")
|
||||||
}
|
}
|
||||||
|
|
||||||
// notified reports whether a state-change tick is pending on ch, draining it.
|
|
||||||
func notified(ch <-chan struct{}) bool {
|
|
||||||
select {
|
|
||||||
case <-ch:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMarkServerStateDoesNotNotifyWhenUnchanged(t *testing.T) {
|
|
||||||
status := NewRecorder("https://mgm")
|
|
||||||
_, ch := status.SubscribeToStateChanges()
|
|
||||||
|
|
||||||
// First transition is a real change and must notify.
|
|
||||||
status.MarkManagementConnected()
|
|
||||||
require.True(t, notified(ch), "first connect should notify")
|
|
||||||
|
|
||||||
// Re-marking the same state must not notify again.
|
|
||||||
status.MarkManagementConnected()
|
|
||||||
assert.False(t, notified(ch), "redundant connect should not notify")
|
|
||||||
|
|
||||||
// Same for signal.
|
|
||||||
status.MarkSignalConnected()
|
|
||||||
require.True(t, notified(ch), "first signal connect should notify")
|
|
||||||
status.MarkSignalConnected()
|
|
||||||
assert.False(t, notified(ch), "redundant signal connect should not notify")
|
|
||||||
|
|
||||||
// A genuine change (disconnect with an error) notifies again.
|
|
||||||
err := errors.New("boom")
|
|
||||||
status.MarkManagementDisconnected(err)
|
|
||||||
require.True(t, notified(ch), "disconnect should notify")
|
|
||||||
status.MarkManagementDisconnected(err)
|
|
||||||
assert.False(t, notified(ch), "redundant disconnect should not notify")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package peer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -23,14 +24,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
|
||||||
|
|
||||||
@@ -47,23 +48,36 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrepareInitialHandshake reads the peer's current WireGuard handshake time. It must be
|
// PrepareInitialHandshake reserves the watcher and reads the peer's current WireGuard
|
||||||
// called before the peer is (re)configured on the WireGuard interface, so the captured
|
// handshake time. It must be called before the peer is (re)configured on the WireGuard
|
||||||
// baseline reflects the state prior to this connection attempt instead of racing with
|
// interface, so the captured baseline reflects the state prior to this connection attempt
|
||||||
// that configuration.
|
// instead of racing with that configuration. Returns ok=false if the watcher is already
|
||||||
func (w *WGWatcher) PrepareInitialHandshake() {
|
// running, in which case EnableWgWatcher must not be called.
|
||||||
|
func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
|
||||||
|
w.muEnabled.Lock()
|
||||||
|
if w.enabled {
|
||||||
|
w.muEnabled.Unlock()
|
||||||
|
return 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. onHandshakeSuccessFn is called only for the first
|
// for context lifecycle management.
|
||||||
// handshake observed by this run, onCheckSuccessFn for every check that observed a fresh
|
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
|
||||||
// handshake, including the first.
|
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake)
|
||||||
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) {
|
|
||||||
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake)
|
w.muEnabled.Lock()
|
||||||
|
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
|
||||||
@@ -76,7 +90,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), onCheckSuccessFn func(), enabledTime time.Time, initialHandshake time.Time) {
|
func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), 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)
|
||||||
@@ -89,7 +103,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -104,10 +117,6 @@ 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))
|
||||||
@@ -138,9 +147,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 known handshake did not change
|
// the current know handshake did not change
|
||||||
if handshake.Equal(lastHandshake) {
|
if handshake.Equal(lastHandshake) {
|
||||||
w.log.Warnf("WireGuard handshake not updated: %v", handshake)
|
w.log.Warnf("WireGuard handshake timed out: %v", handshake)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/configurer"
|
"github.com/netbirdio/netbird/client/iface/configurer"
|
||||||
)
|
)
|
||||||
@@ -23,72 +24,6 @@ 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
|
||||||
@@ -100,7 +35,8 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
watcher.PrepareInitialHandshake()
|
ok := watcher.PrepareInitialHandshake()
|
||||||
|
require.True(t, ok, "watcher should not be enabled yet")
|
||||||
|
|
||||||
onDisconnected := make(chan struct{}, 1)
|
onDisconnected := make(chan struct{}, 1)
|
||||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
||||||
@@ -108,7 +44,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)
|
||||||
@@ -130,13 +66,14 @@ 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())
|
||||||
watcher.PrepareInitialHandshake()
|
ok := watcher.PrepareInitialHandshake()
|
||||||
|
require.True(t, ok, "watcher should not be enabled yet")
|
||||||
|
|
||||||
wg := &sync.WaitGroup{}
|
wg := &sync.WaitGroup{}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}, nil)
|
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {})
|
||||||
}()
|
}()
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
@@ -146,12 +83,13 @@ func TestWGWatcher_ReEnable(t *testing.T) {
|
|||||||
ctx, cancel = context.WithCancel(context.Background())
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
watcher.PrepareInitialHandshake()
|
ok = watcher.PrepareInitialHandshake()
|
||||||
|
require.True(t, ok, "watcher should be re-enabled after the previous run stopped")
|
||||||
|
|
||||||
onDisconnected := make(chan struct{}, 1)
|
onDisconnected := make(chan struct{}, 1)
|
||||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
||||||
onDisconnected <- struct{}{}
|
onDisconnected <- struct{}{}
|
||||||
}, func(when time.Time) {}, nil)
|
}, func(when time.Time) {})
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
mocWgIface.disconnect()
|
mocWgIface.disconnect()
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ type ConfigInput struct {
|
|||||||
BlockLANAccess *bool
|
BlockLANAccess *bool
|
||||||
BlockInbound *bool
|
BlockInbound *bool
|
||||||
DisableIPv6 *bool
|
DisableIPv6 *bool
|
||||||
SyncMessageVersion *int
|
|
||||||
|
|
||||||
DisableNotifications *bool
|
DisableNotifications *bool
|
||||||
|
|
||||||
@@ -138,7 +137,6 @@ type Config struct {
|
|||||||
BlockLANAccess bool
|
BlockLANAccess bool
|
||||||
BlockInbound bool
|
BlockInbound bool
|
||||||
DisableIPv6 bool
|
DisableIPv6 bool
|
||||||
SyncMessageVersion *int
|
|
||||||
|
|
||||||
DisableNotifications *bool
|
DisableNotifications *bool
|
||||||
|
|
||||||
@@ -589,12 +587,6 @@ 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,7 +11,6 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
@@ -440,11 +439,7 @@ func (s *ServiceManager) GetStatePath() string {
|
|||||||
|
|
||||||
activeProf, err := s.GetActiveProfileState()
|
activeProf, err := s.GetActiveProfileState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, syscall.ENOSYS) {
|
log.Warnf("failed to get active profile state: %v", err)
|
||||||
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,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
@@ -72,22 +71,3 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveProfileState deletes the per-profile state file (which holds the
|
|
||||||
// account email used for the SSO login hint and the UI display). Called after
|
|
||||||
// a successful logout so a logged-out profile no longer shows a stale account
|
|
||||||
// email. The state file only stores the email, so deleting it is equivalent to
|
|
||||||
// clearing it; the next SSO login recreates it. A missing file is not an error.
|
|
||||||
func (pm *ProfileManager) RemoveProfileState(profileName string) error {
|
|
||||||
configDir, err := getConfigDir()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get config directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stateFile := filepath.Join(configDir, profileName+".state.json")
|
|
||||||
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("remove profile state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ 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
|
||||||
@@ -52,9 +51,8 @@ type Manager struct {
|
|||||||
wgIface PresharedKeySetter
|
wgIface PresharedKeySetter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new Rosenpass manager. localWgKey is the local
|
// NewManager creates a new Rosenpass manager
|
||||||
// WireGuard public key, used to derive the per-peer rendezvous key.
|
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) {
|
||||||
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
|
||||||
@@ -64,7 +62,6 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtype
|
|||||||
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,
|
||||||
@@ -76,7 +73,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtype
|
|||||||
// 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((*[32]byte)(preSharedKey), localWgKey),
|
rpWgHandler: NewNetbirdHandler(),
|
||||||
lock: sync.Mutex{},
|
lock: sync.Mutex{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -164,7 +161,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.preSharedKey, m.localWgKey)
|
m.rpWgHandler = NewNetbirdHandler()
|
||||||
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(nil, wgtypes.Key{0x01}),
|
rpWgHandler: NewNetbirdHandler(),
|
||||||
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", wgtypes.Key{0x01})
|
m, err := NewManager(&psk, "wt0")
|
||||||
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.applyKey ----------------------------------------------
|
// --- NetbirdHandler.outputKey ----------------------------------------------
|
||||||
|
|
||||||
func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
||||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
h := NewNetbirdHandler()
|
||||||
iface := &mockIface{}
|
iface := &mockIface{}
|
||||||
h.SetInterface(iface)
|
h.SetInterface(iface)
|
||||||
|
|
||||||
@@ -348,8 +348,8 @@ func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
|||||||
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
||||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
h := NewNetbirdHandler()
|
||||||
iface := &mockIface{}
|
iface := &mockIface{}
|
||||||
h.SetInterface(iface)
|
h.SetInterface(iface)
|
||||||
|
|
||||||
@@ -364,8 +364,8 @@ func TestHandler_ApplyKey_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_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
||||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
h := NewNetbirdHandler()
|
||||||
// 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_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
|||||||
h.HandshakeCompleted(pid, rp.Key{})
|
h.HandshakeCompleted(pid, rp.Key{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) {
|
func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
|
||||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
h := NewNetbirdHandler()
|
||||||
iface := &mockIface{}
|
iface := &mockIface{}
|
||||||
h.SetInterface(iface)
|
h.SetInterface(iface)
|
||||||
|
|
||||||
@@ -384,7 +384,7 @@ func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
|
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
|
||||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
h := NewNetbirdHandler()
|
||||||
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(nil, wgtypes.Key{0x01})
|
h := NewNetbirdHandler()
|
||||||
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))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user