mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-18 13:59:55 +00:00
Compare commits
53 Commits
ui-tray-li
...
fix/ipv6-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d8b0310a4 | ||
|
|
ee360963f9 | ||
|
|
8d9580e491 | ||
|
|
5bd7c6c7ea | ||
|
|
8ae2cd0a08 | ||
|
|
e4397d4d46 | ||
|
|
6fbc90b4d3 | ||
|
|
5095e17cc5 | ||
|
|
6df0175607 | ||
|
|
3c23700e56 | ||
|
|
38ad2b67e8 | ||
|
|
01aa49433e | ||
|
|
08a2b63675 | ||
|
|
b3f9e6588a | ||
|
|
967e2d6864 | ||
|
|
e7c1d364c3 | ||
|
|
a44198fd77 | ||
|
|
b57f714350 | ||
|
|
f893abc41d | ||
|
|
60067619a1 | ||
|
|
cd777395f2 | ||
|
|
b19467e3af | ||
|
|
2bcea9d582 | ||
|
|
8ff3b06cf1 | ||
|
|
d7703767d5 | ||
|
|
7feda907ca | ||
|
|
62da482133 | ||
|
|
079bce3c2f | ||
|
|
1a09aa6715 | ||
|
|
61abf5b9ea | ||
|
|
e229050ba3 | ||
|
|
e919b2d55d | ||
|
|
a40028092d | ||
|
|
13200265d8 | ||
|
|
ed7a9363aa | ||
|
|
d56859dc5d | ||
|
|
367d37050b | ||
|
|
106527182f | ||
|
|
8e1d5b78c2 | ||
|
|
d3b63c6be9 | ||
|
|
60d2fa08b0 | ||
|
|
1e7b16db0a | ||
|
|
b377d99933 | ||
|
|
512899d82d | ||
|
|
5993ec6e43 | ||
|
|
eac6d501c3 | ||
|
|
deeae30612 | ||
|
|
f3cdf163e1 | ||
|
|
3e61ccb162 | ||
|
|
a48c20d8d8 | ||
|
|
2b57a7d43b | ||
|
|
fa1e241aea | ||
|
|
e7c9182ff9 |
16
.github/workflows/golang-test-darwin.yml
vendored
16
.github/workflows/golang-test-darwin.yml
vendored
@@ -45,13 +45,11 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Test
|
||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||
# which fails to compile until the frontend has been built. The Wails UI
|
||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||
# before goreleaser.
|
||||
# `go list -e` lets the listing succeed even though the embed fails to
|
||||
# resolve; the grep then drops the broken package by path. Without -e,
|
||||
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||
# root, which has no Go files.
|
||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -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)
|
||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -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)
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,client
|
||||
|
||||
75
.github/workflows/golang-test-linux.yml
vendored
75
.github/workflows/golang-test-linux.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-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
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
${{ runner.os }}-gotest-cache-
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-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
|
||||
if: matrix.arch == '386'
|
||||
@@ -158,15 +158,16 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Test
|
||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||
# which fails to compile until the frontend has been built. The Wails UI
|
||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||
# before goreleaser.
|
||||
# `go list -e` lets the listing succeed even though the embed fails to
|
||||
# resolve; the grep then drops the broken package by path. Without -e,
|
||||
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||
# root, which has no Go files.
|
||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
|
||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,client
|
||||
|
||||
|
||||
test_client_on_docker:
|
||||
name: "Client (Docker) / Unit"
|
||||
@@ -228,7 +229,7 @@ jobs:
|
||||
sh -c ' \
|
||||
apk update; apk add --no-cache \
|
||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
||||
go test -buildvcs=false -tags devcert -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)
|
||||
go test -buildvcs=false -tags devcert -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)
|
||||
'
|
||||
|
||||
test_relay:
|
||||
@@ -284,9 +285,17 @@ jobs:
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test ${{ matrix.raceFlag }} \
|
||||
-exec 'sudo' \
|
||||
-exec 'sudo' -coverprofile=coverage.txt \
|
||||
-timeout 10m -p 1 ./relay/... ./shared/relay/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,relay
|
||||
|
||||
test_proxy:
|
||||
name: "Proxy / Unit"
|
||||
needs: [build-cache]
|
||||
@@ -334,7 +343,15 @@ jobs:
|
||||
- name: Test
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test -timeout 10m -p 1 ./proxy/...
|
||||
go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,proxy
|
||||
|
||||
test_signal:
|
||||
name: "Signal / Unit"
|
||||
@@ -385,9 +402,17 @@ jobs:
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test \
|
||||
-exec 'sudo' \
|
||||
-exec 'sudo' -coverprofile=coverage.txt \
|
||||
-timeout 10m ./signal/... ./shared/signal/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,signal
|
||||
|
||||
test_management:
|
||||
name: "Management / Unit"
|
||||
needs: [build-cache]
|
||||
@@ -453,10 +478,18 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=devcert \
|
||||
go test -tags=devcert -coverprofile=coverage.txt \
|
||||
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
|
||||
-timeout 20m ./management/... ./shared/management/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,management
|
||||
|
||||
benchmark:
|
||||
name: "Management / Benchmark"
|
||||
needs: [build-cache]
|
||||
@@ -695,6 +728,14 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=integration \
|
||||
go test -tags=integration -coverprofile=coverage.txt \
|
||||
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
|
||||
-timeout 20m ./management/server/http/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: integration,management
|
||||
|
||||
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 mod tidy
|
||||
- name: Generate test script
|
||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||
# which fails to compile until the frontend has been built. The Wails UI
|
||||
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||
# before goreleaser.
|
||||
# `go list -e` lets the listing succeed even though the embed fails to
|
||||
# resolve; the Where-Object pipeline then drops the broken package by
|
||||
# path. Without -e, go list aborts with empty stdout.
|
||||
run: |
|
||||
$packages = go list -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"
|
||||
$cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
||||
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
||||
|
||||
17
.github/workflows/golangci-lint.yml
vendored
17
.github/workflows/golangci-lint.yml
vendored
@@ -22,11 +22,7 @@ jobs:
|
||||
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||
with:
|
||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
||||
# Non-English UI translations trip codespell on real foreign words
|
||||
# (de: "Sie", "oder", "ist"). Only en/common.json is the source of
|
||||
# truth that should be spell-checked. Add each new locale dir here
|
||||
# when a language is added under client/ui/i18n/locales/.
|
||||
skip: go.mod,go.sum,**/proxy/web/**,**/pnpm-lock.yaml,**/package-lock.json,client/ui/i18n/locales/de/**,client/ui/i18n/locales/hu/**
|
||||
skip: go.mod,go.sum,**/proxy/web/**
|
||||
golangci:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -58,16 +54,7 @@ jobs:
|
||||
cache: false
|
||||
- name: Install dependencies
|
||||
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 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
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||
with:
|
||||
|
||||
39
.github/workflows/proto-version-check.yml
vendored
39
.github/workflows/proto-version-check.yml
vendored
@@ -20,15 +20,30 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const modifiedPbFiles = files.filter(
|
||||
f => f.filename.endsWith('.pb.go') && f.status === 'modified'
|
||||
);
|
||||
if (modifiedPbFiles.length === 0) {
|
||||
console.log('No modified .pb.go files to check');
|
||||
// Cover renamed .pb.go files in addition to plain edits.
|
||||
// Renamed entries land under the new path with previous_filename
|
||||
// pointing at the base-side name, so we read the base content
|
||||
// from the old path when present.
|
||||
const changedPbFiles = files
|
||||
.filter(f => (f.status === 'modified' || f.status === 'renamed')
|
||||
&& f.filename.endsWith('.pb.go'))
|
||||
.map(f => ({
|
||||
headPath: f.filename,
|
||||
basePath: f.previous_filename || f.filename,
|
||||
}));
|
||||
if (changedPbFiles.length === 0) {
|
||||
console.log('No modified or renamed .pb.go files to check');
|
||||
return;
|
||||
}
|
||||
|
||||
const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
||||
// Matches the generator version headers protoc writes at the top
|
||||
// of generated files:
|
||||
// // protoc v3.21.12
|
||||
// // protoc-gen-go v1.26.0
|
||||
// // - protoc-gen-go-grpc v1.6.1 (grpc files prefix with "- ")
|
||||
// The optional "- " prefix and the optional -gen-go / -gen-go-grpc
|
||||
// suffixes keep the *_grpc.pb.go headers in scope.
|
||||
const versionPattern = /^\s*\/\/\s+(?:-\s+)?protoc(?:-gen-go(?:-grpc)?)?\s+v[\d.]+/;
|
||||
const baseSha = context.payload.pull_request.base.sha;
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
|
||||
@@ -55,20 +70,22 @@ jobs:
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
for (const file of modifiedPbFiles) {
|
||||
for (const file of changedPbFiles) {
|
||||
const [base, head] = await Promise.all([
|
||||
getVersionHeader(file.filename, baseSha),
|
||||
getVersionHeader(file.filename, headSha),
|
||||
getVersionHeader(file.basePath, baseSha),
|
||||
getVersionHeader(file.headPath, headSha),
|
||||
]);
|
||||
if (!base.ok || !head.ok) {
|
||||
core.warning(
|
||||
`Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
||||
`Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (base.lines.join('\n') !== head.lines.join('\n')) {
|
||||
violations.push({
|
||||
file: file.filename,
|
||||
file: file.basePath === file.headPath
|
||||
? file.headPath
|
||||
: `${file.basePath} → ${file.headPath}`,
|
||||
base: base.lines,
|
||||
head: head.lines,
|
||||
});
|
||||
|
||||
138
.github/workflows/release.yml
vendored
138
.github/workflows/release.yml
vendored
@@ -9,10 +9,13 @@ on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
SIGN_PIPE_VER: "v0.1.5"
|
||||
GORELEASER_VER: "v2.14.3"
|
||||
SIGN_PIPE_VER: "v0.1.6"
|
||||
GORELEASER_VER: "v2.16.0"
|
||||
PRODUCT_NAME: "NetBird"
|
||||
COPYRIGHT: "NetBird GmbH"
|
||||
flags: ""
|
||||
SKIP_PUBLISH: "true"
|
||||
SKIP_DOCKER_PUSH: "false"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
@@ -29,10 +32,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate FreeBSD port diff
|
||||
run: bash release_files/freebsd-port-diff.sh
|
||||
run: bash -x release_files/freebsd-port-diff.sh
|
||||
|
||||
- name: Generate FreeBSD port issue body
|
||||
run: bash release_files/freebsd-port-issue-body.sh
|
||||
run: bash -x release_files/freebsd-port-issue-body.sh
|
||||
|
||||
- name: Check if diff was generated
|
||||
id: check_diff
|
||||
@@ -130,8 +133,6 @@ jobs:
|
||||
windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }}
|
||||
macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }}
|
||||
ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }}
|
||||
env:
|
||||
flags: ""
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -143,8 +144,27 @@ jobs:
|
||||
id: semver_parser
|
||||
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
|
||||
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
- name: Set snapshot flag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
|
||||
- name: Set build vars
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
fi
|
||||
|
||||
if [[ "x-${{ github.repository }}" != "x-netbirdio/netbird" ]]; then
|
||||
echo "SKIP_DOCKER_PUSH=true" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
with:
|
||||
@@ -161,6 +181,8 @@ jobs:
|
||||
${{ runner.os }}-go-releaser-
|
||||
- name: Install modules
|
||||
run: go mod tidy
|
||||
- name: run openapi generator
|
||||
run: bash shared/management/http/api/generate.sh
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
- name: Set up QEMU
|
||||
@@ -194,9 +216,9 @@ jobs:
|
||||
- name: Install goversioninfo
|
||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||
- name: Generate windows syso amd64
|
||||
run: goversioninfo -icon client/ui/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
|
||||
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
|
||||
id: goreleaser
|
||||
uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||
@@ -210,6 +232,8 @@ jobs:
|
||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||
SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }}
|
||||
- name: Verify RPM signatures
|
||||
run: |
|
||||
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||
@@ -332,8 +356,22 @@ jobs:
|
||||
id: semver_parser
|
||||
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
|
||||
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
- name: Set snapshot flag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
|
||||
- name: Set build vars
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
@@ -356,18 +394,8 @@ jobs:
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-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
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
@@ -386,16 +414,10 @@ jobs:
|
||||
echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
|
||||
- name: Install goversioninfo
|
||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||
- name: Install wails3 CLI
|
||||
# Version derived from go.mod so the binding generator always matches
|
||||
# the wails runtime the binary links against.
|
||||
run: |
|
||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||
- name: Generate windows syso amd64
|
||||
run: goversioninfo -64 -icon client/ui/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
|
||||
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
|
||||
uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||
@@ -409,6 +431,7 @@ jobs:
|
||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||
NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||
- name: Verify RPM signatures
|
||||
run: |
|
||||
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||
@@ -463,20 +486,6 @@ jobs:
|
||||
run: go mod tidy
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 11
|
||||
- name: Install wails3 CLI
|
||||
# Version derived from go.mod so the binding generator always matches
|
||||
# the wails runtime the binary links against.
|
||||
run: |
|
||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||
- name: Run GoReleaser
|
||||
id: goreleaser
|
||||
uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||
@@ -564,6 +573,23 @@ jobs:
|
||||
- name: Move wintun.dll into dist
|
||||
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||
|
||||
- name: Download Mesa3D (amd64 only)
|
||||
id: download-mesa3d
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
with:
|
||||
url: https://pkgs.netbird.io/mesa3d/MesaForWindows-x64-20.1.8.7z
|
||||
destination: ${{ env.downloadPath }}\mesa3d.7z
|
||||
sha256: 71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9
|
||||
|
||||
- name: Extract Mesa3D driver (amd64 only)
|
||||
if: matrix.arch == 'amd64'
|
||||
run: 7z x -o"${{ env.downloadPath }}" "${{ env.downloadPath }}/mesa3d.7z"
|
||||
|
||||
- name: Move opengl32.dll into dist (amd64 only)
|
||||
if: matrix.arch == 'amd64'
|
||||
run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||
|
||||
- name: Download EnVar plugin for NSIS
|
||||
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
with:
|
||||
@@ -586,28 +612,6 @@ jobs:
|
||||
if: matrix.arch == 'amd64'
|
||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
||||
|
||||
- name: Set up Go for wails3 CLI
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
|
||||
- name: Install wails3 CLI
|
||||
# Version derived from go.mod so the bootstrapper payload always
|
||||
# matches the wails runtime the binary links against.
|
||||
shell: bash
|
||||
run: |
|
||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||
|
||||
- name: Stage WebView2 bootstrapper for installers
|
||||
# Both client/installer.nsis and client/netbird.wxs reference
|
||||
# client/MicrosoftEdgeWebview2Setup.exe. wails3 writes it there.
|
||||
# The signing pipeline (netbirdio/sign-pipelines) does the same
|
||||
# step for release builds; this mirrors it for PR sanity testing.
|
||||
shell: bash
|
||||
run: wails3 generate webview2bootstrapper -dir client
|
||||
|
||||
- name: Build NSIS installer
|
||||
shell: pwsh
|
||||
env:
|
||||
|
||||
6
.github/workflows/wasm-build-validation.yml
vendored
6
.github/workflows/wasm-build-validation.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-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
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||
with:
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
|
||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||
|
||||
if [ ${SIZE} -gt 58720256 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
||||
if [ ${SIZE} -gt 62914560 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -114,16 +114,6 @@ linters:
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: "QF1012"
|
||||
# client/ui/main.go uses //go:embed all:frontend/dist; the
|
||||
# directory is populated by `pnpm build` in the release pipeline
|
||||
# and missing at lint time, so the embed parses to "no matching
|
||||
# files found" — surfaced by golangci-lint's typecheck pre-pass.
|
||||
# Suppress just that one diagnostic; the rest of the package
|
||||
# (services/, tray.go, grpc.go, ...) still gets linted normally.
|
||||
- linters:
|
||||
- typecheck
|
||||
path: client/ui/main\.go
|
||||
text: "pattern all:frontend/dist"
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
|
||||
862
.goreleaser.yaml
862
.goreleaser.yaml
@@ -1,5 +1,7 @@
|
||||
version: 2
|
||||
|
||||
env:
|
||||
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
||||
- SKIP_DOCKER_PUSH={{ if index .Env "SKIP_DOCKER_PUSH" }}{{ .Env.SKIP_DOCKER_PUSH }}{{ else }}false{{ end }}
|
||||
project_name: netbird
|
||||
builds:
|
||||
- id: netbird-wasm
|
||||
@@ -74,6 +76,8 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
@@ -88,6 +92,8 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
@@ -102,6 +108,8 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
@@ -122,6 +130,8 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
@@ -136,6 +146,8 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
@@ -150,6 +162,8 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
@@ -170,6 +184,8 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
@@ -222,670 +238,192 @@ nfpms:
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
dockers:
|
||||
- image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-amd64
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
|
||||
ids:
|
||||
- netbird
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: client/Dockerfile
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
|
||||
ids:
|
||||
- netbird
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: client/Dockerfile
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm
|
||||
ids:
|
||||
- netbird
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
use: buildx
|
||||
dockerfile: client/Dockerfile
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
|
||||
- image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-rootless-amd64
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
|
||||
ids:
|
||||
- netbird
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: client/Dockerfile-rootless
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
||||
ids:
|
||||
- netbird
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: client/Dockerfile-rootless
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-rootless-arm
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
|
||||
ids:
|
||||
- netbird
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
use: buildx
|
||||
dockerfile: client/Dockerfile-rootless
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
|
||||
- image_templates:
|
||||
- netbirdio/relay:{{ .Version }}-amd64
|
||||
- ghcr.io/netbirdio/relay:{{ .Version }}-amd64
|
||||
ids:
|
||||
- netbird-relay
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: relay/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/relay:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
|
||||
ids:
|
||||
- netbird-relay
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: relay/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/relay:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm
|
||||
ids:
|
||||
- netbird-relay
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
use: buildx
|
||||
dockerfile: relay/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/signal:{{ .Version }}-amd64
|
||||
- ghcr.io/netbirdio/signal:{{ .Version }}-amd64
|
||||
ids:
|
||||
- netbird-signal
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: signal/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/signal:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
|
||||
ids:
|
||||
- netbird-signal
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: signal/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/signal:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm
|
||||
ids:
|
||||
- netbird-signal
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
use: buildx
|
||||
dockerfile: signal/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/management:{{ .Version }}-amd64
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-amd64
|
||||
ids:
|
||||
- netbird-mgmt
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: management/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/management:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
|
||||
ids:
|
||||
- netbird-mgmt
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: management/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/management:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm
|
||||
ids:
|
||||
- netbird-mgmt
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
use: buildx
|
||||
dockerfile: management/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/management:{{ .Version }}-debug-amd64
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64
|
||||
ids:
|
||||
- netbird-mgmt
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: management/Dockerfile.debug
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/management:{{ .Version }}-debug-arm64v8
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8
|
||||
ids:
|
||||
- netbird-mgmt
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: management/Dockerfile.debug
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
|
||||
- image_templates:
|
||||
- netbirdio/management:{{ .Version }}-debug-arm
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-arm
|
||||
ids:
|
||||
- netbird-mgmt
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
use: buildx
|
||||
dockerfile: management/Dockerfile.debug
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/upload:{{ .Version }}-amd64
|
||||
- ghcr.io/netbirdio/upload:{{ .Version }}-amd64
|
||||
ids:
|
||||
- netbird-upload
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: upload-server/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/upload:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
|
||||
ids:
|
||||
- netbird-upload
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: upload-server/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/upload:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm
|
||||
ids:
|
||||
- netbird-upload
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
use: buildx
|
||||
dockerfile: upload-server/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/netbird-server:{{ .Version }}-amd64
|
||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
|
||||
ids:
|
||||
- netbird-server
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: combined/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||
ids:
|
||||
- netbird-server
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: combined/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/netbird-server:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
|
||||
ids:
|
||||
- netbird-server
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
use: buildx
|
||||
dockerfile: combined/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||
ids:
|
||||
- netbird-proxy
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: proxy/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||
ids:
|
||||
- netbird-proxy
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: proxy/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
- image_templates:
|
||||
- netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||
ids:
|
||||
- netbird-proxy
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
use: buildx
|
||||
dockerfile: proxy/Dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
||||
- "--label=maintainer=dev@netbird.io"
|
||||
docker_manifests:
|
||||
- name_template: netbirdio/netbird:{{ .Version }}
|
||||
image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-arm64v8
|
||||
- netbirdio/netbird:{{ .Version }}-arm
|
||||
- netbirdio/netbird:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/netbird:latest
|
||||
image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-arm64v8
|
||||
- netbirdio/netbird:{{ .Version }}-arm
|
||||
- netbirdio/netbird:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/netbird:{{ .Version }}-rootless
|
||||
image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
||||
- netbirdio/netbird:{{ .Version }}-rootless-arm
|
||||
- netbirdio/netbird:{{ .Version }}-rootless-amd64
|
||||
|
||||
- name_template: netbirdio/netbird:rootless-latest
|
||||
image_templates:
|
||||
- netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
||||
- netbirdio/netbird:{{ .Version }}-rootless-arm
|
||||
- netbirdio/netbird:{{ .Version }}-rootless-amd64
|
||||
|
||||
- name_template: netbirdio/relay:{{ .Version }}
|
||||
image_templates:
|
||||
- netbirdio/relay:{{ .Version }}-arm64v8
|
||||
- netbirdio/relay:{{ .Version }}-arm
|
||||
- netbirdio/relay:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/relay:latest
|
||||
image_templates:
|
||||
- netbirdio/relay:{{ .Version }}-arm64v8
|
||||
- netbirdio/relay:{{ .Version }}-arm
|
||||
- netbirdio/relay:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/signal:{{ .Version }}
|
||||
image_templates:
|
||||
- netbirdio/signal:{{ .Version }}-arm64v8
|
||||
- netbirdio/signal:{{ .Version }}-arm
|
||||
- netbirdio/signal:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/signal:latest
|
||||
image_templates:
|
||||
- netbirdio/signal:{{ .Version }}-arm64v8
|
||||
- netbirdio/signal:{{ .Version }}-arm
|
||||
- netbirdio/signal:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/management:{{ .Version }}
|
||||
image_templates:
|
||||
- netbirdio/management:{{ .Version }}-arm64v8
|
||||
- netbirdio/management:{{ .Version }}-arm
|
||||
- netbirdio/management:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/management:latest
|
||||
image_templates:
|
||||
- netbirdio/management:{{ .Version }}-arm64v8
|
||||
- netbirdio/management:{{ .Version }}-arm
|
||||
- netbirdio/management:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/management:debug-latest
|
||||
image_templates:
|
||||
- netbirdio/management:{{ .Version }}-debug-arm64v8
|
||||
- netbirdio/management:{{ .Version }}-debug-arm
|
||||
- netbirdio/management:{{ .Version }}-debug-amd64
|
||||
- name_template: netbirdio/upload:{{ .Version }}
|
||||
image_templates:
|
||||
- netbirdio/upload:{{ .Version }}-arm64v8
|
||||
- netbirdio/upload:{{ .Version }}-arm
|
||||
- netbirdio/upload:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/upload:latest
|
||||
image_templates:
|
||||
- netbirdio/upload:{{ .Version }}-arm64v8
|
||||
- netbirdio/upload:{{ .Version }}-arm
|
||||
- netbirdio/upload:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/netbird-server:{{ .Version }}
|
||||
image_templates:
|
||||
- netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||
- netbirdio/netbird-server:{{ .Version }}-arm
|
||||
- netbirdio/netbird-server:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/netbird-server:latest
|
||||
image_templates:
|
||||
- netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||
- netbirdio/netbird-server:{{ .Version }}-arm
|
||||
- netbirdio/netbird-server:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/netbird:{{ .Version }}
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/netbird:latest
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/netbird:{{ .Version }}-rootless
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/netbird:rootless-latest
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
|
||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/relay:{{ .Version }}
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/relay:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/relay:latest
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/relay:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/signal:{{ .Version }}
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/signal:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/signal:latest
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/signal:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/management:{{ .Version }}
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/management:latest
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/management:debug-latest
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-arm
|
||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/upload:{{ .Version }}
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/upload:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/upload:latest
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/upload:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }}
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/netbird-server:latest
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/reverse-proxy:{{ .Version }}
|
||||
image_templates:
|
||||
- netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||
- netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||
- netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||
|
||||
- name_template: netbirdio/reverse-proxy:latest
|
||||
image_templates:
|
||||
- netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||
- netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||
- netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/reverse-proxy:{{ .Version }}
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||
|
||||
- name_template: ghcr.io/netbirdio/reverse-proxy:latest
|
||||
image_templates:
|
||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
|
||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
|
||||
dockers_v2:
|
||||
- id: netbird
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird
|
||||
images:
|
||||
- netbirdio/netbird
|
||||
- ghcr.io/netbirdio/netbird
|
||||
tags:
|
||||
- "v{{ .Version }}"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: client/Dockerfile
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm/6
|
||||
annotations:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: netbird-rootless
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird
|
||||
images:
|
||||
- netbirdio/netbird
|
||||
- ghcr.io/netbirdio/netbird
|
||||
tags:
|
||||
- "v{{ .Version }}-rootless"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: client/Dockerfile-rootless
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm/6
|
||||
annotations:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: relay
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird-relay
|
||||
images:
|
||||
- netbirdio/relay
|
||||
- ghcr.io/netbirdio/relay
|
||||
tags:
|
||||
- "v{{ .Version }}"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: relay/Dockerfile
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm
|
||||
annotations:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: signal
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird-signal
|
||||
images:
|
||||
- netbirdio/signal
|
||||
- ghcr.io/netbirdio/signal
|
||||
tags:
|
||||
- "v{{ .Version }}"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: signal/Dockerfile
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm
|
||||
annotations:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: management
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird-mgmt
|
||||
images:
|
||||
- netbirdio/management
|
||||
- ghcr.io/netbirdio/management
|
||||
tags:
|
||||
- "v{{ .Version }}"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: management/Dockerfile
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm
|
||||
annotations:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: upload
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird-upload
|
||||
images:
|
||||
- netbirdio/upload
|
||||
- ghcr.io/netbirdio/upload
|
||||
tags:
|
||||
- "v{{ .Version }}"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: upload-server/Dockerfile
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm
|
||||
annotations:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: netbird-server
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird-server
|
||||
images:
|
||||
- netbirdio/netbird-server
|
||||
- ghcr.io/netbirdio/netbird-server
|
||||
tags:
|
||||
- "v{{ .Version }}"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: combined/Dockerfile
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm
|
||||
annotations:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: netbird-proxy
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird-proxy
|
||||
images:
|
||||
- netbirdio/reverse-proxy
|
||||
- ghcr.io/netbirdio/reverse-proxy
|
||||
tags:
|
||||
- "v{{ .Version }}"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: proxy/Dockerfile
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm
|
||||
annotations:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
|
||||
brews:
|
||||
- ids:
|
||||
- default
|
||||
skip_upload: "{{ .Env.SKIP_PUBLISH }}"
|
||||
repository:
|
||||
owner: netbirdio
|
||||
name: homebrew-tap
|
||||
@@ -902,6 +440,7 @@ brews:
|
||||
|
||||
uploads:
|
||||
- name: debian
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_deb
|
||||
mode: archive
|
||||
@@ -910,6 +449,7 @@ uploads:
|
||||
method: PUT
|
||||
|
||||
- name: yum
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_rpm
|
||||
mode: archive
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
version: 2
|
||||
|
||||
env:
|
||||
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
||||
project_name: netbird-ui
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# Bindings are gitignored; regenerate before the frontend build so
|
||||
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
||||
# build without them).
|
||||
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts'
|
||||
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||
|
||||
builds:
|
||||
- id: netbird-ui
|
||||
dir: client/ui
|
||||
@@ -79,15 +71,12 @@ nfpms:
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
- src: client/ui/build/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
|
||||
dependencies:
|
||||
- netbird
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
- libayatana-appindicator3-1
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
@@ -101,21 +90,19 @@ nfpms:
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
- src: client/ui/build/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
|
||||
dependencies:
|
||||
- netbird
|
||||
- gtk3
|
||||
- webkit2gtk4.1
|
||||
- libayatana-appindicator-gtk3
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
uploads:
|
||||
- name: debian
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_ui_deb
|
||||
mode: archive
|
||||
@@ -124,6 +111,7 @@ uploads:
|
||||
method: PUT
|
||||
|
||||
- name: yum
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_ui_rpm
|
||||
mode: archive
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
version: 2
|
||||
|
||||
project_name: netbird-ui
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# Bindings are gitignored; regenerate before the frontend build so
|
||||
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
||||
# build without them).
|
||||
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts'
|
||||
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||
|
||||
builds:
|
||||
- id: netbird-ui-darwin
|
||||
dir: client/ui
|
||||
@@ -29,6 +20,8 @@ builds:
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- load_wgnt_from_rsrc
|
||||
|
||||
universal_binaries:
|
||||
- id: netbird-ui-darwin
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
|
||||
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
|
||||
|
||||
FROM alpine:3.23.3
|
||||
FROM alpine:3.24
|
||||
# iproute2: busybox doesn't display ip rules properly
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
@@ -21,7 +21,7 @@ ENV \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||
|
||||
ARG NETBIRD_BINARY=netbird
|
||||
ARG TARGETPLATFORM
|
||||
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
|
||||
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
||||
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
|
||||
# podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
|
||||
|
||||
FROM alpine:3.22.0
|
||||
FROM alpine:3.24
|
||||
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
@@ -27,7 +27,7 @@ ENV \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||
|
||||
ARG NETBIRD_BINARY=netbird
|
||||
ARG TARGETPLATFORM
|
||||
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
|
||||
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
||||
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -24,6 +23,7 @@ const (
|
||||
|
||||
// Profile represents a profile for gomobile
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
IsActive bool
|
||||
}
|
||||
@@ -53,10 +53,10 @@ func (p *ProfileArray) Get(i int) *Profile {
|
||||
├── state.json ← Default profile state
|
||||
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
|
||||
└── profiles/ ← Subdirectory for non-default profiles
|
||||
├── work.json ← Work profile config
|
||||
├── work.state.json ← Work profile state
|
||||
├── personal.json ← Personal profile config
|
||||
└── personal.state.json ← Personal profile state
|
||||
├── work.json ← Legacy work profile config
|
||||
├── work.state.json ← Legacy work profile state
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
|
||||
*/
|
||||
|
||||
// ProfileManager manages profiles for Android
|
||||
@@ -99,6 +99,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
var profiles []*Profile
|
||||
for _, p := range internalProfiles {
|
||||
profiles = append(profiles, &Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
@@ -108,55 +109,65 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
}
|
||||
|
||||
// GetActiveProfile returns the currently active profile name
|
||||
func (pm *ProfileManager) GetActiveProfile() (string, error) {
|
||||
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
// Use ServiceManager to stay consistent with ListProfiles
|
||||
// ServiceManager uses active_profile.json
|
||||
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||
return nil, fmt.Errorf("failed to get active profile: %w", err)
|
||||
}
|
||||
return activeState.Name, nil
|
||||
|
||||
// ActiveProfileState only stores the ID (and username), not the display
|
||||
// name. Resolve the ID to the full profile so callers get the real Name.
|
||||
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||
}
|
||||
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
|
||||
}
|
||||
|
||||
// SwitchProfile switches to a different profile
|
||||
func (pm *ProfileManager) SwitchProfile(profileName string) error {
|
||||
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
// Use ServiceManager to stay consistent with ListProfiles
|
||||
// ServiceManager uses active_profile.json
|
||||
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
Name: profileName,
|
||||
ID: profilemanager.ID(id),
|
||||
Username: androidUsername,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to switch profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("switched to profile: %s", profileName)
|
||||
log.Infof("switched to profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddProfile creates a new profile
|
||||
func (pm *ProfileManager) AddProfile(profileName string) error {
|
||||
// Use ServiceManager (creates profile in profiles/ directory)
|
||||
if err := pm.serviceMgr.AddProfile(profileName, androidUsername); err != nil {
|
||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("created new profile: %s", profileName)
|
||||
log.Infof("created new profile: %s", profile.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogoutProfile logs out from a profile (clears authentication)
|
||||
func (pm *ProfileManager) LogoutProfile(profileName string) error {
|
||||
profileName = sanitizeProfileName(profileName)
|
||||
|
||||
configPath, err := pm.getProfileConfigPath(profileName)
|
||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return fmt.Errorf("id '%s' is not valid", id)
|
||||
}
|
||||
|
||||
// Check if profile exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("profile '%s' does not exist", profileName)
|
||||
return fmt.Errorf("profile '%s' does not exist", id)
|
||||
}
|
||||
|
||||
// Read current config using internal profilemanager
|
||||
@@ -174,53 +185,57 @@ func (pm *ProfileManager) LogoutProfile(profileName string) error {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("logged out from profile: %s", profileName)
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProfile deletes a profile
|
||||
func (pm *ProfileManager) RemoveProfile(profileName string) error {
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
// Use ServiceManager (removes profile from profiles/ directory)
|
||||
if err := pm.serviceMgr.RemoveProfile(profileName, androidUsername); err != nil {
|
||||
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
|
||||
return fmt.Errorf("failed to remove profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("removed profile: %s", profileName)
|
||||
log.Infof("removed profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getProfileConfigPath returns the config file path for a profile
|
||||
// This is needed for Android-specific path handling (netbird.cfg for default profile)
|
||||
func (pm *ProfileManager) getProfileConfigPath(profileName string) (string, error) {
|
||||
if profileName == "" || profileName == profilemanager.DefaultProfileName {
|
||||
func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
if id == profilemanager.DefaultProfileName {
|
||||
// Android uses netbird.cfg for default profile instead of default.json
|
||||
// Default profile is stored in root configDir, not in profiles/
|
||||
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
||||
}
|
||||
|
||||
// Non-default profiles are stored in profiles subdirectory
|
||||
// This matches the Java Preferences.java expectation
|
||||
profileName = sanitizeProfileName(profileName)
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, profileName+".json"), nil
|
||||
return filepath.Join(profilesDir, id+".json"), nil
|
||||
}
|
||||
|
||||
// GetConfigPath returns the config file path for a given profile
|
||||
// GetConfigPath returns the config file path for a given profile id
|
||||
// Java should call this instead of constructing paths with Preferences.configFile()
|
||||
func (pm *ProfileManager) GetConfigPath(profileName string) (string, error) {
|
||||
return pm.getProfileConfigPath(profileName)
|
||||
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
|
||||
return pm.getProfileConfigPath(id)
|
||||
}
|
||||
|
||||
// GetStateFilePath returns the state file path for a given profile
|
||||
// Java should call this instead of constructing paths with Preferences.stateFile()
|
||||
func (pm *ProfileManager) GetStateFilePath(profileName string) (string, error) {
|
||||
if profileName == "" || profileName == profilemanager.DefaultProfileName {
|
||||
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
||||
if id == "" || id == profilemanager.DefaultProfileName {
|
||||
return filepath.Join(pm.configDir, "state.json"), nil
|
||||
}
|
||||
|
||||
profileName = sanitizeProfileName(profileName)
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, profileName+".state.json"), nil
|
||||
return filepath.Join(profilesDir, id+".state.json"), nil
|
||||
}
|
||||
|
||||
// GetActiveConfigPath returns the config file path for the currently active profile
|
||||
@@ -230,7 +245,7 @@ func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||
}
|
||||
return pm.GetConfigPath(activeProfile)
|
||||
return pm.GetConfigPath(activeProfile.ID)
|
||||
}
|
||||
|
||||
// GetActiveStateFilePath returns the state file path for the currently active profile
|
||||
@@ -240,18 +255,5 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||
}
|
||||
return pm.GetStateFilePath(activeProfile)
|
||||
}
|
||||
|
||||
// sanitizeProfileName removes invalid characters from profile name
|
||||
func sanitizeProfileName(name string) string {
|
||||
// Keep only alphanumeric, underscore, and hyphen
|
||||
var result strings.Builder
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
return pm.GetStateFilePath(activeProfile.ID)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/upload-server/types"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
const errCloseConnection = "Failed to close connection: %v"
|
||||
@@ -84,6 +87,73 @@ var persistenceCmd = &cobra.Command{
|
||||
RunE: setSyncResponsePersistence,
|
||||
}
|
||||
|
||||
var debugConfigCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Example: " netbird debug config",
|
||||
Short: "Dump the effective configuration",
|
||||
Long: "Prints the daemon's resolved configuration (after applying defaults, file, env, CLI input, and MDM policy overrides) as JSON. Includes the list of MDM-managed fields.",
|
||||
RunE: debugConfigDump,
|
||||
}
|
||||
|
||||
// debugConfigDump implements `netbird debug config`. It resolves the
|
||||
// active profile, queries the daemon for the effective configuration
|
||||
// via GetConfig, and prints the resulting GetConfigResponse as JSON
|
||||
// (via protojson with EmitUnpopulated=true so the output is stable
|
||||
// across runs and includes zero-valued fields).
|
||||
//
|
||||
// Useful for verifying MDM enforcement end-to-end: the response's
|
||||
// mDMManagedFields array is the single source of truth for "which
|
||||
// fields is the daemon currently enforcing from the MDM source", and
|
||||
// every config field side-by-side with that list confirms the merge
|
||||
// result. Secrets in the response (e.g. PreSharedKey) are already
|
||||
// redacted by the daemon-side handler.
|
||||
func debugConfigDump(cmd *cobra.Command, _ []string) error {
|
||||
pm := profilemanager.NewProfileManager()
|
||||
activeProf, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get active profile: %v", err)
|
||||
}
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %v", err)
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Errorf(errCloseConnection, err)
|
||||
}
|
||||
}()
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{
|
||||
ProfileName: activeProf.Name,
|
||||
Username: currUser.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config: %v", status.Convert(err).Message())
|
||||
}
|
||||
|
||||
// Use protojson so well-known fields render correctly; emit defaults so
|
||||
// the operator sees every field even when zero/empty.
|
||||
m := protojson.MarshalOptions{Multiline: true, Indent: " ", EmitUnpopulated: true}
|
||||
out, err := m.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
cmd.Println(string(out))
|
||||
return nil
|
||||
}
|
||||
|
||||
// debugBundle requests the daemon to create a debug bundle and prints
|
||||
// the resulting local file path and, if uploaded, the uploaded file
|
||||
// key. It uses the package flags (anonymize, system info, log file
|
||||
// count, CLI version, optional upload URL) to configure the bundle
|
||||
// request. Returns an error if the RPC fails or if the daemon reports
|
||||
// an upload failure reason.
|
||||
func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
@@ -100,6 +170,7 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -298,6 +369,7 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -432,6 +504,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
|
||||
SyncResponse: syncResponse,
|
||||
LogPath: logFilePath,
|
||||
CPUProfile: nil,
|
||||
DaemonVersion: version.NetbirdVersion(), // acting as daemon
|
||||
},
|
||||
debug.BundleConfig{
|
||||
IncludeSystemInfo: true,
|
||||
|
||||
301
client/cmd/kubernetes.go
Normal file
301
client/cmd/kubernetes.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
KubernetesDNSSuffix = "netbird-kubeapi-proxy"
|
||||
)
|
||||
|
||||
var kubernetesCmd = &cobra.Command{
|
||||
Use: "kubernetes",
|
||||
Short: "Kubernetes cluster commands.",
|
||||
Long: "Kubernetes cluster commands.",
|
||||
}
|
||||
|
||||
var kubernetesListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
RunE: kubernetesList,
|
||||
Short: "List Kubernetes clusters.",
|
||||
Long: "List Kubernetes clusters by discovering NetBird peers running netbird-kubeapi-proxy.",
|
||||
}
|
||||
|
||||
var kubernetesWriteKubeconfigCmd = &cobra.Command{
|
||||
Use: "write-kubeconfig",
|
||||
RunE: kubernetesWriteKubeconfig,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Write kubeconfig for a Kubernetes cluster.",
|
||||
Long: "Updates kubeconfig in place to allow token-less access to the Kubernetes cluster through NetBird.",
|
||||
}
|
||||
|
||||
func init() {
|
||||
kubernetesWriteKubeconfigCmd.Flags().String("kubeconfig", "", "path to kubeconfig file")
|
||||
}
|
||||
|
||||
func kubernetesList(cmd *cobra.Command, _ []string) error {
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(kcs) == 0 {
|
||||
cmd.Println("No Kubernetes clusters available.")
|
||||
return nil
|
||||
}
|
||||
cmd.Println("Available Kubernetes clusters:")
|
||||
for _, k := range kcs {
|
||||
cmd.Printf("\n - Name: %s\n FQDN: %s\n Version: %s\n", k.name, k.url.Host, k.version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kubernetesWriteKubeconfig(cmd *cobra.Command, args []string) error {
|
||||
kubeconfigPath, err := resolveKubeconfigPath(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clusterName := args[0]
|
||||
kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, clusterName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(kcs) == 0 {
|
||||
return fmt.Errorf("kubernetes cluster named %s not found", clusterName)
|
||||
}
|
||||
if len(kcs) > 1 {
|
||||
return fmt.Errorf("too many Kubernetes clusters returned")
|
||||
}
|
||||
err = writeKubeconfig(kubeconfigPath, kcs[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type kubernetesCluster struct {
|
||||
name string
|
||||
url *url.URL
|
||||
version string
|
||||
}
|
||||
|
||||
func getKubernetesClusters(ctx context.Context, peers []*proto.PeerState, nameFilter string) ([]kubernetesCluster, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
resolver := net.Resolver{
|
||||
// Required so both DNS records are returned.
|
||||
// https://github.com/golang/go/issues/17093
|
||||
PreferGo: true,
|
||||
}
|
||||
|
||||
kcs := []kubernetesCluster{}
|
||||
attempted := map[string]struct{}{}
|
||||
for _, peer := range peers {
|
||||
fqdns, err := resolver.LookupAddr(ctx, peer.IP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, fqdn := range fqdns {
|
||||
if _, ok := attempted[fqdn]; ok {
|
||||
continue
|
||||
}
|
||||
attempted[fqdn] = struct{}{}
|
||||
comps := strings.Split(fqdn, ".")
|
||||
if len(comps) < 2 {
|
||||
continue
|
||||
}
|
||||
if comps[1] != KubernetesDNSSuffix {
|
||||
continue
|
||||
}
|
||||
if nameFilter != "" && nameFilter != comps[0] {
|
||||
continue
|
||||
}
|
||||
clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn)
|
||||
if err != nil {
|
||||
log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err)
|
||||
continue
|
||||
}
|
||||
kc := kubernetesCluster{
|
||||
name: comps[0],
|
||||
url: clusterURL,
|
||||
version: clusterVersion,
|
||||
}
|
||||
if nameFilter != "" {
|
||||
return []kubernetesCluster{kc}, nil
|
||||
}
|
||||
kcs = append(kcs, kc)
|
||||
}
|
||||
}
|
||||
return kcs, nil
|
||||
}
|
||||
|
||||
func fingerprintClusters(ctx context.Context, httpClient *http.Client, fqdn string) (*url.URL, string, error) {
|
||||
clusterURL, err := url.Parse("https://" + fqdn)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
versionURL, err := clusterURL.Parse("/version")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("expected %d response but got %s", http.StatusOK, resp.Status)
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
versionData := map[string]string{}
|
||||
err = json.Unmarshal(b, &versionData)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
version, ok := versionData["gitVersion"]
|
||||
if !ok {
|
||||
return nil, "", errors.New("no version found in response")
|
||||
}
|
||||
return clusterURL, version, nil
|
||||
}
|
||||
|
||||
func resolveKubeconfigPath(cmd *cobra.Command) (string, error) {
|
||||
if cmd.Flags().Changed("kubeconfig") {
|
||||
path, err := cmd.Flags().GetString("kubeconfig")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
if env := os.Getenv("KUBECONFIG"); env != "" {
|
||||
return env, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".kube", "config"), nil
|
||||
}
|
||||
|
||||
func writeKubeconfig(kubeconfigPath string, kc kubernetesCluster) error {
|
||||
b, err := os.ReadFile(kubeconfigPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := yaml.Unmarshal(b, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]any{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Config",
|
||||
}
|
||||
}
|
||||
|
||||
cfg["clusters"] = appendWithName(cfg["clusters"], map[string]any{
|
||||
"name": kc.name,
|
||||
"cluster": map[string]any{
|
||||
"server": kc.url.String(),
|
||||
"insecure-skip-tls-verify": true,
|
||||
},
|
||||
})
|
||||
cfg["users"] = appendWithName(cfg["users"], map[string]any{
|
||||
"name": "netbird",
|
||||
"user": map[string]any{
|
||||
"token": "none",
|
||||
},
|
||||
})
|
||||
cfg["contexts"] = appendWithName(cfg["contexts"], map[string]any{
|
||||
"name": kc.name,
|
||||
"context": map[string]any{
|
||||
"cluster": kc.name,
|
||||
"user": "netbird",
|
||||
"namespace": "default",
|
||||
},
|
||||
})
|
||||
cfg["current-context"] = kc.name
|
||||
|
||||
out, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(kubeconfigPath, out, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendWithName(data any, add map[string]any) any {
|
||||
if data == nil {
|
||||
return []any{add}
|
||||
}
|
||||
v, ok := data.([]any)
|
||||
if !ok {
|
||||
return []any{add}
|
||||
}
|
||||
i := slices.IndexFunc(v, func(item any) bool {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return m["name"] == add["name"]
|
||||
})
|
||||
if i == -1 {
|
||||
return append(v, add)
|
||||
}
|
||||
v[i] = add
|
||||
return v
|
||||
}
|
||||
120
client/cmd/kubernetes_test.go
Normal file
120
client/cmd/kubernetes_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFingerprintClusters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
//nolint: errcheck
|
||||
w.Write([]byte(`{"gitVersion": "foobar"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
clusterURL, clusterVersion, err := fingerprintClusters(t.Context(), srv.Client(), srv.Listener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, srv.URL, clusterURL.String())
|
||||
require.Equal(t, "foobar", clusterVersion)
|
||||
}
|
||||
|
||||
func TestResolveKubeconfigPath(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("could not determine home directory: %v", err)
|
||||
}
|
||||
defaultPath := filepath.Join(home, ".kube", "config")
|
||||
path, err := resolveKubeconfigPath(&cobra.Command{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultPath, path)
|
||||
|
||||
flagPath := "flag-path"
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("kubeconfig", "", "")
|
||||
err = cmd.Flags().Set("kubeconfig", flagPath)
|
||||
require.NoError(t, err)
|
||||
path, err = resolveKubeconfigPath(cmd)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, flagPath, path)
|
||||
|
||||
envPath := "env-path"
|
||||
t.Setenv("KUBECONFIG", envPath)
|
||||
path, err = resolveKubeconfigPath(&cobra.Command{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, envPath, path)
|
||||
}
|
||||
|
||||
func TestWriteKubeconfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
existing string
|
||||
}{
|
||||
{
|
||||
name: "empty file",
|
||||
},
|
||||
{
|
||||
name: "existing content",
|
||||
existing: `apiVersion: v1
|
||||
clusters:
|
||||
- cluster:
|
||||
insecure-skip-tls-verify: true
|
||||
server: https://foobar.com
|
||||
name: foo
|
||||
current-context: test
|
||||
kind: Config
|
||||
users: []
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
kubeconfigPath := filepath.Join(t.TempDir(), "config")
|
||||
err := os.WriteFile(kubeconfigPath, []byte(tt.existing), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
kc := kubernetesCluster{
|
||||
name: "foo",
|
||||
url: &url.URL{Scheme: "https", Host: "example.com"},
|
||||
}
|
||||
err = writeKubeconfig(kubeconfigPath, kc)
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := os.ReadFile(kubeconfigPath)
|
||||
require.NoError(t, err)
|
||||
expected := `apiVersion: v1
|
||||
clusters:
|
||||
- cluster:
|
||||
insecure-skip-tls-verify: true
|
||||
server: https://example.com
|
||||
name: foo
|
||||
contexts:
|
||||
- context:
|
||||
cluster: foo
|
||||
namespace: default
|
||||
user: netbird
|
||||
name: foo
|
||||
current-context: foo
|
||||
kind: Config
|
||||
users:
|
||||
- name: netbird
|
||||
user:
|
||||
token: none
|
||||
`
|
||||
require.Equal(t, expected, string(b))
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,19 +22,11 @@ import (
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// extendSessionFlag drives the `netbird login --extend` flow: refresh the
|
||||
// SSO session expiry on the management server without tearing down the
|
||||
// tunnel. Mutually exclusive with setup-key login (a setup-key cannot
|
||||
// refresh an SSO-tracked peer — see auth.errSetupKeyOnSSOExpiredPeer).
|
||||
var extendSessionFlag bool
|
||||
|
||||
func init() {
|
||||
loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
|
||||
loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc)
|
||||
loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
|
||||
loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location")
|
||||
loginCmd.PersistentFlags().BoolVar(&extendSessionFlag, "extend", false,
|
||||
"refresh the SSO session expiry without tearing down the tunnel (requires an active connection)")
|
||||
}
|
||||
|
||||
var loginCmd = &cobra.Command{
|
||||
@@ -69,16 +61,6 @@ var loginCmd = &cobra.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
if extendSessionFlag {
|
||||
if providedSetupKey != "" {
|
||||
return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers")
|
||||
}
|
||||
if err := doExtendSession(ctx, cmd); err != nil {
|
||||
return fmt.Errorf("extend session failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// workaround to run without service
|
||||
if util.FindFirstLogPath(logFiles) == "" {
|
||||
if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil {
|
||||
@@ -114,17 +96,19 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
||||
dnsLabelsReq = dnsLabelsValidated.ToSafeStringList()
|
||||
}
|
||||
|
||||
handle := activeProf.ID.String()
|
||||
|
||||
loginRequest := proto.LoginRequest{
|
||||
SetupKey: providedSetupKey,
|
||||
ManagementUrl: managementURL,
|
||||
IsUnixDesktopClient: isUnixRunningDesktop(),
|
||||
Hostname: hostName,
|
||||
DnsLabels: dnsLabelsReq,
|
||||
ProfileName: &activeProf.Name,
|
||||
ProfileName: &handle,
|
||||
Username: &username,
|
||||
}
|
||||
|
||||
profileState, err := pm.GetProfileState(activeProf.Name)
|
||||
profileState, err := pm.GetProfileState(activeProf.ID)
|
||||
if err != nil {
|
||||
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||
} else if profileState.Email != "" {
|
||||
@@ -168,65 +152,6 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
||||
return nil
|
||||
}
|
||||
|
||||
// doExtendSession drives the daemon's RequestExtendAuthSession /
|
||||
// WaitExtendAuthSession pair. The user is sent through a regular SSO flow
|
||||
// (browser + verification URL) and the resulting JWT is forwarded to the
|
||||
// management server's ExtendAuthSession RPC. The tunnel stays up
|
||||
// throughout — no Down/Up, no network-map resync.
|
||||
func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
//nolint
|
||||
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
||||
"If the daemon is not running please run: "+
|
||||
"\nnetbird service install \nnetbird service start\n", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
|
||||
req := &proto.RequestExtendAuthSessionRequest{}
|
||||
// Pre-fill the IdP login hint from the active profile so the user
|
||||
// doesn't have to retype their email. Best-effort: we still proceed
|
||||
// without a hint if the lookup fails.
|
||||
pm := profilemanager.NewProfileManager()
|
||||
if active, perr := pm.GetActiveProfile(); perr == nil {
|
||||
if profState, sperr := pm.GetProfileState(active.Name); sperr == nil && profState.Email != "" {
|
||||
req.Hint = &profState.Email
|
||||
}
|
||||
}
|
||||
|
||||
startResp, err := client.RequestExtendAuthSession(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start extend session: %v", err)
|
||||
}
|
||||
|
||||
uri := startResp.GetVerificationURIComplete()
|
||||
if uri == "" {
|
||||
uri = startResp.GetVerificationURI()
|
||||
}
|
||||
openURL(cmd, uri, startResp.GetUserCode(), noBrowser, showQR)
|
||||
|
||||
waitResp, err := client.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{
|
||||
DeviceCode: startResp.GetDeviceCode(),
|
||||
UserCode: startResp.GetUserCode(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for extend session: %v", err)
|
||||
}
|
||||
|
||||
if ts := waitResp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
|
||||
deadline := ts.AsTime().Local()
|
||||
cmd.Printf("Session extended. New expiry: %s\n", deadline.Format("2006-01-02 15:04:05 MST"))
|
||||
} else {
|
||||
// Management reported the peer is not eligible (e.g. login
|
||||
// expiration disabled on the account). Surface that fact
|
||||
// instead of pretending the call succeeded.
|
||||
cmd.Println("Session extension call completed, but the management server did not return a new deadline (peer may not be SSO-tracked or login expiration is disabled).")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) (*profilemanager.Profile, error) {
|
||||
// switch profile if provided
|
||||
|
||||
@@ -247,14 +172,13 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
|
||||
return activeProf, nil
|
||||
}
|
||||
|
||||
func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) error {
|
||||
err := switchProfile(context.Background(), profileName, username)
|
||||
func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error {
|
||||
resolvedID, err := switchProfile(ctx, handle, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch profile on daemon: %v", err)
|
||||
}
|
||||
|
||||
err = pm.SwitchProfile(profileName)
|
||||
if err != nil {
|
||||
if err := pm.SwitchProfile(resolvedID); err != nil {
|
||||
return fmt.Errorf("switch profile: %v", err)
|
||||
}
|
||||
|
||||
@@ -282,11 +206,15 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage
|
||||
return nil
|
||||
}
|
||||
|
||||
func switchProfile(ctx context.Context, profileName string, username string) error {
|
||||
// switchProfile asks the daemon to switch to the profile identified by
|
||||
// handle (a name, ID, or unique ID prefix). Returns the resolved profile
|
||||
// ID so the caller can update the local active-profile state without
|
||||
// re-resolving the handle.
|
||||
func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) {
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
//nolint
|
||||
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
||||
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)
|
||||
}
|
||||
@@ -294,15 +222,15 @@ func switchProfile(ctx context.Context, profileName string, username string) err
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
|
||||
_, err = client.SwitchProfile(ctx, &proto.SwitchProfileRequest{
|
||||
ProfileName: &profileName,
|
||||
resp, err := client.SwitchProfile(ctx, &proto.SwitchProfileRequest{
|
||||
ProfileName: &handle,
|
||||
Username: &username,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch profile failed: %v", err)
|
||||
return "", fmt.Errorf("switch profile failed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return profilemanager.ID(resp.Id), nil
|
||||
}
|
||||
|
||||
func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, activeProf *profilemanager.Profile) error {
|
||||
@@ -326,7 +254,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
||||
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
||||
}
|
||||
|
||||
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name)
|
||||
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("foreground login failed: %v", err)
|
||||
}
|
||||
@@ -354,7 +282,7 @@ func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.Lo
|
||||
return nil
|
||||
}
|
||||
|
||||
func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey, profileName string) error {
|
||||
func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey string, profileID profilemanager.ID) error {
|
||||
authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth client: %v", err)
|
||||
@@ -368,7 +296,7 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman
|
||||
|
||||
jwtToken := ""
|
||||
if setupKey == "" && needsLogin {
|
||||
tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileName)
|
||||
tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
@@ -383,10 +311,10 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman
|
||||
return nil
|
||||
}
|
||||
|
||||
func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileName string) (*auth.TokenInfo, error) {
|
||||
func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileID profilemanager.ID) (*auth.TokenInfo, error) {
|
||||
hint := ""
|
||||
pm := profilemanager.NewProfileManager()
|
||||
profileState, err := pm.GetProfileState(profileName)
|
||||
profileState, err := pm.GetProfileState(profileID)
|
||||
if err != nil {
|
||||
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||
} else if profileState.Email != "" {
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestLogin(t *testing.T) {
|
||||
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
|
||||
sm := profilemanager.ServiceManager{}
|
||||
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
Name: "default",
|
||||
ID: "default",
|
||||
Username: currUser.Username,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -2,11 +2,16 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/user"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
@@ -14,6 +19,8 @@ import (
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
var profileListShowID bool
|
||||
|
||||
var profileCmd = &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage NetBird client profiles",
|
||||
@@ -31,27 +38,40 @@ var profileListCmd = &cobra.Command{
|
||||
var profileAddCmd = &cobra.Command{
|
||||
Use: "add <profile_name>",
|
||||
Short: "Add a new profile",
|
||||
Long: `Add a new profile to the NetBird client. The profile name must be unique.`,
|
||||
Long: `Add a new profile. Profile name is free-form, a unique ID is generated for the on-disk config file.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: addProfileFunc,
|
||||
}
|
||||
|
||||
var profileRenameCmd = &cobra.Command{
|
||||
Use: "rename <profile> <new_profile_name>",
|
||||
Short: "Renames an existing profile",
|
||||
Long: `Renames an existing profile (by a name, ID, or unique ID prefix). Profile name is free-form.`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: renameProfileFunc,
|
||||
}
|
||||
|
||||
var profileRemoveCmd = &cobra.Command{
|
||||
Use: "remove <profile_name>",
|
||||
Short: "Remove a profile",
|
||||
Long: `Remove a profile from the NetBird client. The profile must not be inactive.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: removeProfileFunc,
|
||||
Use: "remove <profile>",
|
||||
Short: "Remove a profile",
|
||||
Long: `Remove a profile by name, ID, or unique ID prefix.`,
|
||||
Aliases: []string{"rm"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: removeProfileFunc,
|
||||
}
|
||||
|
||||
var profileSelectCmd = &cobra.Command{
|
||||
Use: "select <profile_name>",
|
||||
Use: "select <profile>",
|
||||
Short: "Select a profile",
|
||||
Long: `Make the specified profile active. This will switch the client to use the selected profile's configuration.`,
|
||||
Long: `Make the specified profile active. Accepts a name, ID, or unique ID prefix.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: selectProfileFunc,
|
||||
}
|
||||
|
||||
func init() {
|
||||
profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column")
|
||||
}
|
||||
|
||||
func setupCmd(cmd *cobra.Command) error {
|
||||
SetFlagsFromEnvVars(rootCmd)
|
||||
SetFlagsFromEnvVars(cmd)
|
||||
@@ -65,6 +85,7 @@ func setupCmd(cmd *cobra.Command) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
||||
if err := setupCmd(cmd); err != nil {
|
||||
return err
|
||||
@@ -83,25 +104,33 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||
|
||||
profiles, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
|
||||
resp, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
|
||||
Username: currUser.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// list profiles, add a tick if the profile is active
|
||||
cmd.Println("Found", len(profiles.Profiles), "profiles:")
|
||||
for _, profile := range profiles.Profiles {
|
||||
// use a cross to indicate the passive profiles
|
||||
activeMarker := "✗"
|
||||
if profile.IsActive {
|
||||
activeMarker = "✓"
|
||||
}
|
||||
cmd.Println(activeMarker, profile.Name)
|
||||
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
||||
if profileListShowID {
|
||||
fmt.Fprintln(tw, "ID\tNAME\tACTIVE")
|
||||
} else {
|
||||
fmt.Fprintln(tw, "NAME\tACTIVE")
|
||||
}
|
||||
|
||||
return nil
|
||||
for _, profile := range resp.Profiles {
|
||||
marker := ""
|
||||
if profile.IsActive {
|
||||
marker = "✓"
|
||||
}
|
||||
name := profilemanager.StripCtrlChars(profile.Name)
|
||||
id := profilemanager.ID(profile.Id)
|
||||
if profileListShowID {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", id.ShortID(), name, marker)
|
||||
} else {
|
||||
fmt.Fprintf(tw, "%s\t%s\n", name, marker)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func addProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
@@ -121,21 +150,82 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||
|
||||
profileName := args[0]
|
||||
|
||||
_, err = daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{
|
||||
resp, err := daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{
|
||||
ProfileName: profileName,
|
||||
Username: currUser.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("add profile request: %w", err)
|
||||
}
|
||||
|
||||
dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName)
|
||||
if dupCount > 1 {
|
||||
cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, profileName)
|
||||
cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
|
||||
}
|
||||
|
||||
id := profilemanager.ID(resp.Id)
|
||||
cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName))
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func renameProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
if err := setupCmd(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Println("Profile added successfully:", profileName)
|
||||
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to service CLI interface: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
|
||||
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||
handle := args[0]
|
||||
newProfilename := args[1]
|
||||
|
||||
resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{
|
||||
Handle: handle,
|
||||
Username: currUser.Username,
|
||||
NewProfileName: newProfilename,
|
||||
})
|
||||
if err != nil {
|
||||
return wrapAmbiguityError(err, handle)
|
||||
}
|
||||
|
||||
dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename)
|
||||
if dupCount > 1 {
|
||||
cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, newProfilename)
|
||||
cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
|
||||
}
|
||||
|
||||
cmd.Printf("Profile renamed from %s to %s\n", profilemanager.StripCtrlChars(resp.OldProfileName), profilemanager.StripCtrlChars(newProfilename))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func countProfilesWithName(ctx context.Context, c proto.DaemonServiceClient, username, name string) (int, error) {
|
||||
resp, err := c.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, p := range resp.Profiles {
|
||||
if p.Name == name {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func removeProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
if err := setupCmd(cmd); err != nil {
|
||||
return err
|
||||
@@ -153,18 +243,17 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||
handle := args[0]
|
||||
|
||||
profileName := args[0]
|
||||
|
||||
_, err = daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
|
||||
ProfileName: profileName,
|
||||
resp, err := daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
|
||||
ProfileName: handle,
|
||||
Username: currUser.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return wrapAmbiguityError(err, handle)
|
||||
}
|
||||
|
||||
cmd.Println("Profile removed successfully:", profileName)
|
||||
cmd.Printf("Profile removed: %s\n", resp.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -174,7 +263,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
profileManager := profilemanager.NewProfileManager()
|
||||
profileName := args[0]
|
||||
handle := args[0]
|
||||
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
@@ -191,32 +280,15 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
|
||||
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||
|
||||
profiles, err := daemonClient.ListProfiles(ctx, &proto.ListProfilesRequest{
|
||||
Username: currUser.Username,
|
||||
switchResp, err := daemonClient.SwitchProfile(ctx, &proto.SwitchProfileRequest{
|
||||
ProfileName: &handle,
|
||||
Username: &currUser.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list profiles: %w", err)
|
||||
return wrapAmbiguityError(err, handle)
|
||||
}
|
||||
|
||||
var profileExists bool
|
||||
|
||||
for _, profile := range profiles.Profiles {
|
||||
if profile.Name == profileName {
|
||||
profileExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !profileExists {
|
||||
return fmt.Errorf("profile %s does not exist", profileName)
|
||||
}
|
||||
|
||||
if err := switchProfile(cmd.Context(), profileName, currUser.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = profileManager.SwitchProfile(profileName)
|
||||
if err != nil {
|
||||
if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -231,6 +303,30 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Println("Profile switched successfully to:", profileName)
|
||||
id := profilemanager.ID(switchResp.Id)
|
||||
cmd.Printf("Profile switched to: %s\n", id.ShortID())
|
||||
return nil
|
||||
}
|
||||
|
||||
// wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors
|
||||
// (which carry the resolver's message verbatim) into CLI-friendly text
|
||||
// that points the user at --show-id.
|
||||
func wrapAmbiguityError(err error, handle string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
st, ok := gstatus.FromError(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
switch st.Code() {
|
||||
case codes.InvalidArgument:
|
||||
msg := st.Message()
|
||||
if strings.Contains(msg, "ambiguous") {
|
||||
return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n netbird profile select|remove <id-prefix>")
|
||||
}
|
||||
case codes.NotFound:
|
||||
return fmt.Errorf("profile %q not found", handle)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -95,7 +95,9 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// Execute executes the root command.
|
||||
// Execute runs the appropriate Cobra command for the CLI.
|
||||
// If the process is the update binary it delegates to updateCmd; otherwise it runs the root command.
|
||||
// It returns any error produced during command execution.
|
||||
func Execute() error {
|
||||
if isUpdateBinary() {
|
||||
return updateCmd.Execute()
|
||||
@@ -103,6 +105,16 @@ func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
// init initialises package-level defaults and configures the root
|
||||
// Cobra command tree. Sets platform-specific config / log directory
|
||||
// paths (including legacy Wiretrustee fallbacks) and a default daemon
|
||||
// address; registers persistent CLI flags (daemon address,
|
||||
// management / admin URLs, logging, setup key (file and inline,
|
||||
// mutually exclusive), preshared key, hostname, anonymise, config
|
||||
// path); attaches top-level and nested subcommands to the root
|
||||
// command; and registers `up`-specific persistent flags (external IP
|
||||
// maps, custom DNS resolver address, Rosenpass options, auto-connect
|
||||
// disabling, lazy connection).
|
||||
func init() {
|
||||
defaultConfigPathDir = "/etc/netbird/"
|
||||
defaultLogFileDir = "/var/log/netbird/"
|
||||
@@ -168,10 +180,17 @@ func init() {
|
||||
logCmd.AddCommand(logLevelCmd)
|
||||
debugCmd.AddCommand(forCmd)
|
||||
debugCmd.AddCommand(persistenceCmd)
|
||||
debugCmd.AddCommand(debugConfigCmd)
|
||||
|
||||
// kubernetes commands
|
||||
rootCmd.AddCommand(kubernetesCmd)
|
||||
kubernetesCmd.AddCommand(kubernetesListCmd)
|
||||
kubernetesCmd.AddCommand(kubernetesWriteKubeconfigCmd)
|
||||
|
||||
// profile commands
|
||||
profileCmd.AddCommand(profileListCmd)
|
||||
profileCmd.AddCommand(profileAddCmd)
|
||||
profileCmd.AddCommand(profileRenameCmd)
|
||||
profileCmd.AddCommand(profileRemoveCmd)
|
||||
profileCmd.AddCommand(profileSelectCmd)
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ func (p *program) Stop(srv service.Service) error {
|
||||
}
|
||||
|
||||
// Common setup for service control commands
|
||||
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
|
||||
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) {
|
||||
// rootCmd env vars are already applied by PersistentPreRunE.
|
||||
SetFlagsFromEnvVars(serviceCmd)
|
||||
|
||||
@@ -112,8 +112,14 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := util.InitLog(logLevel, logFiles...); err != nil {
|
||||
return nil, fmt.Errorf("init log: %w", err)
|
||||
if consoleLog {
|
||||
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||
return nil, fmt.Errorf("init log: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := util.InitLog(logLevel, logFiles...); err != nil {
|
||||
return nil, fmt.Errorf("init log: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := newSVCConfig()
|
||||
@@ -138,7 +144,7 @@ var runCmd = &cobra.Command{
|
||||
SetupCloseHandler(ctx, cancel)
|
||||
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
|
||||
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -152,7 +158,7 @@ var startCmd = &cobra.Command{
|
||||
Short: "starts NetBird service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,7 +176,7 @@ var stopCmd = &cobra.Command{
|
||||
Short: "stops NetBird service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -188,7 +194,7 @@ var restartCmd = &cobra.Command{
|
||||
Short: "restarts NetBird service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -206,7 +212,7 @@ var svcStatusCmd = &cobra.Command{
|
||||
Short: "shows NetBird service status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
@@ -118,11 +117,6 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
profName = activeProf.Name
|
||||
}
|
||||
|
||||
var sessionExpiresAt time.Time
|
||||
if ts := resp.GetSessionExpiresAt(); ts.IsValid() {
|
||||
sessionExpiresAt = ts.AsTime().UTC()
|
||||
}
|
||||
|
||||
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||
Anonymize: anonymizeFlag,
|
||||
DaemonVersion: resp.GetDaemonVersion(),
|
||||
@@ -133,7 +127,6 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
IPsFilter: ipsFilterMap,
|
||||
ConnectionTypeFilter: connectionTypeFilter,
|
||||
ProfileName: profName,
|
||||
SessionExpiresAt: sessionExpiresAt,
|
||||
})
|
||||
var statusOutputString string
|
||||
switch {
|
||||
|
||||
@@ -128,13 +128,12 @@ func upFunc(cmd *cobra.Command, args []string) error {
|
||||
var profileSwitched bool
|
||||
// switch profile if provided
|
||||
if profileName != "" {
|
||||
err = switchProfile(cmd.Context(), profileName, username.Username)
|
||||
resolvedID, err := switchProfile(cmd.Context(), profileName, username.Username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch profile: %v", err)
|
||||
}
|
||||
|
||||
err = pm.SwitchProfile(profileName)
|
||||
if err != nil {
|
||||
if err := pm.SwitchProfile(resolvedID); err != nil {
|
||||
return fmt.Errorf("switch profile: %v", err)
|
||||
}
|
||||
|
||||
@@ -190,7 +189,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
||||
|
||||
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
||||
|
||||
err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.Name)
|
||||
err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("foreground login failed: %v", err)
|
||||
}
|
||||
@@ -261,10 +260,10 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
||||
}
|
||||
|
||||
// set the new config
|
||||
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.Name, username.Username)
|
||||
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
|
||||
if _, err := client.SetConfig(ctx, req); err != nil {
|
||||
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
|
||||
log.Warnf("setConfig method is not available in the daemon")
|
||||
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
|
||||
} else {
|
||||
return fmt.Errorf("call service setConfig method: %v", err)
|
||||
}
|
||||
@@ -289,10 +288,11 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
return fmt.Errorf("setup login request: %v", err)
|
||||
}
|
||||
|
||||
loginRequest.ProfileName = &activeProf.Name
|
||||
profileID := activeProf.ID.String()
|
||||
loginRequest.ProfileName = &profileID
|
||||
loginRequest.Username = &username
|
||||
|
||||
profileState, err := pm.GetProfileState(activeProf.Name)
|
||||
profileState, err := pm.GetProfileState(activeProf.ID)
|
||||
if err != nil {
|
||||
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||
} else if profileState.Email != "" {
|
||||
@@ -329,7 +329,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
}
|
||||
|
||||
if _, err := client.Up(ctx, &proto.UpRequest{
|
||||
ProfileName: &activeProf.Name,
|
||||
ProfileName: &profileID,
|
||||
Username: &username,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("call service up method: %v", err)
|
||||
|
||||
@@ -29,14 +29,14 @@ func TestUpDaemon(t *testing.T) {
|
||||
}
|
||||
|
||||
sm := profilemanager.ServiceManager{}
|
||||
err = sm.AddProfile("test1", currUser.Username)
|
||||
created, err := sm.AddProfile("test1", currUser.Username)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add profile: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
Name: "test1",
|
||||
ID: created.ID,
|
||||
Username: currUser.Username,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -12,7 +12,13 @@ var (
|
||||
Short: "Print the NetBird's client application version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
cmd.Println(version.NetbirdVersion())
|
||||
out := version.NetbirdVersion()
|
||||
if version.IsDevelopmentVersion(out) {
|
||||
if commit := version.NetbirdCommit(); commit != "" {
|
||||
out += "-" + commit
|
||||
}
|
||||
}
|
||||
cmd.Println(out)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -279,6 +279,10 @@ func (c *Client) Start(startCtx context.Context) error {
|
||||
|
||||
select {
|
||||
case <-startCtx.Done():
|
||||
// Cancel the client context before stopping: Engine.Start blocks on the
|
||||
// signal stream while holding the engine mutex and only unblocks on
|
||||
// cancellation. Stopping first would deadlock on that mutex.
|
||||
cancel()
|
||||
if stopErr := client.Stop(); stopErr != nil {
|
||||
return fmt.Errorf("stop error after context done. Stop error: %w. Context done: %w", stopErr, startCtx.Err())
|
||||
}
|
||||
@@ -442,8 +446,8 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
|
||||
|
||||
// IdentityForIP looks up a remote peer by its tunnel IP using the
|
||||
// embedded client's status recorder. Returns the peer's WireGuard public
|
||||
// key and FQDN. ok=false means the IP isn't in this client's peer
|
||||
// roster — callers should treat that as "unknown peer".
|
||||
// key and FQDN. ok=false means the IP doesn't belong to an active peer
|
||||
// — offline roster peers are treated as unknown, same as foreign IPs.
|
||||
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
|
||||
if !ip.IsValid() || c.recorder == nil {
|
||||
return "", "", false
|
||||
@@ -464,7 +468,7 @@ func (c *Client) Status() (peer.FullStatus, error) {
|
||||
if connect != nil {
|
||||
engine := connect.Engine()
|
||||
if engine != nil {
|
||||
_ = engine.RunHealthProbes(context.Background(), false)
|
||||
_ = engine.RunHealthProbes(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
168
client/embed/embed_test.go
Normal file
168
client/embed/embed_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package embed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
mgmt "github.com/netbirdio/netbird/management/server"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const testSetupKey = "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
|
||||
|
||||
// TestClientStartTimeoutRollback reproduces a deadlock between Engine.Start and
|
||||
// Engine.Stop. The signal endpoint accepts gRPC connections but never serves the
|
||||
// SignalExchange service, so Engine.Start parks in WaitStreamConnected while
|
||||
// holding the engine mutex. When the Start context expires, the rollback path
|
||||
// calls ConnectClient.Stop, which must not block forever acquiring that mutex.
|
||||
func TestClientStartTimeoutRollback(t *testing.T) {
|
||||
signalAddr := startBlackholeSignal(t)
|
||||
mgmAddr := startManagement(t, signalAddr)
|
||||
|
||||
wgPort := 0
|
||||
client, err := New(Options{
|
||||
DeviceName: "embed-rollback-test",
|
||||
SetupKey: testSetupKey,
|
||||
ManagementURL: "http://" + mgmAddr,
|
||||
WireguardPort: &wgPort,
|
||||
})
|
||||
require.NoError(t, err, "embed client creation must succeed")
|
||||
|
||||
startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
startErr := make(chan error, 1)
|
||||
go func() {
|
||||
startErr <- client.Start(startCtx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-startErr:
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("client.Start did not return after its context expired: Engine.Stop deadlocked against Engine.Start waiting for the signal stream")
|
||||
}
|
||||
}
|
||||
|
||||
// startBlackholeSignal starts a gRPC server without the SignalExchange service
|
||||
// registered. Connections succeed, but the signal stream can never be
|
||||
// established, which keeps Engine.Start parked in WaitStreamConnected.
|
||||
func startBlackholeSignal(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
lis, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
s := grpc.NewServer()
|
||||
go func() {
|
||||
if err := s.Serve(lis); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
t.Cleanup(s.Stop)
|
||||
|
||||
return lis.Addr().String()
|
||||
}
|
||||
|
||||
func startManagement(t *testing.T, signalAddr string) string {
|
||||
t.Helper()
|
||||
|
||||
cfg := &config.Config{
|
||||
Stuns: []*config.Host{},
|
||||
TURNConfig: &config.TURNConfig{},
|
||||
Relay: &config.Relay{
|
||||
Addresses: []string{"127.0.0.1:1234"},
|
||||
CredentialsTTL: util.Duration{Duration: time.Hour},
|
||||
Secret: "222222222222222222",
|
||||
},
|
||||
Signal: &config.Host{
|
||||
Proto: "http",
|
||||
URI: signalAddr,
|
||||
},
|
||||
Datadir: t.TempDir(),
|
||||
HttpConfig: nil,
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
s := grpc.NewServer()
|
||||
|
||||
testStore, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", cfg.Datadir)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
eventStore := &activity.InMemoryEventStore{}
|
||||
|
||||
permissionsManager := permissions.NewManager(testStore)
|
||||
peersManager := peers.NewManager(testStore, permissionsManager)
|
||||
jobManager := job.NewJobManager(nil, testStore, peersManager)
|
||||
|
||||
cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
iv, err := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
||||
require.NoError(t, err)
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
t.Cleanup(ctrl.Finish)
|
||||
settingsMockManager := settings.NewMockManager(ctrl)
|
||||
settingsMockManager.EXPECT().
|
||||
GetSettings(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(&types.Settings{}, nil).
|
||||
AnyTimes()
|
||||
settingsMockManager.EXPECT().
|
||||
GetExtraSettings(gomock.Any(), gomock.Any()).
|
||||
Return(&types.ExtraSettings{}, nil).
|
||||
AnyTimes()
|
||||
|
||||
groupsManager := groups.NewManagerMock()
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
|
||||
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg)
|
||||
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
require.NoError(t, err)
|
||||
|
||||
secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager)
|
||||
require.NoError(t, err)
|
||||
|
||||
mgmtServer, err := nbgrpc.NewServer(cfg, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil)
|
||||
require.NoError(t, err)
|
||||
mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
|
||||
|
||||
go func() {
|
||||
if err := s.Serve(lis); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
t.Cleanup(s.Stop)
|
||||
|
||||
return lis.Addr().String()
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package iptables
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"slices"
|
||||
|
||||
@@ -421,12 +422,17 @@ func (m *aclManager) updateState() {
|
||||
currentState.Lock()
|
||||
defer currentState.Unlock()
|
||||
|
||||
// Clone the maps so the persisted state holds a private snapshot. The
|
||||
// live maps keep being mutated by subsequent rule operations while the
|
||||
// state manager marshals the state from its periodic-save goroutine.
|
||||
// Sharing them by reference races the two and aborts the process with a
|
||||
// concurrent map iteration and write.
|
||||
if m.v6 {
|
||||
currentState.ACLEntries6 = m.entries
|
||||
currentState.ACLIPsetStore6 = m.ipsetStore
|
||||
currentState.ACLEntries6 = maps.Clone(m.entries)
|
||||
currentState.ACLIPsetStore6 = m.ipsetStore.clone()
|
||||
} else {
|
||||
currentState.ACLEntries = m.entries
|
||||
currentState.ACLIPsetStore = m.ipsetStore
|
||||
currentState.ACLEntries = maps.Clone(m.entries)
|
||||
currentState.ACLIPsetStore = m.ipsetStore.clone()
|
||||
}
|
||||
|
||||
if err := m.stateManager.UpdateState(currentState); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ package iptables
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -749,11 +750,17 @@ func (r *router) updateState() {
|
||||
currentState.Lock()
|
||||
defer currentState.Unlock()
|
||||
|
||||
// Clone the rule map so the persisted state holds a private snapshot. The
|
||||
// live map keeps being mutated by subsequent rule operations while the
|
||||
// state manager marshals the state from its periodic-save goroutine.
|
||||
// Sharing it by reference races the two and aborts the process with a
|
||||
// concurrent map iteration and write. The ipset counter guards itself
|
||||
// during marshaling, so it can be shared directly.
|
||||
if r.v6 {
|
||||
currentState.RouteRules6 = r.rules
|
||||
currentState.RouteRules6 = maps.Clone(r.rules)
|
||||
currentState.RouteIPsetCounter6 = r.ipsetCounter
|
||||
} else {
|
||||
currentState.RouteRules = r.rules
|
||||
currentState.RouteRules = maps.Clone(r.rules)
|
||||
currentState.RouteIPsetCounter = r.ipsetCounter
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package iptables
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
)
|
||||
|
||||
type ipList struct {
|
||||
ips map[string]struct{}
|
||||
@@ -19,6 +22,14 @@ func (s *ipList) addIP(ip string) {
|
||||
s.ips[ip] = struct{}{}
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the ipList with its own ips map.
|
||||
func (s *ipList) clone() *ipList {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return &ipList{ips: maps.Clone(s.ips)}
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler
|
||||
func (s *ipList) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
@@ -55,6 +66,19 @@ func newIpsetStore() *ipsetStore {
|
||||
}
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the ipsetStore with its own ipsets map and
|
||||
// independent ipList entries.
|
||||
func (s *ipsetStore) clone() *ipsetStore {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))}
|
||||
for name, list := range s.ipsets {
|
||||
cloned.ipsets[name] = list.clone()
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) {
|
||||
r, ok := s.ipsets[ipsetName]
|
||||
return r, ok
|
||||
|
||||
@@ -362,6 +362,10 @@ func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload
|
||||
return 0
|
||||
}
|
||||
|
||||
if pc := f.endpoint.capture.Load(); pc != nil {
|
||||
(*pc).Offer(fullPacket, true)
|
||||
}
|
||||
|
||||
return len(fullPacket)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ type ICEBind struct {
|
||||
*wgConn.StdNetBind
|
||||
|
||||
transportNet transport.Net
|
||||
filterFn udpmux.FilterFn
|
||||
address wgaddr.Address
|
||||
mtu uint16
|
||||
|
||||
@@ -61,12 +60,11 @@ type ICEBind struct {
|
||||
ipv6Conn *net.UDPConn
|
||||
}
|
||||
|
||||
func NewICEBind(transportNet transport.Net, filterFn udpmux.FilterFn, address wgaddr.Address, mtu uint16) *ICEBind {
|
||||
func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) *ICEBind {
|
||||
b, _ := wgConn.NewStdNetBind().(*wgConn.StdNetBind)
|
||||
ib := &ICEBind{
|
||||
StdNetBind: b,
|
||||
transportNet: transportNet,
|
||||
filterFn: filterFn,
|
||||
address: address,
|
||||
mtu: mtu,
|
||||
endpoints: make(map[netip.Addr]net.Conn),
|
||||
@@ -265,7 +263,6 @@ func (s *ICEBind) createOrUpdateMux() {
|
||||
udpmux.UniversalUDPMuxParams{
|
||||
UDPConn: muxConn,
|
||||
Net: s.transportNet,
|
||||
FilterFn: s.filterFn,
|
||||
WGAddress: s.address,
|
||||
MTU: s.mtu,
|
||||
},
|
||||
|
||||
@@ -289,7 +289,7 @@ func setupICEBind(t *testing.T) *ICEBind {
|
||||
IP: netip.MustParseAddr("100.64.0.1"),
|
||||
Network: netip.MustParsePrefix("100.64.0.0/10"),
|
||||
}
|
||||
return NewICEBind(transportNet, nil, address, 1280)
|
||||
return NewICEBind(transportNet, address, 1280)
|
||||
}
|
||||
|
||||
func createDualStackConns(t *testing.T) (*net.UDPConn, *net.UDPConn) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
|
||||
@@ -41,10 +44,13 @@ type PacketCapture interface {
|
||||
type FilteredDevice struct {
|
||||
tun.Device
|
||||
|
||||
filter PacketFilter
|
||||
capture atomic.Pointer[PacketCapture]
|
||||
mutex sync.RWMutex
|
||||
closeOnce sync.Once
|
||||
filter PacketFilter
|
||||
capture atomic.Pointer[PacketCapture]
|
||||
// panicHandler is invoked after a panic in the underlying device is
|
||||
// recovered in Read or Write.
|
||||
panicHandler atomic.Pointer[func()]
|
||||
mutex sync.RWMutex
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// newDeviceFilter constructor function
|
||||
@@ -70,7 +76,7 @@ func (d *FilteredDevice) Close() error {
|
||||
|
||||
// Read wraps read method with filtering feature
|
||||
func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) {
|
||||
if n, err = d.Device.Read(bufs, sizes, offset); err != nil {
|
||||
if n, err = d.deviceRead(bufs, sizes, offset); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -112,7 +118,7 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
|
||||
d.mutex.RUnlock()
|
||||
|
||||
if filter == nil {
|
||||
return d.Device.Write(bufs, offset)
|
||||
return d.deviceWrite(bufs, offset)
|
||||
}
|
||||
|
||||
filteredBufs := make([][]byte, 0, len(bufs))
|
||||
@@ -125,9 +131,44 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
n, err := d.Device.Write(filteredBufs, offset)
|
||||
n += dropped
|
||||
return n, err
|
||||
n, err := d.deviceWrite(filteredBufs, offset)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
return n + dropped, nil
|
||||
}
|
||||
|
||||
// deviceRead calls the underlying device Read, recovering from panics in the
|
||||
// wintun read path and converting them into errors.
|
||||
func (d *FilteredDevice) deviceRead(bufs [][]byte, sizes []int, offset int) (n int, err error) {
|
||||
defer d.recoverFromPanic("read", &n, &err)
|
||||
return d.Device.Read(bufs, sizes, offset)
|
||||
}
|
||||
|
||||
// deviceWrite calls the underlying device Write, recovering from panics in the
|
||||
// wintun write path and converting them into errors.
|
||||
func (d *FilteredDevice) deviceWrite(bufs [][]byte, offset int) (n int, err error) {
|
||||
defer d.recoverFromPanic("write", &n, &err)
|
||||
return d.Device.Write(bufs, offset)
|
||||
}
|
||||
|
||||
// recoverFromPanic converts a panic in the underlying device into a regular
|
||||
// error and invokes the registered panic handler. The wintun read path is
|
||||
// known to panic on zero-length packets that third-party filter drivers can
|
||||
// place in the ring.
|
||||
func (d *FilteredDevice) recoverFromPanic(op string, n *int, err *error) {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Errorf("recovered panic in tun device %s: %v\n%s", op, r, debug.Stack())
|
||||
*n = 0
|
||||
*err = fmt.Errorf("tun device %s panic: %v", op, r)
|
||||
|
||||
if handler := d.panicHandler.Load(); handler != nil {
|
||||
(*handler)()
|
||||
}
|
||||
}
|
||||
|
||||
// SetFilter sets packet filter to device
|
||||
@@ -137,6 +178,17 @@ func (d *FilteredDevice) SetFilter(filter PacketFilter) {
|
||||
d.mutex.Unlock()
|
||||
}
|
||||
|
||||
// SetPanicHandler registers a handler invoked after a recovered panic in Read
|
||||
// or Write. The device is unusable after such a panic; the handler should
|
||||
// trigger recreation of the interface. Pass nil to remove.
|
||||
func (d *FilteredDevice) SetPanicHandler(handler func()) {
|
||||
if handler == nil {
|
||||
d.panicHandler.Store(nil)
|
||||
return
|
||||
}
|
||||
d.panicHandler.Store(&handler)
|
||||
}
|
||||
|
||||
// SetCapture sets or clears the packet capture sink. Pass nil to disable.
|
||||
// Uses atomic store so the hot path (Read/Write) is a single pointer load
|
||||
// with no locking overhead when capture is off.
|
||||
|
||||
@@ -221,3 +221,60 @@ func TestDeviceWrapperRead(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceWrapperReadPanic(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
tun := mocks.NewMockDevice(ctrl)
|
||||
tun.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(bufs [][]byte, sizes []int, offset int) (int, error) {
|
||||
// Reproduce the wintun zero-length packet panic (index out of range).
|
||||
packet := make([]byte, 0)
|
||||
return int(packet[0]), nil
|
||||
})
|
||||
|
||||
wrapped := newDeviceFilter(tun)
|
||||
|
||||
handlerCalled := false
|
||||
wrapped.SetPanicHandler(func() { handlerCalled = true })
|
||||
|
||||
n, err := wrapped.Read([][]byte{{}}, []int{0}, 0)
|
||||
if err == nil {
|
||||
t.Errorf("expected error from recovered panic, got nil")
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected n=0, got %d", n)
|
||||
}
|
||||
if !handlerCalled {
|
||||
t.Errorf("expected panic handler to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceWrapperWritePanic(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
tun := mocks.NewMockDevice(ctrl)
|
||||
tun.EXPECT().Write(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(bufs [][]byte, offset int) (int, error) {
|
||||
packet := make([]byte, 0)
|
||||
return int(packet[0]), nil
|
||||
})
|
||||
|
||||
wrapped := newDeviceFilter(tun)
|
||||
|
||||
handlerCalled := false
|
||||
wrapped.SetPanicHandler(func() { handlerCalled = true })
|
||||
|
||||
n, err := wrapped.Write([][]byte{{0x45, 0x00}}, 0)
|
||||
if err == nil {
|
||||
t.Errorf("expected error from recovered panic, got nil")
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected n=0, got %d", n)
|
||||
}
|
||||
if !handlerCalled {
|
||||
t.Errorf("expected panic handler to be called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,6 @@ type TunKernelDevice struct {
|
||||
link *wgLink
|
||||
udpMuxConn net.PacketConn
|
||||
udpMux *udpmux.UniversalUDPMuxDefault
|
||||
|
||||
filterFn udpmux.FilterFn
|
||||
}
|
||||
|
||||
func NewKernelDevice(name string, address wgaddr.Address, wgPort int, key string, mtu uint16, transportNet transport.Net) *TunKernelDevice {
|
||||
@@ -104,7 +102,6 @@ func (t *TunKernelDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
||||
bindParams := udpmux.UniversalUDPMuxParams{
|
||||
UDPConn: nbnet.WrapPacketConn(rawSock),
|
||||
Net: t.transportNet,
|
||||
FilterFn: t.filterFn,
|
||||
WGAddress: t.address,
|
||||
MTU: t.mtu,
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ type WGIFaceOpts struct {
|
||||
MTU uint16
|
||||
MobileArgs *device.MobileIFaceArguments
|
||||
TransportNet transport.Net
|
||||
FilterFn udpmux.FilterFn
|
||||
DisableDNS bool
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// NewWGIFace Creates a new WireGuard interface instance
|
||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
|
||||
|
||||
var tun WGTunDevice
|
||||
if netstack.IsEnabled() {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// NewWGIFace Creates a new WireGuard interface instance
|
||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
|
||||
|
||||
if netstack.IsEnabled() {
|
||||
wgIFace := &WGIface{
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
// NewWGIFace Creates a new WireGuard interface instance
|
||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
|
||||
|
||||
wgIFace := &WGIface{
|
||||
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunFd),
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// NewWGIFace Creates a new WireGuard interface instance
|
||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
||||
if netstack.IsEnabled() {
|
||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
|
||||
return &WGIface{
|
||||
tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()),
|
||||
userspaceBind: true,
|
||||
@@ -30,7 +30,7 @@ func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
||||
}
|
||||
|
||||
if device.ModuleTunIsLoaded() {
|
||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
|
||||
return &WGIface{
|
||||
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind),
|
||||
userspaceBind: true,
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -22,10 +20,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
|
||||
// FilterFn is a function that filters out candidates based on the address.
|
||||
// If it returns true, the address is to be filtered. It also returns the prefix of matching route.
|
||||
type FilterFn func(address netip.Addr) (bool, netip.Prefix, error)
|
||||
|
||||
// UniversalUDPMuxDefault handles STUN and TURN servers packets by wrapping the original UDPConn
|
||||
// It then passes packets to the UDPMux that does the actual connection muxing.
|
||||
type UniversalUDPMuxDefault struct {
|
||||
@@ -43,7 +37,6 @@ type UniversalUDPMuxParams struct {
|
||||
UDPConn net.PacketConn
|
||||
XORMappedAddrCacheTTL time.Duration
|
||||
Net transport.Net
|
||||
FilterFn FilterFn
|
||||
WGAddress wgaddr.Address
|
||||
MTU uint16
|
||||
}
|
||||
@@ -68,7 +61,6 @@ func NewUniversalUDPMuxDefault(params UniversalUDPMuxParams) *UniversalUDPMuxDef
|
||||
PacketConn: params.UDPConn,
|
||||
mux: m,
|
||||
logger: params.Logger,
|
||||
filterFn: params.FilterFn,
|
||||
address: params.WGAddress,
|
||||
}
|
||||
|
||||
@@ -115,15 +107,12 @@ func (m *UniversalUDPMuxDefault) ReadFromConn(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// UDPConn is a wrapper around UDPMux conn that overrides ReadFrom and handles STUN/TURN packets
|
||||
// UDPConn is a wrapper around UDPMux conn that overrides WriteTo to drop packets destined for the overlay subnet.
|
||||
type UDPConn struct {
|
||||
net.PacketConn
|
||||
mux *UniversalUDPMuxDefault
|
||||
logger logging.LeveledLogger
|
||||
filterFn FilterFn
|
||||
// TODO: reset cache on route changes
|
||||
addrCache sync.Map
|
||||
address wgaddr.Address
|
||||
mux *UniversalUDPMuxDefault
|
||||
logger logging.LeveledLogger
|
||||
address wgaddr.Address
|
||||
}
|
||||
|
||||
// GetPacketConn returns the underlying PacketConn
|
||||
@@ -132,65 +121,16 @@ func (u *UDPConn) GetPacketConn() net.PacketConn {
|
||||
}
|
||||
|
||||
func (u *UDPConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
if u.filterFn == nil {
|
||||
udpAddr, ok := addr.(*net.UDPAddr)
|
||||
if !ok {
|
||||
return u.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
|
||||
if isRouted, found := u.addrCache.Load(addr.String()); found {
|
||||
return u.handleCachedAddress(isRouted.(bool), b, addr)
|
||||
}
|
||||
|
||||
return u.handleUncachedAddress(b, addr)
|
||||
}
|
||||
|
||||
func (u *UDPConn) handleCachedAddress(isRouted bool, b []byte, addr net.Addr) (int, error) {
|
||||
if isRouted {
|
||||
return 0, fmt.Errorf("address %s is part of a routed network, refusing to write", addr)
|
||||
}
|
||||
return u.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
|
||||
func (u *UDPConn) handleUncachedAddress(b []byte, addr net.Addr) (int, error) {
|
||||
if err := u.performFilterCheck(addr); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return u.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
|
||||
func (u *UDPConn) performFilterCheck(addr net.Addr) error {
|
||||
host, err := getHostFromAddr(addr)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get host from address %s: %v", addr, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
a, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to parse address %s: %v", addr, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if u.address.Network.Contains(a) {
|
||||
dst := udpAddr.AddrPort().Addr().Unmap()
|
||||
if (u.address.Network.IsValid() && u.address.Network.Contains(dst)) || (u.address.IPv6Net.IsValid() && u.address.IPv6Net.Contains(dst)) {
|
||||
log.Warnf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
|
||||
return fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
|
||||
return 0, fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
|
||||
}
|
||||
|
||||
if isRouted, prefix, err := u.filterFn(a); err != nil {
|
||||
log.Errorf("Failed to check if address %s is routed: %v", addr, err)
|
||||
} else {
|
||||
u.addrCache.Store(addr.String(), isRouted)
|
||||
if isRouted {
|
||||
// Extra log, as the error only shows up with ICE logging enabled
|
||||
log.Infof("address %s is part of routed network %s, refusing to write", addr, prefix)
|
||||
return fmt.Errorf("address %s is part of routed network %s, refusing to write", addr, prefix)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getHostFromAddr(addr net.Addr) (string, error) {
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
return host, err
|
||||
return u.PacketConn.WriteTo(b, addr)
|
||||
}
|
||||
|
||||
// GetSharedConn returns the shared udp conn
|
||||
@@ -225,6 +165,13 @@ func (m *UniversalUDPMuxDefault) HandleSTUNMessage(msg *stun.Message, addr net.A
|
||||
return nil
|
||||
}
|
||||
|
||||
src := udpAddr.AddrPort().Addr().Unmap()
|
||||
wg := m.params.WGAddress
|
||||
if (wg.Network.IsValid() && wg.Network.Contains(src)) || (wg.IPv6Net.IsValid() && wg.IPv6Net.Contains(src)) {
|
||||
log.Debugf("dropping STUN message from overlay source %s", udpAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.isXORMappedResponse(msg, udpAddr.String()) {
|
||||
err := m.handleXORMappedResponse(udpAddr, msg)
|
||||
if err != nil {
|
||||
|
||||
@@ -66,7 +66,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280)
|
||||
iceBind := bind.NewICEBind(nil, wgAddress, 1280)
|
||||
endpointAddress := &net.UDPAddr{
|
||||
IP: net.IPv4(10, 0, 0, 1),
|
||||
Port: 1234,
|
||||
|
||||
@@ -22,7 +22,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280)
|
||||
iceBind := bind.NewICEBind(nil, wgAddress, 1280)
|
||||
endpointAddress := &net.UDPAddr{
|
||||
IP: net.IPv4(10, 0, 0, 1),
|
||||
Port: 1234,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
!define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls."
|
||||
!define INSTALLER_NAME "netbird-installer.exe"
|
||||
!define MAIN_APP_EXE "Netbird"
|
||||
!define ICON "ui\\build\\windows\\icon.ico"
|
||||
!define ICON "ui\\assets\\netbird.ico"
|
||||
!define BANNER "ui\\build\\banner.bmp"
|
||||
!define LICENSE_DATA "..\\LICENSE"
|
||||
|
||||
@@ -280,43 +280,6 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
||||
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
||||
SectionEnd
|
||||
|
||||
# Install the Microsoft Edge WebView2 runtime if it isn't already present.
|
||||
# Macro adapted from Wails3's NSIS template (wails_tools.nsh): a registry
|
||||
# probe followed by a silent install of the embedded evergreen bootstrapper.
|
||||
# The MicrosoftEdgeWebview2Setup.exe payload is staged next to this script
|
||||
# by the sign-pipelines build step (`wails3 generate webview2bootstrapper`).
|
||||
!macro nb.webview2runtime
|
||||
SetRegView 64
|
||||
# Per-machine install marker — populated when the runtime ships with
|
||||
# Edge or has been installed by an admin previously.
|
||||
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto webview2_ok
|
||||
${EndIf}
|
||||
# Per-user fallback for HKCU installs.
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto webview2_ok
|
||||
${EndIf}
|
||||
|
||||
SetDetailsPrint both
|
||||
DetailPrint "Installing: WebView2 Runtime"
|
||||
SetDetailsPrint listonly
|
||||
|
||||
InitPluginsDir
|
||||
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||
File "MicrosoftEdgeWebview2Setup.exe"
|
||||
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||
|
||||
SetDetailsPrint both
|
||||
webview2_ok:
|
||||
!macroend
|
||||
|
||||
Section -WebView2
|
||||
!insertmacro nb.webview2runtime
|
||||
SectionEnd
|
||||
|
||||
Section -Post
|
||||
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
|
||||
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
|
||||
@@ -363,9 +326,9 @@ DetailPrint "Deleting application files..."
|
||||
Delete "$INSTDIR\${UI_APP_EXE}"
|
||||
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
||||
Delete "$INSTDIR\wintun.dll"
|
||||
# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove
|
||||
# any leftover copy on uninstall so old upgrades don't leave it behind.
|
||||
!if ${ARCH} == "amd64"
|
||||
Delete "$INSTDIR\opengl32.dll"
|
||||
!endif
|
||||
DetailPrint "Removing application directory..."
|
||||
RmDir /r "$INSTDIR"
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -22,25 +21,6 @@ import (
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// peerLoginExpiredMsg is the exact phrase the management server returns
|
||||
// when a previously SSO-enrolled peer's login has expired. Sourced from
|
||||
// shared/management/status/error.go (NewPeerLoginExpiredError). Matched
|
||||
// by substring so a future server-side rewording that keeps the phrase
|
||||
// still triggers the friendly fallback in Login().
|
||||
const peerLoginExpiredMsg = "peer login has expired"
|
||||
|
||||
// errSetupKeyOnSSOExpiredPeer replaces the raw management error when the
|
||||
// user runs `netbird login -k <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
|
||||
// It maintains a long-lived connection and automatically handles reconnection with backoff
|
||||
type Auth struct {
|
||||
@@ -204,15 +184,6 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
||||
log.Debugf("peer registration required")
|
||||
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
|
||||
if err != nil {
|
||||
// The peer pub-key is already on file with the management
|
||||
// server (originally enrolled via SSO) and the session has
|
||||
// expired. The setup-key path can only enrol new peers, so
|
||||
// retrying with -k will keep failing. Replace the raw mgm
|
||||
// message with an actionable hint that tells the user to
|
||||
// re-authenticate via SSO instead.
|
||||
if setupKey != "" && jwtToken == "" && isPeerLoginExpired(err) {
|
||||
err = errSetupKeyOnSSOExpiredPeer
|
||||
}
|
||||
isAuthError = isPermissionDenied(err)
|
||||
return err
|
||||
}
|
||||
@@ -503,16 +474,3 @@ func isLoginNeeded(err error) bool {
|
||||
func isRegistrationNeeded(err error) bool {
|
||||
return isPermissionDenied(err)
|
||||
}
|
||||
|
||||
// isPeerLoginExpired reports whether err is the management server's
|
||||
// "peer login has expired" PermissionDenied response. Used by Login to
|
||||
// detect the case where the caller passed a setup-key but the peer is
|
||||
// actually an SSO-enrolled record whose session needs refreshing — the
|
||||
// setup-key path cannot help there.
|
||||
func isPeerLoginExpired(err error) bool {
|
||||
if !isPermissionDenied(err) {
|
||||
return false
|
||||
}
|
||||
s, _ := status.FromError(err)
|
||||
return strings.Contains(s.Message(), peerLoginExpiredMsg)
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestIsPeerLoginExpired(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain error (not a gRPC status)",
|
||||
err: errors.New("network read: connection reset"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "PermissionDenied with different message",
|
||||
err: status.Error(codes.PermissionDenied, "user is blocked"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Unauthenticated with the expected phrase",
|
||||
// Wrong status code — must still return false.
|
||||
err: status.Error(codes.Unauthenticated, "peer login has expired, please log in once more"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exact server message",
|
||||
err: status.Error(codes.PermissionDenied, "peer login has expired, please log in once more"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "phrase as substring",
|
||||
// Future-proofing: if mgm reworords but keeps the phrase,
|
||||
// the friendly fallback must still kick in.
|
||||
err: status.Error(codes.PermissionDenied, "session refused: peer login has expired (account=foo)"),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isPeerLoginExpired(tc.err); got != tc.want {
|
||||
t.Fatalf("isPeerLoginExpired(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrSetupKeyOnSSOExpiredPeer(t *testing.T) {
|
||||
// Sentinel must surface as PermissionDenied so the upstream
|
||||
// isPermissionDenied / isAuthError checks classify it correctly
|
||||
// (short-circuit retry backoff, set StatusNeedsLogin).
|
||||
if !isPermissionDenied(errSetupKeyOnSSOExpiredPeer) {
|
||||
t.Fatalf("errSetupKeyOnSSOExpiredPeer must be a PermissionDenied gRPC error")
|
||||
}
|
||||
|
||||
// Message must actually mention SSO and `netbird up` so it is
|
||||
// actionable for the end user. Loose substring checks keep the
|
||||
// test resilient to copy edits.
|
||||
s, _ := status.FromError(errSetupKeyOnSSOExpiredPeer)
|
||||
msg := strings.ToLower(s.Message())
|
||||
for _, want := range []string{"sso", "netbird up"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("sentinel message should contain %q, got %q", want, s.Message())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PendingFlow stores an in-progress OAuth flow between the RPC that
|
||||
// initiates it (returns the verification URI to the UI) and the RPC
|
||||
// that waits for the user to complete it. The flow handle, the
|
||||
// device-code info, and the absolute expiry are kept together so the
|
||||
// waiting RPC can validate the device code and reuse the same flow.
|
||||
//
|
||||
// PendingFlow is safe for concurrent use; callers must not access the
|
||||
// stored fields directly.
|
||||
type PendingFlow struct {
|
||||
mu sync.Mutex
|
||||
flow OAuthFlow
|
||||
info AuthFlowInfo
|
||||
expiresAt time.Time
|
||||
waitCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewPendingFlow returns an empty PendingFlow ready to be populated by Set.
|
||||
func NewPendingFlow() *PendingFlow {
|
||||
return &PendingFlow{}
|
||||
}
|
||||
|
||||
// Set stores the flow and its authorization info, computing the absolute
|
||||
// expiry from info.ExpiresIn (seconds, as returned by the IdP).
|
||||
func (p *PendingFlow) Set(flow OAuthFlow, info AuthFlowInfo) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.flow = flow
|
||||
p.info = info
|
||||
p.expiresAt = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second)
|
||||
}
|
||||
|
||||
// Get returns the stored flow, info, and whether a flow is currently
|
||||
// pending. Returns (nil, zero, false) after Clear or before Set.
|
||||
func (p *PendingFlow) Get() (OAuthFlow, AuthFlowInfo, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.flow == nil {
|
||||
return nil, AuthFlowInfo{}, false
|
||||
}
|
||||
return p.flow, p.info, true
|
||||
}
|
||||
|
||||
// ExpiresAt returns the absolute expiry of the pending flow. Returns
|
||||
// the zero time when no flow is pending.
|
||||
func (p *PendingFlow) ExpiresAt() time.Time {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.expiresAt
|
||||
}
|
||||
|
||||
// SetWaitCancel records the cancel function for the goroutine currently
|
||||
// blocked in WaitToken so a new RequestAuth can preempt it.
|
||||
func (p *PendingFlow) SetWaitCancel(cancel context.CancelFunc) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.waitCancel = cancel
|
||||
}
|
||||
|
||||
// CancelWait invokes and clears the stored wait-cancel, if any. Safe to
|
||||
// call when no wait is in progress.
|
||||
func (p *PendingFlow) CancelWait() {
|
||||
p.mu.Lock()
|
||||
cancel := p.waitCancel
|
||||
p.waitCancel = nil
|
||||
p.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Clear resets the pending flow to empty. Any stored wait-cancel is
|
||||
// dropped without being invoked — call CancelWait first if the waiting
|
||||
// goroutine must be stopped.
|
||||
func (p *PendingFlow) Clear() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.flow = nil
|
||||
p.info = AuthFlowInfo{}
|
||||
p.expiresAt = time.Time{}
|
||||
p.waitCancel = nil
|
||||
}
|
||||
@@ -1,74 +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"
|
||||
)
|
||||
|
||||
// expiresAtLayout is the wire format used for MetaSessionExpiresAt.
|
||||
// Producer and consumers both go through FormatExpiresAt/ParseExpiresAt
|
||||
// so this layout stays a single source of truth.
|
||||
const expiresAtLayout = time.RFC3339
|
||||
|
||||
// FormatExpiresAt encodes a deadline for MetaSessionExpiresAt. Always
|
||||
// emits UTC so a consumer in another timezone reads the same wall-clock
|
||||
// deadline.
|
||||
func FormatExpiresAt(t time.Time) string {
|
||||
return t.UTC().Format(expiresAtLayout)
|
||||
}
|
||||
|
||||
// ParseExpiresAt decodes the MetaSessionExpiresAt value back to a UTC
|
||||
// time. Returns an error when the field is empty or malformed; the
|
||||
// caller decides whether to fall back (zero value) or propagate.
|
||||
func ParseExpiresAt(s string) (time.Time, error) {
|
||||
t, err := time.Parse(expiresAtLayout, s)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return t.UTC(), nil
|
||||
}
|
||||
|
||||
// FormatLeadMinutes encodes a lead duration for MetaSessionLeadMinutes
|
||||
// as the integer count of whole minutes. Sub-minute residuals are
|
||||
// truncated — the field is informational ("expires in ~N minutes") and
|
||||
// fractional minutes don't change what the UI displays.
|
||||
func FormatLeadMinutes(d time.Duration) string {
|
||||
return strconv.Itoa(int(d / time.Minute))
|
||||
}
|
||||
|
||||
// ParseLeadMinutes decodes a MetaSessionLeadMinutes value. Returns 0
|
||||
// and the parse error for malformed input; consumers that prefer a
|
||||
// silent fallback can simply ignore the error.
|
||||
func ParseLeadMinutes(s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
// Package sessionwatch tracks the SSO session expiry deadline that the
|
||||
// management server publishes via LoginResponse / SyncResponse and fires
|
||||
// two warning events at fixed lead times before expiry: an interactive
|
||||
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
|
||||
// fallback dialog.
|
||||
//
|
||||
// The watcher is idempotent: Update may be called as often as the network
|
||||
// map snapshots arrive. Repeating the same deadline is a no-op; a new
|
||||
// deadline reschedules the timers and arms a fresh warning cycle.
|
||||
//
|
||||
// Warning firing is edge-detected. Each unique deadline value fires each
|
||||
// warning callback at most once.
|
||||
package sessionwatch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
// Skew tolerates a small clock difference between the management
|
||||
// server and this peer before treating a deadline as "in the past".
|
||||
// Slightly above typical NTP drift; tight enough that the UI doesn't
|
||||
// paint a stale expiry as if it were valid.
|
||||
Skew = 30 * time.Second
|
||||
|
||||
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||
// glitch, and silently arming a 100-year timer would hide the bug.
|
||||
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
|
||||
|
||||
// WarningLead is how far before expiry the first (interactive)
|
||||
// warning fires. Drives the T-10 OS notification with
|
||||
// Extend/Dismiss actions.
|
||||
WarningLead = 10 * time.Minute
|
||||
|
||||
// FinalWarningLead is how far before expiry the fallback final
|
||||
// warning fires. Drives the auto-opened SessionAboutToExpire dialog,
|
||||
// but only when the user has not dismissed the T-WarningLead warning
|
||||
// for the same deadline. Must be strictly less than WarningLead.
|
||||
FinalWarningLead = 2 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDeadlineBeforeEpoch is returned by Update when the supplied
|
||||
// deadline pre-dates 1970-01-01.
|
||||
ErrDeadlineBeforeEpoch = errors.New("session deadline before unix epoch")
|
||||
|
||||
// ErrDeadlineTooFarFuture is returned by Update when the supplied
|
||||
// deadline is more than maxDeadlineHorizon in the future.
|
||||
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
|
||||
|
||||
// ErrDeadlineInPast is returned by Update when the supplied deadline
|
||||
// is more than Skew in the past.
|
||||
ErrDeadlineInPast = errors.New("session deadline in the past")
|
||||
)
|
||||
|
||||
// StatusRecorder is the side-effect surface the watcher drives on every
|
||||
// state transition. Production wires this to peer.Status (SetSessionExpiresAt
|
||||
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
||||
// a fake recorder so the same surface is observable without an engine.
|
||||
//
|
||||
// The watcher is the single owner of the deadline propagated to the
|
||||
// recorder: every set, clear, sanity-check rejection and Close routes the
|
||||
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
|
||||
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
|
||||
// fans out its own state-change notification, so no separate notify is
|
||||
// needed.) The recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
|
||||
// a profile switch) would leave the next session showing the previous one's
|
||||
// stale "expires in" value.
|
||||
//
|
||||
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
|
||||
// composes the metadata internally so the wire format (MetaSession*) is
|
||||
// owned by sessionwatch, not the caller.
|
||||
type StatusRecorder interface {
|
||||
SetSessionExpiresAt(deadline time.Time)
|
||||
PublishEvent(
|
||||
severity cProto.SystemEvent_Severity,
|
||||
category cProto.SystemEvent_Category,
|
||||
message string,
|
||||
userMessage string,
|
||||
metadata map[string]string,
|
||||
)
|
||||
}
|
||||
|
||||
// Watcher observes the latest session deadline and fires two warnings
|
||||
// before it expires: the interactive T-WarningLead notification, and the
|
||||
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
|
||||
// the first one for the same deadline). Safe for concurrent use.
|
||||
type Watcher struct {
|
||||
lead time.Duration
|
||||
finalLead time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
current time.Time
|
||||
timer *time.Timer
|
||||
finalTimer *time.Timer
|
||||
firedAt time.Time // deadline value the T-WarningLead callback last fired against
|
||||
finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against
|
||||
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
|
||||
closed bool
|
||||
recorder StatusRecorder
|
||||
}
|
||||
|
||||
// New returns a watcher with the package defaults WarningLead and
|
||||
// FinalWarningLead. Pass nil for recorder to silence side effects (handy
|
||||
// in unit tests that exercise sanity checks without observing the publish
|
||||
// path).
|
||||
func New(recorder StatusRecorder) *Watcher {
|
||||
return NewWithLeads(WarningLead, FinalWarningLead, recorder)
|
||||
}
|
||||
|
||||
// NewWithLeads returns a watcher with custom lead times. Useful for tests.
|
||||
// final must be strictly less than lead; otherwise both timers fire in the
|
||||
// wrong order or simultaneously and the UI flow breaks. A zero final lead
|
||||
// disables the final-warning timer entirely (see armTimerLocked) so a
|
||||
// millisecond-scale deadline doesn't flush both timers in one tick.
|
||||
func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||
return &Watcher{
|
||||
lead: lead,
|
||||
finalLead: final,
|
||||
recorder: recorder,
|
||||
}
|
||||
}
|
||||
|
||||
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
|
||||
// a Sync push from the server omits the field because login expiration
|
||||
// was disabled).
|
||||
//
|
||||
// Same-value updates are no-ops. A different non-zero value cancels any
|
||||
// pending timer, resets the "already fired" guard, and arms a new one.
|
||||
//
|
||||
// Returns one of the sentinel Err* values when the deadline fails the
|
||||
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
|
||||
// In every error case the watcher first clears its state so it stays
|
||||
// consistent with what the caller will push into its other sinks (e.g.
|
||||
// applySessionDeadline forces a zero deadline into the status recorder
|
||||
// after a non-nil error).
|
||||
func (w *Watcher) Update(deadline time.Time) error {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
if deadline.IsZero() {
|
||||
w.clearLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch {
|
||||
case deadline.Before(time.Unix(0, 0)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v", ErrDeadlineBeforeEpoch, deadline)
|
||||
case deadline.After(now.Add(maxDeadlineHorizon)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
|
||||
case deadline.Before(now.Add(-Skew)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
|
||||
}
|
||||
|
||||
if deadline.Equal(w.current) {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
w.stopTimerLocked()
|
||||
w.current = deadline
|
||||
// Reset every per-deadline guard so a refreshed deadline arms a fresh
|
||||
// warning cycle: both edge triggers and the user Dismiss decision
|
||||
// (the user agreed to the old deadline expiring; a new deadline
|
||||
// restarts the contract).
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
|
||||
w.armTimerLocked(deadline)
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil {
|
||||
recorder.SetSessionExpiresAt(deadline)
|
||||
}
|
||||
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deadline returns the most recently observed deadline. Zero when no
|
||||
// deadline is currently tracked.
|
||||
func (w *Watcher) Deadline() time.Time {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.current
|
||||
}
|
||||
|
||||
// Dismiss records the user's "Dismiss" action against the current deadline
|
||||
// and suppresses the upcoming final-warning callback for that deadline.
|
||||
// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
|
||||
// deadline resets the dismissal so the final-warning cycle re-arms.
|
||||
//
|
||||
// No-op when the watcher holds no deadline or has been closed.
|
||||
func (w *Watcher) Dismiss() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed || w.current.IsZero() {
|
||||
return
|
||||
}
|
||||
if w.dismissedAt.Equal(w.current) {
|
||||
return
|
||||
}
|
||||
w.dismissedAt = w.current
|
||||
// Cancel the armed final-warning timer eagerly. fireFinal would also
|
||||
// gate on dismissedAt, but stopping the timer avoids a wakeup with
|
||||
// nothing to do and makes the intent visible.
|
||||
if w.finalTimer != nil {
|
||||
w.finalTimer.Stop()
|
||||
w.finalTimer = nil
|
||||
}
|
||||
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Close stops any pending timer and drops the deadline on the status
|
||||
// recorder. Update calls after Close are ignored. Clearing the recorder
|
||||
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
|
||||
// from leaving the next session showing this one's stale "expires in"
|
||||
// value — the recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher, so nothing else drops the anchor on teardown.
|
||||
func (w *Watcher) Close() {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
w.stopTimerLocked()
|
||||
hadDeadline := !w.current.IsZero()
|
||||
w.current = time.Time{}
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil && hadDeadline {
|
||||
recorder.SetSessionExpiresAt(time.Time{})
|
||||
}
|
||||
}
|
||||
|
||||
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||
// downstream consumers (SubscribeStatus stream, UI) drop their anchor.
|
||||
// The caller must hold w.mu; this helper releases it before invoking
|
||||
// the recorder.
|
||||
func (w *Watcher) clearLocked() {
|
||||
if w.current.IsZero() {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.stopTimerLocked()
|
||||
w.current = time.Time{}
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil {
|
||||
recorder.SetSessionExpiresAt(time.Time{})
|
||||
}
|
||||
log.Infof("auth session deadline cleared")
|
||||
}
|
||||
|
||||
func (w *Watcher) stopTimerLocked() {
|
||||
if w.timer != nil {
|
||||
w.timer.Stop()
|
||||
w.timer = nil
|
||||
}
|
||||
if w.finalTimer != nil {
|
||||
w.finalTimer.Stop()
|
||||
w.finalTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) armTimerLocked(deadline time.Time) {
|
||||
w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) })
|
||||
// finalLead <= 0 disables the final-warning timer entirely. Used by
|
||||
// tests that predate the final-warning fallback so a millisecond-scale
|
||||
// deadline does not flush both timers at once.
|
||||
if w.finalLead > 0 {
|
||||
w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) })
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) fire(armedFor time.Time) {
|
||||
w.mu.Lock()
|
||||
if w.closed || !w.current.Equal(armedFor) {
|
||||
// Deadline moved while we were waiting (e.g. a successful extend).
|
||||
// The reschedule path armed a fresh timer; this one is stale.
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.firedAt = armedFor
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
log.Infof("auth session expiry soon warning fired")
|
||||
publishWarning(recorder, armedFor, false)
|
||||
}
|
||||
|
||||
// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra
|
||||
// dismiss-gate: if the user dismissed the T-WarningLead notification for
|
||||
// this deadline, the final warning is suppressed entirely.
|
||||
func (w *Watcher) fireFinal(armedFor time.Time) {
|
||||
w.mu.Lock()
|
||||
if w.closed || !w.current.Equal(armedFor) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if w.dismissedAt.Equal(armedFor) {
|
||||
w.mu.Unlock()
|
||||
log.Infof("auth session final-warning skipped (dismissed by user)")
|
||||
return
|
||||
}
|
||||
w.finalFiredAt = armedFor
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
log.Infof("auth session final-warning fired")
|
||||
publishWarning(recorder, armedFor, true)
|
||||
}
|
||||
|
||||
// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
|
||||
// past it dispatches on the next scheduler tick so a state-change recorder
|
||||
// notification (invoked after w.mu is released) lands first. Caller must
|
||||
// hold w.mu.
|
||||
func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer {
|
||||
delay := time.Until(fireAt)
|
||||
if delay <= 0 {
|
||||
return time.AfterFunc(0, cb)
|
||||
}
|
||||
return time.AfterFunc(delay, cb)
|
||||
}
|
||||
|
||||
// publishWarning composes the SystemEvent for a watcher-fired warning and
|
||||
// pushes it through the recorder. Severity is CRITICAL on both — bypassing
|
||||
// the user's Notifications toggle is deliberate: missing the warning
|
||||
// window forces the post-mortem SessionExpired flow (tunnel torn down,
|
||||
// lock icon, manual re-login), which is the UX we are trying to avoid.
|
||||
func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
|
||||
lead := WarningLead
|
||||
message := "session expiry warning"
|
||||
meta := map[string]string{
|
||||
MetaSessionWarning: "true",
|
||||
MetaSessionExpiresAt: FormatExpiresAt(deadline),
|
||||
}
|
||||
if final {
|
||||
lead = FinalWarningLead
|
||||
message = "session expiry final warning"
|
||||
meta[MetaSessionFinal] = "true"
|
||||
}
|
||||
meta[MetaSessionLeadMinutes] = FormatLeadMinutes(lead)
|
||||
|
||||
recorder.PublishEvent(
|
||||
cProto.SystemEvent_CRITICAL,
|
||||
cProto.SystemEvent_AUTHENTICATION,
|
||||
message,
|
||||
"",
|
||||
meta,
|
||||
)
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
package sessionwatch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// fakeRecorder satisfies StatusRecorder and records every call so tests
|
||||
// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent
|
||||
// land in the same ordered events slice (with the Kind distinguishing
|
||||
// them) so tests that care about ordering still work. lastDeadline holds
|
||||
// the most recent value passed to SetSessionExpiresAt so tests can assert
|
||||
// the recorder ended up cleared/set as expected.
|
||||
type fakeRecorder struct {
|
||||
mu sync.Mutex
|
||||
events []event
|
||||
lastDeadline time.Time
|
||||
}
|
||||
|
||||
type eventKind int
|
||||
|
||||
const (
|
||||
stateChange eventKind = iota
|
||||
publish
|
||||
)
|
||||
|
||||
type event struct {
|
||||
kind eventKind
|
||||
// Set only for publish events.
|
||||
severity cProto.SystemEvent_Severity
|
||||
category cProto.SystemEvent_Category
|
||||
message string
|
||||
meta map[string]string
|
||||
}
|
||||
|
||||
// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op,
|
||||
// a real change records the new value and fans out a state-change (the
|
||||
// production recorder calls notifyStateChange internally). The baseline
|
||||
// is the zero time, so an initial clear before any deadline is set emits
|
||||
// nothing — matching the real recorder.
|
||||
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.lastDeadline.Equal(deadline) {
|
||||
return
|
||||
}
|
||||
r.lastDeadline = deadline
|
||||
r.events = append(r.events, event{kind: stateChange})
|
||||
}
|
||||
|
||||
func (r *fakeRecorder) deadline() time.Time {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.lastDeadline
|
||||
}
|
||||
|
||||
func (r *fakeRecorder) PublishEvent(
|
||||
severity cProto.SystemEvent_Severity,
|
||||
category cProto.SystemEvent_Category,
|
||||
message string,
|
||||
_ string,
|
||||
metadata map[string]string,
|
||||
) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.events = append(r.events, event{
|
||||
kind: publish,
|
||||
severity: severity,
|
||||
category: category,
|
||||
message: message,
|
||||
meta: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *fakeRecorder) snapshot() []event {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]event, len(r.events))
|
||||
copy(out, r.events)
|
||||
return out
|
||||
}
|
||||
|
||||
func (e event) isFinalWarning() bool {
|
||||
return e.kind == publish && e.meta[MetaSessionFinal] == "true"
|
||||
}
|
||||
|
||||
func (e event) isWarning() bool {
|
||||
return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true"
|
||||
}
|
||||
|
||||
func countWhere(events []event, pred func(event) bool) int {
|
||||
n := 0
|
||||
for _, e := range events {
|
||||
if pred(e) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if got := r.snapshot(); len(got) >= want {
|
||||
return got
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
got := r.snapshot()
|
||||
t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got)
|
||||
return nil
|
||||
}
|
||||
|
||||
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
|
||||
// matching the lead-only behaviour the pre-final-warning tests assume.
|
||||
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
||||
return NewWithLeads(lead, 0, r)
|
||||
}
|
||||
|
||||
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
_ = w.Update(time.Time{})
|
||||
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events on initial zero, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNonZeroFiresStateChange(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(time.Hour)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 1)
|
||||
if events[0].kind != stateChange {
|
||||
t.Fatalf("expected stateChange, got %+v", events[0])
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameDeadlineIsNoop(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(time.Hour)
|
||||
_ = w.Update(d)
|
||||
_ = w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 1)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
lead := 50 * time.Millisecond
|
||||
w := newWatcher(lead, r)
|
||||
defer w.Close()
|
||||
|
||||
// Deadline 80ms out — warning should fire after ~30ms.
|
||||
d := time.Now().Add(80 * time.Millisecond)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[0].kind != stateChange {
|
||||
t.Fatalf("event[0] should be stateChange, got %+v", events[0])
|
||||
}
|
||||
if !events[1].isWarning() {
|
||||
t.Fatalf("event[1] should be a warning publish, got %+v", events[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r) // lead > delta => fire immediately
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(10 * time.Millisecond)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if !events[1].isWarning() {
|
||||
t.Fatalf("expected immediate warning publish, got %+v", events[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
lead := 50 * time.Millisecond
|
||||
w := newWatcher(lead, r)
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
|
||||
_ = w.Update(first)
|
||||
|
||||
// Replace with a far-future deadline before the warning fires.
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
second := time.Now().Add(time.Hour)
|
||||
_ = w.Update(second)
|
||||
|
||||
// Wait past when first's warning would have fired.
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
|
||||
t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
lead := 30 * time.Millisecond
|
||||
w := newWatcher(lead, r)
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(50 * time.Millisecond)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Wait for stateChange + warning of the first cycle.
|
||||
waitForEvents(t, r, 2)
|
||||
|
||||
// Simulate a successful extend: brand new deadline.
|
||||
second := time.Now().Add(60 * time.Millisecond)
|
||||
_ = w.Update(second)
|
||||
|
||||
// 4 events total: stateChange, warning (first), stateChange, warning (second).
|
||||
events := waitForEvents(t, r, 4)
|
||||
if events[2].kind != stateChange {
|
||||
t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2])
|
||||
}
|
||||
if !events[3].isWarning() {
|
||||
t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(2 * time.Hour)
|
||||
_ = w.Update(d)
|
||||
waitForEvents(t, r, 1)
|
||||
|
||||
_ = w.Update(time.Time{})
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[1].kind != stateChange {
|
||||
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("Deadline should be zero after clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRejectsBeforeEpoch(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
good := time.Now().Add(time.Hour)
|
||||
if err := w.Update(good); err != nil {
|
||||
t.Fatalf("seed Update: %v", err)
|
||||
}
|
||||
|
||||
err := w.Update(time.Unix(-100, 0))
|
||||
if !errors.Is(err, ErrDeadlineBeforeEpoch) {
|
||||
t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err)
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
good := time.Now().Add(time.Hour)
|
||||
if err := w.Update(good); err != nil {
|
||||
t.Fatalf("seed Update: %v", err)
|
||||
}
|
||||
|
||||
err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour))
|
||||
if !errors.Is(err, ErrDeadlineTooFarFuture) {
|
||||
t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err)
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
good := time.Now().Add(time.Hour)
|
||||
if err := w.Update(good); err != nil {
|
||||
t.Fatalf("seed Update: %v", err)
|
||||
}
|
||||
// Drain the stateChange from the seed.
|
||||
waitForEvents(t, r, 1)
|
||||
|
||||
err := w.Update(time.Now().Add(-1 * time.Hour))
|
||||
if !errors.Is(err, ErrDeadlineInPast) {
|
||||
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
|
||||
}
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[1].kind != stateChange {
|
||||
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWithinSkewAccepted(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
|
||||
d := time.Now().Add(-5 * time.Second)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("within-skew Update should succeed, got %v", err)
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseSilencesUpdates(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
w.Close()
|
||||
|
||||
_ = w.Update(time.Now().Add(time.Hour))
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events after Close, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
|
||||
// holding a live deadline must zero the recorder on Close so the next
|
||||
// engine's watcher (and the UI reading the shared server-scoped recorder)
|
||||
// doesn't start out showing the previous session's stale "expires in".
|
||||
func TestCloseClearsRecorderDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r)
|
||||
|
||||
d := time.Now().Add(2 * time.Hour)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("seed Update: %v", err)
|
||||
}
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("recorder deadline after Update = %v, want %v", got, d)
|
||||
}
|
||||
|
||||
w.Close()
|
||||
|
||||
if got := r.deadline(); !got.IsZero() {
|
||||
t.Fatalf("recorder deadline after Close = %v, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric
|
||||
// case: closing a watcher that never held a deadline must not emit a
|
||||
// redundant clear (the recorder may legitimately hold a value written by
|
||||
// some other path; the watcher only owns what it set).
|
||||
func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r)
|
||||
|
||||
w.Close()
|
||||
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events from Close on an empty watcher, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
// Warning fires at deadline-80ms, final at deadline-30ms.
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Expect stateChange + warning + final-warning.
|
||||
events := waitForEvents(t, r, 3)
|
||||
|
||||
if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 {
|
||||
t.Fatalf("expected exactly 1 stateChange, got %+v", events)
|
||||
}
|
||||
if countWhere(events, event.isWarning) != 1 {
|
||||
t.Fatalf("expected exactly 1 warning publish, got %+v", events)
|
||||
}
|
||||
if countWhere(events, event.isFinalWarning) != 1 {
|
||||
t.Fatalf("expected exactly 1 final-warning publish, got %+v", events)
|
||||
}
|
||||
|
||||
// Warning must precede final (same deadline, longer lead fires first).
|
||||
var wIdx, fIdx int
|
||||
for i, e := range events {
|
||||
switch {
|
||||
case e.isWarning():
|
||||
wIdx = i
|
||||
case e.isFinalWarning():
|
||||
fIdx = i
|
||||
}
|
||||
}
|
||||
if wIdx > fIdx {
|
||||
t.Fatalf("warning must publish before final-warning, got order %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissSuppressesFinalWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Wait for the warning publish so we know we're inside the warning
|
||||
// window, then dismiss before the final timer would fire.
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if countWhere(r.snapshot(), event.isWarning) >= 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
if countWhere(r.snapshot(), event.isWarning) < 1 {
|
||||
t.Fatalf("warning did not publish in time, events=%+v", r.snapshot())
|
||||
}
|
||||
|
||||
w.Dismiss()
|
||||
|
||||
// Now wait past when the final would have fired.
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 {
|
||||
t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissResetByNewDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(100 * time.Millisecond)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Dismiss against the first deadline.
|
||||
w.Dismiss()
|
||||
|
||||
// Replace with a fresh deadline before the first's timers complete.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
second := time.Now().Add(100 * time.Millisecond)
|
||||
_ = w.Update(second)
|
||||
|
||||
// The second cycle must publish a final-warning (the dismiss state
|
||||
// did not carry over).
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if countWhere(r.snapshot(), event.isFinalWarning) < 1 {
|
||||
t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
|
||||
w.Dismiss()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Final warning should still publish — Dismiss only acts on the current
|
||||
// deadline, and there was none at the time of the call.
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
@@ -117,6 +118,8 @@ func (c *ConnectClient) RunOniOS(
|
||||
networkChangeListener listener.NetworkChangeListener,
|
||||
dnsManager dns.IosDnsManager,
|
||||
stateFilePath string,
|
||||
cacheDir string,
|
||||
logFilePath string,
|
||||
) error {
|
||||
// Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension.
|
||||
debug.SetGCPercent(5)
|
||||
@@ -126,8 +129,9 @@ func (c *ConnectClient) RunOniOS(
|
||||
NetworkChangeListener: networkChangeListener,
|
||||
DnsManager: dnsManager,
|
||||
StateFilePath: stateFilePath,
|
||||
TempDir: cacheDir,
|
||||
}
|
||||
return c.run(mobileDependency, nil, "")
|
||||
return c.run(mobileDependency, nil, logFilePath)
|
||||
}
|
||||
|
||||
func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error {
|
||||
@@ -256,15 +260,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
||||
if err != nil {
|
||||
// On daemon shutdown / Down() the parent context is cancelled
|
||||
// and the dial fails with "context canceled". Wrapping that
|
||||
// into state would leave the snapshot stuck at Connecting+err
|
||||
// until the backoff loop wakes up — instead let the operation
|
||||
// return cleanly so the deferred state.Set(StatusIdle) takes
|
||||
// effect on the next iteration.
|
||||
if c.ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err))
|
||||
}
|
||||
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
|
||||
@@ -355,6 +350,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
return wrapErr(err)
|
||||
}
|
||||
engineConfig.TempDir = mobileDependency.TempDir
|
||||
// Leave StateDir empty when there is no state path so a disk-backed
|
||||
// syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
|
||||
if path != "" {
|
||||
engineConfig.StateDir = filepath.Dir(path)
|
||||
}
|
||||
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
|
||||
c.statusRecorder.SetRelayMgr(relayManager)
|
||||
@@ -393,10 +393,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
return wrapErr(err)
|
||||
}
|
||||
|
||||
// Seed the session-expiry deadline from the LoginResponse. Subsequent
|
||||
// changes flow in through SyncResponse and are applied in handleSync.
|
||||
engine.ApplySessionDeadline(loginResp.GetSessionExpiresAt())
|
||||
|
||||
log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress())
|
||||
state.Set(StatusConnected)
|
||||
|
||||
@@ -437,11 +433,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}
|
||||
|
||||
c.statusRecorder.ClientStart()
|
||||
// Wrap the backoff with c.ctx so Down()/actCancel propagates into the
|
||||
// inter-attempt sleep — otherwise a 15s MaxInterval can keep the retry
|
||||
// loop alive long after the caller asked to give up, leaving the
|
||||
// status stream stuck at Connecting.
|
||||
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
|
||||
err = backoff.Retry(operation, backOff)
|
||||
if err != nil {
|
||||
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
||||
|
||||
@@ -250,10 +250,13 @@ type BundleGenerator struct {
|
||||
syncResponse *mgmProto.SyncResponse
|
||||
logPath string
|
||||
tempDir string
|
||||
statePath string
|
||||
cpuProfile []byte
|
||||
capturePath string
|
||||
refreshStatus func() // Optional callback to refresh status before bundle generation
|
||||
clientMetrics MetricsExporter
|
||||
daemonVersion string
|
||||
cliVersion string
|
||||
|
||||
anonymize bool
|
||||
includeSystemInfo bool
|
||||
@@ -274,10 +277,13 @@ type GeneratorDependencies struct {
|
||||
SyncResponse *mgmProto.SyncResponse
|
||||
LogPath string
|
||||
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
|
||||
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
|
||||
CPUProfile []byte
|
||||
CapturePath string
|
||||
RefreshStatus func()
|
||||
ClientMetrics MetricsExporter
|
||||
DaemonVersion string
|
||||
CliVersion string
|
||||
}
|
||||
|
||||
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
|
||||
@@ -295,10 +301,13 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
syncResponse: deps.SyncResponse,
|
||||
logPath: deps.LogPath,
|
||||
tempDir: deps.TempDir,
|
||||
statePath: deps.StatePath,
|
||||
cpuProfile: deps.CPUProfile,
|
||||
capturePath: deps.CapturePath,
|
||||
refreshStatus: deps.RefreshStatus,
|
||||
clientMetrics: deps.ClientMetrics,
|
||||
daemonVersion: deps.DaemonVersion,
|
||||
cliVersion: deps.CliVersion,
|
||||
|
||||
anonymize: cfg.Anonymize,
|
||||
includeSystemInfo: cfg.IncludeSystemInfo,
|
||||
@@ -459,9 +468,11 @@ func (g *BundleGenerator) addStatus() error {
|
||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
})
|
||||
overview.CliVersion = g.cliVersion
|
||||
statusOutput := overview.FullDetailSummary()
|
||||
|
||||
statusReader := strings.NewReader(statusOutput)
|
||||
@@ -508,6 +519,14 @@ func (g *BundleGenerator) addConfig() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the set of MDM-enforced keys so a support engineer reading
|
||||
// the bundle can tell which field values are user-set vs MDM-overridden.
|
||||
// Same semantics as the mDMManagedFields list returned by the
|
||||
// GetConfig RPC consumed by `netbird debug config`.
|
||||
if managed := g.internalConfig.Policy().ManagedKeys(); len(managed) > 0 {
|
||||
configContent.WriteString(fmt.Sprintf("MDMManagedFields: %v\n", managed))
|
||||
}
|
||||
|
||||
configReader := strings.NewReader(configContent.String())
|
||||
if err := g.addFileToZip(configReader, "config.txt"); err != nil {
|
||||
return fmt.Errorf("add config file to zip: %w", err)
|
||||
@@ -798,6 +817,8 @@ func (g *BundleGenerator) addSyncResponse() error {
|
||||
AllowPartial: true,
|
||||
}
|
||||
|
||||
g.maskSecrets()
|
||||
|
||||
jsonBytes, err := options.Marshal(g.syncResponse)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate json: %w", err)
|
||||
@@ -810,9 +831,33 @@ func (g *BundleGenerator) addSyncResponse() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) maskSecrets() {
|
||||
if g.syncResponse == nil || g.syncResponse.NetbirdConfig == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if g.syncResponse.NetbirdConfig.Flow != nil {
|
||||
g.syncResponse.NetbirdConfig.Flow.TokenPayload = maskedValue
|
||||
|
||||
}
|
||||
|
||||
if g.syncResponse.NetbirdConfig.Relay != nil {
|
||||
g.syncResponse.NetbirdConfig.Relay.TokenPayload = maskedValue
|
||||
}
|
||||
|
||||
for i := range g.syncResponse.NetbirdConfig.Turns {
|
||||
if g.syncResponse.NetbirdConfig.Turns[i] != nil {
|
||||
g.syncResponse.NetbirdConfig.Turns[i].Password = maskedValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addStateFile() error {
|
||||
sm := profilemanager.NewServiceManager("")
|
||||
path := sm.GetStatePath()
|
||||
path := g.statePath
|
||||
if path == "" {
|
||||
sm := profilemanager.NewServiceManager("")
|
||||
path = sm.GetStatePath()
|
||||
}
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -1039,7 +1084,8 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
||||
return
|
||||
}
|
||||
|
||||
pattern := filepath.Join(logDir, "client-*.log.gz")
|
||||
// This regex will match both logs rotated by us and logrotate on linux
|
||||
pattern := filepath.Join(logDir, "client*.log.*")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
log.Warnf("failed to glob rotated logs: %v", err)
|
||||
@@ -1072,7 +1118,12 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
||||
|
||||
for i := 0; i < maxFiles; i++ {
|
||||
name := filepath.Base(files[i])
|
||||
if err := g.addSingleLogFileGz(files[i], name); err != nil {
|
||||
if strings.HasSuffix(name, ".gz") {
|
||||
err = g.addSingleLogFileGz(files[i], name)
|
||||
} else {
|
||||
err = g.addSingleLogfile(files[i], name)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnf("failed to add rotated log %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
36
client/internal/debug/debug_ios.go
Normal file
36
client/internal/debug/debug_ios.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build ios
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// swiftLogFile is the Swift app log written by the iOS app into the same log
|
||||
// directory as the Go client log, so it can be collected into the bundle.
|
||||
const swiftLogFile = "swift-log.log"
|
||||
|
||||
// addPlatformLog collects logs for the iOS debug bundle. iOS has no logcat or
|
||||
// systemd journal, so we rely on file-based logs. addLogfile handles the Go
|
||||
// client log (logPath) with rotation, the stderr/stdout companions and
|
||||
// anonymization. The iOS app writes its own Swift log into the same directory,
|
||||
// so we add it alongside the Go log.
|
||||
func (g *BundleGenerator) addPlatformLog() error {
|
||||
if err := g.addLogfile(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if g.logPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile)
|
||||
if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil {
|
||||
// The Swift log is best-effort: the app may not have written it yet.
|
||||
log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
103
client/internal/debug/debug_logfiles_test.go
Normal file
103
client/internal/debug/debug_logfiles_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log
|
||||
// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain
|
||||
// and gzipped), and skips unrelated files.
|
||||
func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
writeFile(t, filepath.Join(dir, "client.log"), "active log\n")
|
||||
writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n")
|
||||
|
||||
timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz"
|
||||
writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n")
|
||||
|
||||
logrotatePlain := "client.log.1"
|
||||
writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n")
|
||||
|
||||
logrotateGz := "client.log.2.gz"
|
||||
writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n")
|
||||
|
||||
names := runAddRotatedLogFiles(t, dir, 10)
|
||||
|
||||
require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle")
|
||||
require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle")
|
||||
require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle")
|
||||
require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles")
|
||||
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
|
||||
}
|
||||
|
||||
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
|
||||
// logFileCount rotated files are bundled, ordered by mtime.
|
||||
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
oldest := filepath.Join(dir, "client.log.3")
|
||||
middle := filepath.Join(dir, "client.log.2")
|
||||
newest := filepath.Join(dir, "client.log.1")
|
||||
writeFile(t, oldest, "old\n")
|
||||
writeFile(t, middle, "mid\n")
|
||||
writeFile(t, newest, "new\n")
|
||||
|
||||
now := time.Now()
|
||||
require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour)))
|
||||
require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour)))
|
||||
require.NoError(t, os.Chtimes(newest, now, now))
|
||||
|
||||
names := runAddRotatedLogFiles(t, dir, 2)
|
||||
|
||||
require.Contains(t, names, "client.log.1")
|
||||
require.Contains(t, names, "client.log.2")
|
||||
require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2")
|
||||
}
|
||||
|
||||
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
|
||||
// zip writer and returns the set of entry names that ended up in the archive.
|
||||
func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
g := &BundleGenerator{
|
||||
archive: zip.NewWriter(&buf),
|
||||
logFileCount: logFileCount,
|
||||
}
|
||||
g.addRotatedLogFiles(dir)
|
||||
require.NoError(t, g.archive.Close())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
require.NoError(t, err)
|
||||
|
||||
names := make(map[string]struct{}, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
names[f.Name] = struct{}{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
func writeGzFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
_, err := io.WriteString(gw, content)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gw.Close())
|
||||
require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !android
|
||||
//go:build !android && !ios
|
||||
|
||||
package debug
|
||||
|
||||
|
||||
@@ -843,6 +843,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
"PreSharedKey": "sensitive: WireGuard pre-shared key",
|
||||
"SSHKey": "sensitive: SSH private key",
|
||||
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
|
||||
"Name": "non-config: profile name is not needed for debug purposes",
|
||||
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
|
||||
}
|
||||
|
||||
mURL, _ := url.Parse("https://api.example.com:443")
|
||||
|
||||
@@ -482,7 +482,7 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
|
||||
// completely when every proxy peer is offline (the upstream may still
|
||||
// be reachable some other way, or the peerstore may be stale).
|
||||
func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
|
||||
if len(records) == 0 {
|
||||
if len(records) < 2 {
|
||||
return records
|
||||
}
|
||||
d.mu.RLock()
|
||||
|
||||
@@ -2738,6 +2738,17 @@ func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
|
||||
connByIP: nil,
|
||||
wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
|
||||
},
|
||||
{
|
||||
// A single answer is never filtered: dropping it would only
|
||||
// trigger the empty-answer escape hatch, so the fast path
|
||||
// returns it untouched.
|
||||
name: "single disconnected answer passes through",
|
||||
records: []nbdns.SimpleRecord{disconnectedRec},
|
||||
connByIP: map[string]ipState{
|
||||
"100.64.0.11": {known: true, connected: false},
|
||||
},
|
||||
wantInOrder: []string{"100.64.0.11"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// errNoSuitableAddress mirrors the unexported error string the net package
|
||||
// uses when a resolved host has no addresses of the requested family.
|
||||
const errNoSuitableAddress = "no suitable address found"
|
||||
|
||||
// GenerateRequestID creates a random 8-character hex string for request tracing.
|
||||
func GenerateRequestID() string {
|
||||
bytes := make([]byte, 4)
|
||||
@@ -126,6 +130,14 @@ func LookupIP(ctx context.Context, r resolver, network, host string, qtype uint1
|
||||
}
|
||||
|
||||
func getRcodeForError(ctx context.Context, r resolver, host string, qtype uint16, err error) int {
|
||||
// The net package returns this AddrError when the host resolves but has
|
||||
// no addresses of the requested family. The domain exists, so answer
|
||||
// NODATA instead of SERVFAIL.
|
||||
var addrErr *net.AddrError
|
||||
if errors.As(err, &addrErr) && addrErr.Err == errNoSuitableAddress {
|
||||
return dns.RcodeSuccess
|
||||
}
|
||||
|
||||
var dnsErr *net.DNSError
|
||||
if !errors.As(err, &dnsErr) {
|
||||
return dns.RcodeServerFailure
|
||||
|
||||
122
client/internal/dns/resutil/resolve_test.go
Normal file
122
client/internal/dns/resutil/resolve_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package resutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockResolver struct {
|
||||
// results maps network ("ip4"/"ip6") to the lookup outcome.
|
||||
results map[string]mockLookup
|
||||
}
|
||||
|
||||
type mockLookup struct {
|
||||
ips []netip.Addr
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockResolver) LookupNetIP(_ context.Context, network, _ string) ([]netip.Addr, error) {
|
||||
res, ok := m.results[network]
|
||||
if !ok {
|
||||
return nil, errors.New("unexpected network: " + network)
|
||||
}
|
||||
return res.ips, res.err
|
||||
}
|
||||
|
||||
func TestLookupIP_Success(t *testing.T) {
|
||||
r := &mockResolver{results: map[string]mockLookup{
|
||||
"ip4": {ips: []netip.Addr{netip.MustParseAddr("::ffff:192.0.2.1")}},
|
||||
}}
|
||||
|
||||
result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
|
||||
|
||||
assert.Equal(t, dns.RcodeSuccess, result.Rcode, "successful lookup should return NOERROR")
|
||||
require.Len(t, result.IPs, 1, "should return the resolved address")
|
||||
assert.Equal(t, netip.MustParseAddr("192.0.2.1"), result.IPs[0], "v4-mapped address should be unmapped")
|
||||
}
|
||||
|
||||
func TestLookupIP_NoSuitableAddress(t *testing.T) {
|
||||
// The net package returns this AddrError when the host resolves but has
|
||||
// no addresses of the requested family (e.g. AAAA query for a v4-only
|
||||
// hosts file entry). The domain exists, so this is NODATA, not SERVFAIL.
|
||||
r := &mockResolver{results: map[string]mockLookup{
|
||||
"ip6": {err: &net.AddrError{Err: "no suitable address found", Addr: "example.com."}},
|
||||
}}
|
||||
|
||||
result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA)
|
||||
|
||||
assert.Equal(t, dns.RcodeSuccess, result.Rcode, "no suitable address should map to NODATA")
|
||||
assert.Empty(t, result.IPs, "NODATA response should carry no addresses")
|
||||
}
|
||||
|
||||
// TestErrNoSuitableAddressMatchesNetPackage pins our copy of the error string
|
||||
// to what the net package actually emits. A literal IP of the wrong family
|
||||
// takes the same filterAddrList path as a resolved hostname, without network
|
||||
// access.
|
||||
func TestErrNoSuitableAddressMatchesNetPackage(t *testing.T) {
|
||||
_, err := (&net.Resolver{}).LookupNetIP(context.Background(), "ip6", "192.0.2.1")
|
||||
require.Error(t, err)
|
||||
|
||||
var addrErr *net.AddrError
|
||||
require.ErrorAs(t, err, &addrErr, "wrong-family lookup should return AddrError")
|
||||
assert.Equal(t, errNoSuitableAddress, addrErr.Err, "net package error string should match our constant")
|
||||
}
|
||||
|
||||
func TestLookupIP_OtherAddrError(t *testing.T) {
|
||||
r := &mockResolver{results: map[string]mockLookup{
|
||||
"ip4": {err: &net.AddrError{Err: "some other address problem", Addr: "example.com."}},
|
||||
}}
|
||||
|
||||
result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
|
||||
|
||||
assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "unrecognized AddrError should map to SERVFAIL")
|
||||
}
|
||||
|
||||
func TestLookupIP_NotFoundNXDomain(t *testing.T) {
|
||||
r := &mockResolver{results: map[string]mockLookup{
|
||||
"ip4": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
|
||||
"ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
|
||||
}}
|
||||
|
||||
result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
|
||||
|
||||
assert.Equal(t, dns.RcodeNameError, result.Rcode, "not found for both families should map to NXDOMAIN")
|
||||
}
|
||||
|
||||
func TestLookupIP_NotFoundNoData(t *testing.T) {
|
||||
r := &mockResolver{results: map[string]mockLookup{
|
||||
"ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
|
||||
"ip4": {ips: []netip.Addr{netip.MustParseAddr("192.0.2.1")}},
|
||||
}}
|
||||
|
||||
result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA)
|
||||
|
||||
assert.Equal(t, dns.RcodeSuccess, result.Rcode, "not found with the other family present should map to NODATA")
|
||||
}
|
||||
|
||||
func TestLookupIP_GenericError(t *testing.T) {
|
||||
r := &mockResolver{results: map[string]mockLookup{
|
||||
"ip4": {err: errors.New("connection refused")},
|
||||
}}
|
||||
|
||||
result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
|
||||
|
||||
assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "generic error should map to SERVFAIL")
|
||||
}
|
||||
|
||||
func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) {
|
||||
r := &mockResolver{results: map[string]mockLookup{
|
||||
"ip4": {err: &net.DNSError{Err: "server misbehaving", Name: "example.com.", IsTemporary: true}},
|
||||
}}
|
||||
|
||||
result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
|
||||
|
||||
assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL")
|
||||
}
|
||||
@@ -777,13 +777,24 @@ func (s *DefaultServer) applyHostConfig() {
|
||||
// context is released rather than leaked until GC.
|
||||
func (s *DefaultServer) registerFallback() {
|
||||
originalNameservers := s.hostManager.getOriginalNameservers()
|
||||
if len(originalNameservers) == 0 {
|
||||
|
||||
serverIP := s.service.RuntimeIP()
|
||||
var servers []netip.AddrPort
|
||||
for _, ns := range originalNameservers {
|
||||
if ns == serverIP {
|
||||
log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP)
|
||||
continue
|
||||
}
|
||||
servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler")
|
||||
s.clearFallback()
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback)
|
||||
log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback)
|
||||
|
||||
handler, err := newUpstreamResolver(
|
||||
s.ctx,
|
||||
@@ -797,11 +808,6 @@ func (s *DefaultServer) registerFallback() {
|
||||
return
|
||||
}
|
||||
handler.selectedRoutes = s.selectedRoutes
|
||||
|
||||
var servers []netip.AddrPort
|
||||
for _, ns := range originalNameservers {
|
||||
servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
|
||||
}
|
||||
handler.addRace(servers)
|
||||
|
||||
prev := s.fallbackHandler
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/firewall"
|
||||
@@ -54,8 +53,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/relay"
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/syncstore"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/jobexec"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
@@ -65,13 +64,13 @@ import (
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
signal "github.com/netbirdio/netbird/shared/signal/client"
|
||||
sProto "github.com/netbirdio/netbird/shared/signal/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
"github.com/netbirdio/netbird/util/capture"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
|
||||
@@ -148,6 +147,10 @@ type EngineConfig struct {
|
||||
|
||||
LogPath string
|
||||
TempDir string
|
||||
|
||||
// StateDir is the directory holding the state file. The sync response
|
||||
// (network map) is serialized here on platforms that persist it to disk.
|
||||
StateDir string
|
||||
}
|
||||
|
||||
// EngineServices holds the external service dependencies required by the Engine.
|
||||
@@ -226,11 +229,16 @@ type Engine struct {
|
||||
|
||||
afpacketCapture *capture.AFPacketCapture
|
||||
|
||||
// Sync response persistence (protected by syncRespMux)
|
||||
syncRespMux sync.RWMutex
|
||||
persistSyncResponse bool
|
||||
latestSyncResponse *mgmProto.SyncResponse
|
||||
flowManager nftypes.FlowManager
|
||||
// Sync response persistence (protected by syncRespMux).
|
||||
// syncStore is nil unless persistence has been enabled; its presence is
|
||||
// what marks persistence as active. The backend (disk or memory) is
|
||||
// selected per-platform; see the syncstore package. syncStoreDir is where
|
||||
// a disk-backed store serializes to.
|
||||
syncRespMux sync.RWMutex
|
||||
syncStore syncstore.Store
|
||||
syncStoreDir string
|
||||
|
||||
flowManager nftypes.FlowManager
|
||||
|
||||
// auto-update
|
||||
updateManager *updater.Manager
|
||||
@@ -250,20 +258,6 @@ type Engine struct {
|
||||
jobExecutorWG sync.WaitGroup
|
||||
|
||||
exposeManager *expose.Manager
|
||||
|
||||
sessionWatcher sessionDeadlineWatcher
|
||||
}
|
||||
|
||||
// sessionDeadlineWatcher is the engine-facing surface of the SSO session
|
||||
// expiry watcher. The concrete implementation (sessionwatch.Watcher) is wired
|
||||
// in via newSessionWatcher, which is build-tagged so the js/wasm build links a
|
||||
// no-op stub instead of pulling the full sessionwatch package (and its timer
|
||||
// machinery) into the binary — the wasm client never runs the engine's
|
||||
// session-warning flow.
|
||||
type sessionDeadlineWatcher interface {
|
||||
Update(deadline time.Time) error
|
||||
Dismiss()
|
||||
Close()
|
||||
}
|
||||
|
||||
// Peer is an instance of the Connection Peer
|
||||
@@ -306,18 +300,8 @@ func NewEngine(
|
||||
jobExecutor: jobexec.NewExecutor(),
|
||||
clientMetrics: services.ClientMetrics,
|
||||
updateManager: services.UpdateManager,
|
||||
syncStoreDir: config.StateDir,
|
||||
}
|
||||
// sessionWatcher keeps the SubscribeStatus consumers in sync with the
|
||||
// session expiry deadline. Deadline-change ticks come for free via
|
||||
// Status.SetSessionExpiresAt; the watcher exists to push a wake-up at
|
||||
// T-WarningLead and T-FinalWarningLead so the UI repaints the remaining
|
||||
// time / warning state even when nothing else changed, and to publish
|
||||
// two SystemEvents (the warning composition lives in sessionwatch so
|
||||
// the wire format stays owned by one package):
|
||||
// - T-WarningLead → interactive "Extend now / Dismiss" notification
|
||||
// - T-FinalWarningLead → auto-opened SessionAboutToExpire dialog,
|
||||
// suppressed when the user dismissed the earlier warning
|
||||
engine.sessionWatcher = newSessionWatcher(engine.statusRecorder)
|
||||
|
||||
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
||||
return engine
|
||||
@@ -358,10 +342,6 @@ func (e *Engine) Stop() error {
|
||||
e.srWatcher.Close()
|
||||
}
|
||||
|
||||
if e.sessionWatcher != nil {
|
||||
e.sessionWatcher.Close()
|
||||
}
|
||||
|
||||
if e.updateManager != nil {
|
||||
e.updateManager.SetDownloadOnly()
|
||||
}
|
||||
@@ -549,6 +529,10 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
return fmt.Errorf("create wg interface: %w", err)
|
||||
}
|
||||
|
||||
if filteredDevice := e.wgInterface.GetDevice(); filteredDevice != nil {
|
||||
filteredDevice.SetPanicHandler(e.triggerClientRestart)
|
||||
}
|
||||
|
||||
if err := e.createFirewall(); err != nil {
|
||||
e.close()
|
||||
return err
|
||||
@@ -894,69 +878,29 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
return e.ctx.Err()
|
||||
}
|
||||
|
||||
e.ApplySessionDeadline(update.GetSessionExpiresAt())
|
||||
|
||||
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
||||
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
||||
}
|
||||
|
||||
if update.GetNetbirdConfig() != nil {
|
||||
wCfg := update.GetNetbirdConfig()
|
||||
err := e.updateTURNs(wCfg.GetTurns())
|
||||
if err != nil {
|
||||
return fmt.Errorf("update TURNs: %w", err)
|
||||
}
|
||||
if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = e.updateSTUNs(wCfg.GetStuns())
|
||||
if err != nil {
|
||||
return fmt.Errorf("update STUNs: %w", err)
|
||||
}
|
||||
|
||||
var stunTurn []*stun.URI
|
||||
stunTurn = append(stunTurn, e.STUNs...)
|
||||
stunTurn = append(stunTurn, e.TURNs...)
|
||||
e.stunTurn.Store(stunTurn)
|
||||
|
||||
err = e.handleRelayUpdate(wCfg.GetRelay())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = e.handleFlowUpdate(wCfg.GetFlow())
|
||||
if err != nil {
|
||||
return fmt.Errorf("handle the flow configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
|
||||
log.Warnf("Failed to update DNS server config: %v", err)
|
||||
}
|
||||
|
||||
// todo update signal
|
||||
// Posture checks are bound to the network map presence:
|
||||
// NetworkMap != nil, checks present -> apply the received checks
|
||||
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
|
||||
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
|
||||
// leave the previously applied checks untouched
|
||||
nm := update.GetNetworkMap()
|
||||
if nm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := e.updateChecksIfNew(update.Checks); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nm := update.GetNetworkMap()
|
||||
if nm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
|
||||
// Read the storage-enabled flag under the syncRespMux too.
|
||||
e.syncRespMux.RLock()
|
||||
enabled := e.persistSyncResponse
|
||||
e.syncRespMux.RUnlock()
|
||||
|
||||
// Store sync response if persistence is enabled
|
||||
if enabled {
|
||||
e.syncRespMux.Lock()
|
||||
e.latestSyncResponse = update
|
||||
e.syncRespMux.Unlock()
|
||||
|
||||
log.Debugf("sync response persisted with serial %d", nm.GetSerial())
|
||||
}
|
||||
e.persistSyncResponse(update)
|
||||
|
||||
// only apply new changes and ignore old ones
|
||||
if err := e.updateNetworkMap(nm); err != nil {
|
||||
@@ -968,6 +912,64 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateNetbirdConfig applies the management-provided NetBird configuration:
|
||||
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
|
||||
// which is the case for sync updates carrying only a network map.
|
||||
func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error {
|
||||
if wCfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := e.updateTURNs(wCfg.GetTurns()); err != nil {
|
||||
return fmt.Errorf("update TURNs: %w", err)
|
||||
}
|
||||
|
||||
if err := e.updateSTUNs(wCfg.GetStuns()); err != nil {
|
||||
return fmt.Errorf("update STUNs: %w", err)
|
||||
}
|
||||
|
||||
var stunTurn []*stun.URI
|
||||
stunTurn = append(stunTurn, e.STUNs...)
|
||||
stunTurn = append(stunTurn, e.TURNs...)
|
||||
e.stunTurn.Store(stunTurn)
|
||||
|
||||
if err := e.handleRelayUpdate(wCfg.GetRelay()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := e.handleFlowUpdate(wCfg.GetFlow()); err != nil {
|
||||
return fmt.Errorf("handle the flow configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
|
||||
log.Warnf("Failed to update DNS server config: %v", err)
|
||||
}
|
||||
|
||||
// todo update signal
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistSyncResponse stores the full sync response so it can be restored on the next
|
||||
// startup. Persistence is enabled only when syncStore is set. The dedicated syncRespMux
|
||||
// (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.
|
||||
func (e *Engine) persistSyncResponse(update *mgmProto.SyncResponse) {
|
||||
e.syncRespMux.RLock()
|
||||
defer e.syncRespMux.RUnlock()
|
||||
|
||||
if e.syncStore == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.syncStore.Set(update); err != nil {
|
||||
log.Errorf("failed to persist sync response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("sync response persisted with serial %d", update.GetNetworkMap().GetSerial())
|
||||
}
|
||||
|
||||
func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error {
|
||||
if update != nil {
|
||||
// when we receive token we expect valid address list too
|
||||
@@ -1075,11 +1077,17 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
return ErrResetConnection
|
||||
}
|
||||
|
||||
if !e.config.DisableIPv6 && e.hasIPv6Changed(conf) {
|
||||
log.Infof("peer IPv6 address changed, restarting client")
|
||||
_ = CtxGetState(e.ctx).Wrap(ErrResetConnection)
|
||||
e.clientCancel()
|
||||
return ErrResetConnection
|
||||
if !e.config.DisableIPv6 {
|
||||
reset, err := e.reconcileIPv6(conf)
|
||||
if err != nil {
|
||||
log.Warnf("reconcile IPv6 from PeerConfig: %v", err)
|
||||
}
|
||||
if reset {
|
||||
log.Infof("peer IPv6 address changed value, restarting client")
|
||||
_ = CtxGetState(e.ctx).Wrap(ErrResetConnection)
|
||||
e.clientCancel()
|
||||
return ErrResetConnection
|
||||
}
|
||||
}
|
||||
|
||||
if conf.GetSshConfig() != nil {
|
||||
@@ -1094,31 +1102,65 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
|
||||
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
|
||||
state.FQDN = conf.GetFqdn()
|
||||
state.WgPort = e.config.WgPort
|
||||
|
||||
e.statusRecorder.UpdateLocalPeerState(state)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasIPv6Changed reports whether the IPv6 overlay address in the peer config
|
||||
// differs from the configured address (added, removed, or changed).
|
||||
// Compares against e.config.WgAddr (not the interface address, which may have
|
||||
// been cleared by ClearIPv6 if OS assignment failed).
|
||||
func (e *Engine) hasIPv6Changed(conf *mgmProto.PeerConfig) bool {
|
||||
current := e.config.WgAddr
|
||||
// reconcileIPv6 applies the management-supplied IPv6 overlay address to the
|
||||
// engine's WireGuard interface in place when possible. Three transitions:
|
||||
//
|
||||
// - First v6 assignment (current had no v6, conf carries one): apply via
|
||||
// WGIface.UpdateAddr, no reset. Critical for embedded clients whose
|
||||
// boot config has no v6 — without this we reset on every fresh start
|
||||
// once management has v6 enabled, orphaning any netstack listeners
|
||||
// held outside the engine.
|
||||
// - v6 removed (current had v6, conf carries none): clear in place, no
|
||||
// reset.
|
||||
// - v6 swapped to a different non-empty value: returns reset=true so the
|
||||
// caller falls back to the engine-recreate path — the underlying
|
||||
// interface address can't be safely swapped in place across all
|
||||
// backends (gVisor netstack in particular fixes its address at
|
||||
// CreateNetTUN time).
|
||||
//
|
||||
// Mutates e.config.WgAddr to match the applied state so subsequent
|
||||
// PeerConfig comparisons are stable.
|
||||
func (e *Engine) reconcileIPv6(conf *mgmProto.PeerConfig) (reset bool, err error) {
|
||||
raw := conf.GetAddressV6()
|
||||
current := e.config.WgAddr
|
||||
|
||||
if len(raw) == 0 {
|
||||
return current.HasIPv6()
|
||||
if !current.HasIPv6() {
|
||||
return false, nil
|
||||
}
|
||||
current.ClearIPv6()
|
||||
e.config.WgAddr = current
|
||||
if err := e.wgInterface.UpdateAddr(current); err != nil {
|
||||
return false, fmt.Errorf("clear ipv6 on wg interface: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
prefix, err := netiputil.DecodePrefix(raw)
|
||||
if err != nil {
|
||||
log.Errorf("decode v6 overlay address: %v", err)
|
||||
return false
|
||||
incoming := current
|
||||
if err := incoming.SetIPv6FromCompact(raw); err != nil {
|
||||
return false, fmt.Errorf("decode v6 overlay address: %w", err)
|
||||
}
|
||||
|
||||
return !current.HasIPv6() || current.IPv6 != prefix.Addr() || current.IPv6Net != prefix.Masked()
|
||||
if !current.HasIPv6() {
|
||||
e.config.WgAddr = incoming
|
||||
if err := e.wgInterface.UpdateAddr(incoming); err != nil {
|
||||
return false, fmt.Errorf("apply ipv6 on wg interface: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if current.IPv6 == incoming.IPv6 && current.IPv6Net == incoming.IPv6Net {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (e *Engine) receiveJobEvents() {
|
||||
@@ -1172,8 +1214,9 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
LogPath: e.config.LogPath,
|
||||
TempDir: e.config.TempDir,
|
||||
ClientMetrics: e.clientMetrics,
|
||||
DaemonVersion: version.NetbirdVersion(),
|
||||
RefreshStatus: func() {
|
||||
e.RunHealthProbes(e.ctx, true)
|
||||
e.RunHealthProbes(true)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1709,6 +1752,13 @@ func (e *Engine) receiveSignalEvents() {
|
||||
return e.ctx.Err()
|
||||
}
|
||||
|
||||
// Self-addressed heartbeat: the signal client's receive watchdog
|
||||
// round-trips this through the server to confirm the receive stream
|
||||
// is delivering. Liveness is already recorded before this handler.
|
||||
if msg.GetBody().GetType() == sProto.Body_HEARTBEAT {
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, ok := e.peerStore.PeerConn(msg.Key)
|
||||
if !ok {
|
||||
return fmt.Errorf("wrongly addressed message %s", msg.Key)
|
||||
@@ -1844,6 +1894,18 @@ func (e *Engine) close() {
|
||||
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
|
||||
log.Warnf("failed to gracefully stop port forwarding manager: %s", err)
|
||||
}
|
||||
|
||||
// Drop any persisted sync response so its network map does not linger on
|
||||
// disk after the engine stops (and cannot leak into a later run).
|
||||
e.syncRespMux.Lock()
|
||||
store := e.syncStore
|
||||
e.syncStore = nil
|
||||
e.syncRespMux.Unlock()
|
||||
if store != nil {
|
||||
if err := store.Clear(); err != nil {
|
||||
log.Warnf("failed to clear persisted sync response on close: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
||||
@@ -1895,7 +1957,6 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
WGPrivKey: e.config.WgPrivateKey.String(),
|
||||
MTU: e.config.MTU,
|
||||
TransportNet: transportNet,
|
||||
FilterFn: e.addrViaRoutes,
|
||||
DisableDNS: e.config.DisableDNS,
|
||||
}
|
||||
|
||||
@@ -2058,20 +2119,7 @@ func (e *Engine) getRosenpassAddr() string {
|
||||
|
||||
// RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services
|
||||
// and updates the status recorder with the latest states.
|
||||
//
|
||||
// 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()
|
||||
|
||||
func (e *Engine) RunHealthProbes(waitForResult bool) bool {
|
||||
e.syncMsgMux.Lock()
|
||||
|
||||
signalHealthy := e.signal.IsHealthy()
|
||||
@@ -2094,9 +2142,9 @@ func (e *Engine) RunHealthProbes(ctx context.Context, waitForResult bool) bool {
|
||||
if runtime.GOOS != "js" {
|
||||
var results []relay.ProbeResult
|
||||
if waitForResult {
|
||||
results = e.probeStunTurn.ProbeAllWaitResult(ctx, stuns, turns)
|
||||
results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns)
|
||||
} else {
|
||||
results = e.probeStunTurn.ProbeAll(ctx, stuns, turns)
|
||||
results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns)
|
||||
}
|
||||
e.statusRecorder.UpdateRelayStates(results)
|
||||
|
||||
@@ -2156,21 +2204,6 @@ func (e *Engine) startNetworkMonitor() {
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *Engine) addrViaRoutes(addr netip.Addr) (bool, netip.Prefix, error) {
|
||||
var vpnRoutes []netip.Prefix
|
||||
for _, routes := range e.routeManager.GetClientRoutes() {
|
||||
if len(routes) > 0 && routes[0] != nil {
|
||||
vpnRoutes = append(vpnRoutes, routes[0].Network)
|
||||
}
|
||||
}
|
||||
|
||||
if isVpn, prefix := systemops.IsAddrRouted(addr, vpnRoutes); isVpn {
|
||||
return true, prefix, nil
|
||||
}
|
||||
|
||||
return false, netip.Prefix{}, nil
|
||||
}
|
||||
|
||||
func (e *Engine) stopDNSServer() {
|
||||
if e.dnsServer == nil {
|
||||
return
|
||||
@@ -2186,45 +2219,42 @@ func (e *Engine) stopDNSServer() {
|
||||
e.statusRecorder.UpdateDNSStates(nsGroupStates)
|
||||
}
|
||||
|
||||
// SetSyncResponsePersistence enables or disables sync response persistence
|
||||
// SetSyncResponsePersistence enables or disables sync response persistence.
|
||||
// The store is only instantiated while persistence is enabled; construction
|
||||
// itself drops any stale data left over from an earlier run (see syncstore).
|
||||
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
|
||||
e.syncRespMux.Lock()
|
||||
defer e.syncRespMux.Unlock()
|
||||
|
||||
if enabled == e.persistSyncResponse {
|
||||
if enabled == (e.syncStore != nil) {
|
||||
return
|
||||
}
|
||||
e.persistSyncResponse = enabled
|
||||
log.Debugf("Sync response persistence is set to %t", enabled)
|
||||
|
||||
if !enabled {
|
||||
e.latestSyncResponse = nil
|
||||
if err := e.syncStore.Clear(); err != nil {
|
||||
log.Warnf("failed to clear persisted sync response: %v", err)
|
||||
}
|
||||
e.syncStore = nil
|
||||
return
|
||||
}
|
||||
|
||||
e.syncStore = syncstore.New(e.syncStoreDir)
|
||||
}
|
||||
|
||||
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
|
||||
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
|
||||
// Hold the lock for the whole Get so the store cannot be cleared
|
||||
// (disabled / engine close) mid-call.
|
||||
e.syncRespMux.RLock()
|
||||
enabled := e.persistSyncResponse
|
||||
latest := e.latestSyncResponse
|
||||
e.syncRespMux.RUnlock()
|
||||
defer e.syncRespMux.RUnlock()
|
||||
|
||||
if !enabled {
|
||||
if e.syncStore == nil {
|
||||
return nil, errors.New("sync response persistence is disabled")
|
||||
}
|
||||
|
||||
if latest == nil {
|
||||
//nolint:nilnil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(latest))
|
||||
sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to clone sync response")
|
||||
}
|
||||
|
||||
return sr, nil
|
||||
//nolint:nilnil
|
||||
return e.syncStore.Get()
|
||||
}
|
||||
|
||||
// GetWgAddr returns the wireguard address
|
||||
@@ -2260,7 +2290,7 @@ func (e *Engine) updateDNSForwarder(
|
||||
enabled bool,
|
||||
fwdEntries []*dnsfwd.ForwarderEntry,
|
||||
) {
|
||||
if e.config.DisableServerRoutes {
|
||||
if e.config.DisableServerRoutes || e.config.BlockInbound {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +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/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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
305
client/internal/engine_reconcileipv6_test.go
Normal file
305
client/internal/engine_reconcileipv6_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
)
|
||||
|
||||
// reconcileIPv6 / updateConfig regression suite. Locks down the behavior that
|
||||
// PR #5631 (main-side IPv6 overlay support) accidentally broke for embedded
|
||||
// netstack clients: any first NetworkMap update that brings an IPv6 address
|
||||
// used to trigger ErrResetConnection, which destroys the netstack and orphans
|
||||
// every listener bound on it (proxy-side inbound listeners in particular).
|
||||
// The fix in reconcileIPv6 distinguishes "v6 first-assigned" (apply in place)
|
||||
// from "v6 swapped value" (must reset).
|
||||
|
||||
func mustEncodeV6Prefix(t *testing.T, p netip.Prefix) []byte {
|
||||
t.Helper()
|
||||
b, err := netiputil.EncodePrefix(p)
|
||||
require.NoError(t, err, "encode v6 prefix %s", p)
|
||||
return b
|
||||
}
|
||||
|
||||
// reconcileIPv6Fixture builds the smallest Engine the function under test
|
||||
// needs: a config (with WgAddr being the load-bearing field) and a wgInterface
|
||||
// whose UpdateAddr call we can observe.
|
||||
func reconcileIPv6Fixture(t *testing.T, initial wgaddr.Address) (*Engine, *MockWGIface, *wgaddr.Address) {
|
||||
t.Helper()
|
||||
var applied wgaddr.Address
|
||||
mock := &MockWGIface{
|
||||
AddressFunc: func() wgaddr.Address { return initial },
|
||||
UpdateAddrFunc: func(a wgaddr.Address) error {
|
||||
applied = a
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
e := &Engine{
|
||||
ctx: ctx,
|
||||
clientCtx: ctx,
|
||||
clientCancel: cancel,
|
||||
config: &EngineConfig{WgAddr: initial},
|
||||
wgInterface: mock,
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
}
|
||||
return e, mock, &applied
|
||||
}
|
||||
|
||||
func TestReconcileIPv6_FirstAssignment_AppliesInPlace(t *testing.T) {
|
||||
// Embedded clients boot v4-only; management later assigns a v6 overlay.
|
||||
// The fix: apply v6 in place, return reset=false. Pre-fix this case
|
||||
// fell through to the "v6 changed" branch and reset the engine.
|
||||
v4 := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
e, mock, applied := reconcileIPv6Fixture(t, v4)
|
||||
|
||||
v6Prefix := netip.MustParsePrefix("fd00::1/64")
|
||||
conf := &mgmtProto.PeerConfig{
|
||||
Address: v4.String(),
|
||||
AddressV6: mustEncodeV6Prefix(t, v6Prefix),
|
||||
}
|
||||
|
||||
reset, err := e.reconcileIPv6(conf)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, reset, "first v6 assignment must NOT request an engine reset")
|
||||
|
||||
require.True(t, e.config.WgAddr.HasIPv6(), "engine config must record the new v6")
|
||||
assert.Equal(t, v6Prefix.Addr(), e.config.WgAddr.IPv6, "engine config v6 address must match")
|
||||
assert.Equal(t, v6Prefix.Masked(), e.config.WgAddr.IPv6Net, "engine config v6 prefix must match")
|
||||
|
||||
require.True(t, applied.HasIPv6(), "WGIface.UpdateAddr must be called with v6 populated")
|
||||
assert.Equal(t, v6Prefix.Addr(), applied.IPv6, "UpdateAddr must carry the new v6")
|
||||
_ = mock
|
||||
}
|
||||
|
||||
func TestReconcileIPv6_NoChange_NoOp(t *testing.T) {
|
||||
// Steady state: management redelivers the same PeerConfig. No interface
|
||||
// mutation, no reset. Guards against an infinite reset loop if the
|
||||
// comparison ever drifts (e.g. address-vs-prefix masking bugs).
|
||||
v6Prefix := netip.MustParsePrefix("fd00::1/64")
|
||||
addr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
require.NoError(t, addr.SetIPv6FromCompact(mustEncodeV6Prefix(t, v6Prefix)))
|
||||
|
||||
updateAddrCalled := false
|
||||
mock := &MockWGIface{
|
||||
AddressFunc: func() wgaddr.Address { return addr },
|
||||
UpdateAddrFunc: func(a wgaddr.Address) error {
|
||||
updateAddrCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
e := &Engine{
|
||||
ctx: ctx,
|
||||
clientCtx: ctx,
|
||||
clientCancel: cancel,
|
||||
config: &EngineConfig{WgAddr: addr},
|
||||
wgInterface: mock,
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
}
|
||||
|
||||
conf := &mgmtProto.PeerConfig{
|
||||
Address: addr.String(),
|
||||
AddressV6: mustEncodeV6Prefix(t, v6Prefix),
|
||||
}
|
||||
reset, err := e.reconcileIPv6(conf)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, reset, "unchanged v6 must NOT trigger reset")
|
||||
assert.False(t, updateAddrCalled, "unchanged v6 must NOT call UpdateAddr")
|
||||
}
|
||||
|
||||
func TestReconcileIPv6_Removed_AppliesInPlace(t *testing.T) {
|
||||
// Management withdraws v6 (e.g. account toggled off the v6 group).
|
||||
// Cleared in place, no reset.
|
||||
v6Prefix := netip.MustParsePrefix("fd00::1/64")
|
||||
addr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
require.NoError(t, addr.SetIPv6FromCompact(mustEncodeV6Prefix(t, v6Prefix)))
|
||||
|
||||
e, _, applied := reconcileIPv6Fixture(t, addr)
|
||||
e.config.WgAddr = addr
|
||||
|
||||
conf := &mgmtProto.PeerConfig{
|
||||
Address: addr.String(),
|
||||
AddressV6: nil,
|
||||
}
|
||||
reset, err := e.reconcileIPv6(conf)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, reset, "v6 removed must NOT trigger reset")
|
||||
|
||||
assert.False(t, e.config.WgAddr.HasIPv6(), "engine config must reflect v6 cleared")
|
||||
assert.False(t, applied.HasIPv6(), "UpdateAddr must receive cleared v6")
|
||||
}
|
||||
|
||||
func TestReconcileIPv6_PrefixLengthChanged_RequestsReset(t *testing.T) {
|
||||
// Same v6 host, different mask (e.g. /64 → /80). Treated like a value
|
||||
// change because the new netmask redefines the broadcast/scope.
|
||||
oldPrefix := netip.MustParsePrefix("fd00::1/64")
|
||||
newPrefix := netip.MustParsePrefix("fd00::1/80")
|
||||
|
||||
addr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
require.NoError(t, addr.SetIPv6FromCompact(mustEncodeV6Prefix(t, oldPrefix)))
|
||||
|
||||
updateAddrCalled := false
|
||||
mock := &MockWGIface{
|
||||
AddressFunc: func() wgaddr.Address { return addr },
|
||||
UpdateAddrFunc: func(a wgaddr.Address) error {
|
||||
updateAddrCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
e := &Engine{
|
||||
ctx: ctx,
|
||||
clientCtx: ctx,
|
||||
clientCancel: cancel,
|
||||
config: &EngineConfig{WgAddr: addr},
|
||||
wgInterface: mock,
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
}
|
||||
|
||||
conf := &mgmtProto.PeerConfig{
|
||||
Address: addr.String(),
|
||||
AddressV6: mustEncodeV6Prefix(t, newPrefix),
|
||||
}
|
||||
reset, err := e.reconcileIPv6(conf)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reset, "v6 prefix length change must request a reset")
|
||||
assert.False(t, updateAddrCalled, "v6 prefix length change must NOT touch the interface")
|
||||
}
|
||||
|
||||
func TestReconcileIPv6_ValueChanged_RequestsReset(t *testing.T) {
|
||||
// v6 was X, now Y. The netstack backend can't safely swap an existing
|
||||
// address in place — fall back to the engine recreate path.
|
||||
oldPrefix := netip.MustParsePrefix("fd00::1/64")
|
||||
newPrefix := netip.MustParsePrefix("fd00::2/64")
|
||||
|
||||
addr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
require.NoError(t, addr.SetIPv6FromCompact(mustEncodeV6Prefix(t, oldPrefix)))
|
||||
|
||||
updateAddrCalled := false
|
||||
mock := &MockWGIface{
|
||||
AddressFunc: func() wgaddr.Address { return addr },
|
||||
UpdateAddrFunc: func(a wgaddr.Address) error {
|
||||
updateAddrCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
e := &Engine{
|
||||
ctx: ctx,
|
||||
clientCtx: ctx,
|
||||
clientCancel: cancel,
|
||||
config: &EngineConfig{WgAddr: addr},
|
||||
wgInterface: mock,
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
}
|
||||
|
||||
conf := &mgmtProto.PeerConfig{
|
||||
Address: addr.String(),
|
||||
AddressV6: mustEncodeV6Prefix(t, newPrefix),
|
||||
}
|
||||
reset, err := e.reconcileIPv6(conf)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reset, "v6 value change must request a reset")
|
||||
assert.False(t, updateAddrCalled,
|
||||
"v6 value change must NOT call UpdateAddr — caller will recreate the interface")
|
||||
}
|
||||
|
||||
func TestReconcileIPv6_InvalidBytes_ReturnsError(t *testing.T) {
|
||||
// Corrupt PeerConfig.AddressV6 must not crash the engine and must not
|
||||
// trigger a spurious reset.
|
||||
v4 := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
e, _, applied := reconcileIPv6Fixture(t, v4)
|
||||
|
||||
conf := &mgmtProto.PeerConfig{
|
||||
Address: v4.String(),
|
||||
AddressV6: []byte{0x00}, // truncated, definitely not a valid prefix
|
||||
}
|
||||
reset, err := e.reconcileIPv6(conf)
|
||||
require.Error(t, err, "malformed v6 bytes must surface an error")
|
||||
assert.False(t, reset, "decode error must NOT request a reset")
|
||||
assert.False(t, applied.HasIPv6(), "decode error must NOT touch the interface")
|
||||
}
|
||||
|
||||
func TestReconcileIPv6_UpdateAddrError_DoesNotPropagateReset(t *testing.T) {
|
||||
// If WGIface.UpdateAddr fails (e.g. OS-side assignment error on a
|
||||
// kernel device), reconcileIPv6 returns the error to the caller for
|
||||
// logging — but it must NOT request a reset. The whole point of the
|
||||
// fix is to AVOID the reset cascade on v6 transitions.
|
||||
v4 := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
mock := &MockWGIface{
|
||||
AddressFunc: func() wgaddr.Address { return v4 },
|
||||
UpdateAddrFunc: func(_ wgaddr.Address) error { return errors.New("os refused address") },
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
e := &Engine{
|
||||
ctx: ctx,
|
||||
clientCtx: ctx,
|
||||
clientCancel: cancel,
|
||||
config: &EngineConfig{WgAddr: v4},
|
||||
wgInterface: mock,
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
}
|
||||
|
||||
v6Prefix := netip.MustParsePrefix("fd00::1/64")
|
||||
conf := &mgmtProto.PeerConfig{
|
||||
Address: v4.String(),
|
||||
AddressV6: mustEncodeV6Prefix(t, v6Prefix),
|
||||
}
|
||||
reset, err := e.reconcileIPv6(conf)
|
||||
require.Error(t, err, "UpdateAddr failure must surface")
|
||||
assert.False(t, reset, "UpdateAddr failure must NOT request a reset")
|
||||
}
|
||||
|
||||
func TestUpdateConfig_V6FirstAssignment_DoesNotResetEngine(t *testing.T) {
|
||||
// The integration check: updateConfig must not return ErrResetConnection
|
||||
// when the only change between current state and the new PeerConfig is
|
||||
// "v6 added". Pre-fix this returned ErrResetConnection, tearing down
|
||||
// every listener bound on the engine's netstack.
|
||||
v4 := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
mock := &MockWGIface{
|
||||
AddressFunc: func() wgaddr.Address { return v4 },
|
||||
UpdateAddrFunc: func(_ wgaddr.Address) error { return nil },
|
||||
IsUserspaceBindFunc: func() bool { return true },
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
e := &Engine{
|
||||
ctx: ctx,
|
||||
clientCtx: ctx,
|
||||
clientCancel: cancel,
|
||||
config: &EngineConfig{WgAddr: v4, WgPort: 51820},
|
||||
wgInterface: mock,
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
statusRecorder: peer.NewRecorder("https://mgm.test"),
|
||||
}
|
||||
|
||||
v6Prefix := netip.MustParsePrefix("fd00::1/64")
|
||||
conf := &mgmtProto.PeerConfig{
|
||||
Address: v4.String(),
|
||||
AddressV6: mustEncodeV6Prefix(t, v6Prefix),
|
||||
}
|
||||
|
||||
err := e.updateConfig(conf)
|
||||
assert.NoError(t, err,
|
||||
"updateConfig MUST NOT return ErrResetConnection when v6 is added for the first time — that's the bug fix")
|
||||
assert.NotErrorIs(t, err, ErrResetConnection)
|
||||
|
||||
require.True(t, e.config.WgAddr.HasIPv6(), "engine config must record the assigned v6 after updateConfig")
|
||||
assert.Equal(t, v6Prefix.Addr(), e.config.WgAddr.IPv6)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the
|
||||
// wire field carried on LoginResponse / SyncResponse:
|
||||
//
|
||||
// - nil pointer → no info; previously-anchored deadline survives
|
||||
// - explicit zero value → "expiry disabled" sentinel; both sinks cleared
|
||||
// - valid future timestamp → new deadline propagated to both sinks
|
||||
func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
||||
newEngine := func() *Engine {
|
||||
recorder := peer.NewRecorder("")
|
||||
return &Engine{
|
||||
statusRecorder: recorder,
|
||||
sessionWatcher: sessionwatch.New(recorder),
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||
|
||||
e.ApplySessionDeadline(timestamppb.New(deadline))
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline),
|
||||
"status recorder should hold the new deadline")
|
||||
})
|
||||
|
||||
t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||
|
||||
e.ApplySessionDeadline(nil)
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded),
|
||||
"nil snapshot must not disturb the existing deadline")
|
||||
})
|
||||
|
||||
t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||
|
||||
// Explicit zero Timestamp{} (seconds=0, nanos=0) is the
|
||||
// "expiry disabled / not SSO" sentinel.
|
||||
e.ApplySessionDeadline(×tamppb.Timestamp{})
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||
"explicit zero sentinel must clear the deadline")
|
||||
})
|
||||
|
||||
t.Run("invalid timestamp clears the deadline", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||
|
||||
// Out-of-range nanos → IsValid()==false; same-meaning as the
|
||||
// disabled sentinel for downstream sinks.
|
||||
e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1})
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||
"invalid timestamp must clear the deadline")
|
||||
})
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build !js
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// newSessionWatcher returns the real SSO session expiry watcher for every
|
||||
// non-wasm build. The js/wasm build gets a no-op stub from
|
||||
// engine_sessionwatch_js.go so the sessionwatch package (and its timer
|
||||
// machinery) never links into the wasm binary.
|
||||
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
||||
return sessionwatch.New(recorder)
|
||||
}
|
||||
@@ -1,39 +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() {}
|
||||
func (noopSessionWatcher) Close() {}
|
||||
@@ -66,7 +66,6 @@ import (
|
||||
"github.com/netbirdio/netbird/route"
|
||||
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
||||
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
signal "github.com/netbirdio/netbird/shared/signal/client"
|
||||
"github.com/netbirdio/netbird/shared/signal/proto"
|
||||
@@ -1707,82 +1706,12 @@ func getPeers(e *Engine) int {
|
||||
return len(e.peerStore.PeersPubKey())
|
||||
}
|
||||
|
||||
func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte {
|
||||
t.Helper()
|
||||
b, err := netiputil.EncodePrefix(p)
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestEngine_hasIPv6Changed(t *testing.T) {
|
||||
v4Only := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
|
||||
v4v6 := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
v4v6.IPv6 = netip.MustParseAddr("fd00::1")
|
||||
v4v6.IPv6Net = netip.MustParsePrefix("fd00::1/64").Masked()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current wgaddr.Address
|
||||
confV6 []byte
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "no v6 before, no v6 now",
|
||||
current: v4Only,
|
||||
confV6: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no v6 before, v6 added",
|
||||
current: v4Only,
|
||||
confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::1/64")),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "had v6, now removed",
|
||||
current: v4v6,
|
||||
confV6: nil,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "had v6, same v6",
|
||||
current: v4v6,
|
||||
confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::1/64")),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "had v6, different v6",
|
||||
current: v4v6,
|
||||
confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::2/64")),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "same v6 addr, different prefix length",
|
||||
current: v4v6,
|
||||
confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::1/80")),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "decode error keeps status quo",
|
||||
current: v4Only,
|
||||
confV6: []byte{1, 2, 3},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
engine := &Engine{
|
||||
config: &EngineConfig{WgAddr: tt.current},
|
||||
}
|
||||
conf := &mgmtProto.PeerConfig{
|
||||
AddressV6: tt.confV6,
|
||||
}
|
||||
assert.Equal(t, tt.expected, engine.hasIPv6Changed(conf))
|
||||
})
|
||||
}
|
||||
}
|
||||
// The former TestEngine_hasIPv6Changed has been superseded by
|
||||
// engine_reconcileipv6_test.go — the underlying function (hasIPv6Changed)
|
||||
// was replaced by reconcileIPv6, which applies "v6 added" / "v6 removed"
|
||||
// in place instead of demanding a full engine reset. The behavioral
|
||||
// matrix the old test enforced is now covered, with corrected expectations,
|
||||
// by TestReconcileIPv6_* in that sibling file.
|
||||
|
||||
func TestFilterAllowedIPs(t *testing.T) {
|
||||
v4v6Addr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
|
||||
nbversion "github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -11,7 +13,7 @@ var (
|
||||
)
|
||||
|
||||
func IsSupported(agentVersion string) bool {
|
||||
if agentVersion == "development" {
|
||||
if nbversion.IsDevelopmentVersion(agentVersion) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ type connStatusInputs struct {
|
||||
iceInProgress bool // a negotiation is currently in flight
|
||||
}
|
||||
|
||||
|
||||
// ConnStatus describe the status of a peer's connection
|
||||
type ConnStatus int32
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -113,6 +111,7 @@ type LocalPeerState struct {
|
||||
PubKey string
|
||||
KernelInterface bool
|
||||
FQDN string
|
||||
WgPort int
|
||||
Routes map[string]struct{}
|
||||
}
|
||||
|
||||
@@ -192,27 +191,22 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
||||
// every private-service request) don't contend against each other.
|
||||
// Pure read methods take RLock; anything that mutates state takes Lock.
|
||||
type Status struct {
|
||||
mux sync.RWMutex
|
||||
peers map[string]State
|
||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||
signalState bool
|
||||
signalError error
|
||||
managementState bool
|
||||
managementError error
|
||||
relayStates []relay.ProbeResult
|
||||
localPeer LocalPeerState
|
||||
offlinePeers []State
|
||||
mgmAddress string
|
||||
signalAddress string
|
||||
notifier *notifier
|
||||
rosenpassEnabled bool
|
||||
rosenpassPermissive bool
|
||||
// sessionExpiresAt is the absolute UTC instant at which the peer's SSO
|
||||
// session expires. Zero when the peer is not SSO-tracked or login
|
||||
// expiration is disabled. Populated from management LoginResponse /
|
||||
// SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC
|
||||
// so the UI can show remaining time without itself talking to mgm.
|
||||
sessionExpiresAt time.Time
|
||||
mux sync.RWMutex
|
||||
peers map[string]State
|
||||
ipToKey map[string]string
|
||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||
signalState bool
|
||||
signalError error
|
||||
managementState bool
|
||||
managementError error
|
||||
relayStates []relay.ProbeResult
|
||||
localPeer LocalPeerState
|
||||
offlinePeers []State
|
||||
mgmAddress string
|
||||
signalAddress string
|
||||
notifier *notifier
|
||||
rosenpassEnabled bool
|
||||
rosenpassPermissive bool
|
||||
nsGroupStates []NSGroupState
|
||||
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
||||
lazyConnectionEnabled bool
|
||||
@@ -228,21 +222,6 @@ type Status struct {
|
||||
eventStreams map[string]chan *proto.SystemEvent
|
||||
eventQueue *EventQueue
|
||||
|
||||
// stateChangeStreams fan-out connection-state changes (connected /
|
||||
// disconnected / connecting / address change / peers list change) to
|
||||
// every active SubscribeStatus gRPC stream. Each subscriber gets a
|
||||
// buffered chan; the notifier non-blockingly pings them so a slow
|
||||
// consumer can never stall the daemon.
|
||||
stateChangeMux sync.Mutex
|
||||
stateChangeStreams map[string]chan struct{}
|
||||
|
||||
// networksRevision bumps whenever the routed-networks set or their
|
||||
// selected state changes (driven by the route manager). Surfaced in the
|
||||
// status snapshot so the UI can fingerprint on it and re-fetch
|
||||
// ListNetworks only on a real change. Atomic so the snapshot builder can
|
||||
// read it without taking mux.
|
||||
networksRevision atomic.Uint64
|
||||
|
||||
ingressGwMgr *ingressgw.Manager
|
||||
|
||||
routeIDLookup routeIDLookup
|
||||
@@ -253,10 +232,10 @@ type Status struct {
|
||||
func NewRecorder(mgmAddress string) *Status {
|
||||
return &Status{
|
||||
peers: make(map[string]State),
|
||||
ipToKey: make(map[string]string),
|
||||
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
||||
eventStreams: make(map[string]chan *proto.SystemEvent),
|
||||
eventQueue: NewEventQueue(eventQueueSize),
|
||||
stateChangeStreams: make(map[string]chan struct{}),
|
||||
offlinePeers: make([]State, 0),
|
||||
notifier: newNotifier(),
|
||||
mgmAddress: mgmAddress,
|
||||
@@ -305,6 +284,12 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
|
||||
Mux: new(sync.RWMutex),
|
||||
}
|
||||
d.peerListChangedForNotification = true
|
||||
if ipv6 != "" {
|
||||
d.ipToKey[ipv6] = peerPubKey
|
||||
}
|
||||
if ip != "" {
|
||||
d.ipToKey[ip] = peerPubKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -334,19 +319,22 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
|
||||
|
||||
// PeerStateByIP returns the full peer State for the given tunnel IP.
|
||||
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
||||
// address so dual-stack peers are reachable on either family. Returns the
|
||||
// zero State and false when no peer matches or the input is empty.
|
||||
// address so dual-stack peers are reachable on either family. Only
|
||||
// active peers are matched; peers moved into the offline slice by
|
||||
// ReplaceOfflinePeers are intentionally treated as unknown.
|
||||
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||
if ip == "" {
|
||||
return State{}, false
|
||||
}
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
|
||||
for _, state := range d.peers {
|
||||
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
|
||||
return state, true
|
||||
}
|
||||
key, ok := d.ipToKey[ip]
|
||||
if !ok {
|
||||
return State{}, false
|
||||
}
|
||||
state, ok := d.peers[key]
|
||||
if ok {
|
||||
return state, true
|
||||
}
|
||||
return State{}, false
|
||||
}
|
||||
@@ -356,12 +344,18 @@ func (d *Status) RemovePeer(peerPubKey string) error {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
|
||||
_, ok := d.peers[peerPubKey]
|
||||
p, ok := d.peers[peerPubKey]
|
||||
if !ok {
|
||||
return errors.New("no peer with to remove")
|
||||
}
|
||||
|
||||
delete(d.peers, peerPubKey)
|
||||
if mappedKey, exists := d.ipToKey[p.IP]; exists && mappedKey == peerPubKey {
|
||||
delete(d.ipToKey, p.IP)
|
||||
}
|
||||
if mappedKey, exists := d.ipToKey[p.IPv6]; exists && mappedKey == peerPubKey {
|
||||
delete(d.ipToKey, p.IPv6)
|
||||
}
|
||||
d.peerListChangedForNotification = true
|
||||
return nil
|
||||
}
|
||||
@@ -406,7 +400,6 @@ func (d *Status) UpdatePeerState(receivedState State) error {
|
||||
if notifyRouter {
|
||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -432,7 +425,6 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R
|
||||
|
||||
// todo: consider to make sense of this notification or not
|
||||
d.notifier.peerListChanged(numPeers)
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -458,7 +450,6 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error {
|
||||
|
||||
// todo: consider to make sense of this notification or not
|
||||
d.notifier.peerListChanged(numPeers)
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -508,7 +499,6 @@ func (d *Status) UpdatePeerICEState(receivedState State) error {
|
||||
if notifyRouter {
|
||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -545,7 +535,6 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error {
|
||||
if notifyRouter {
|
||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -581,7 +570,6 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error
|
||||
if notifyRouter {
|
||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -620,7 +608,6 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error {
|
||||
if notifyRouter {
|
||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -714,7 +701,6 @@ func (d *Status) FinishPeerListModifications() {
|
||||
for _, rd := range dispatches {
|
||||
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
|
||||
@@ -773,41 +759,6 @@ func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) {
|
||||
d.mux.Unlock()
|
||||
|
||||
d.notifier.localAddressChanged(fqdn, ip)
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// SetSessionExpiresAt records the absolute UTC instant at which the peer's
|
||||
// SSO session is set to expire. Pass the zero value to clear (e.g. when the
|
||||
// management server stops publishing a deadline because login expiration was
|
||||
// disabled or the peer is not SSO-tracked). Same-value updates are no-ops;
|
||||
// real changes fan out via notifyStateChange so SubscribeStatus consumers
|
||||
// pick up the new deadline on their next read.
|
||||
func (d *Status) SetSessionExpiresAt(deadline time.Time) {
|
||||
d.mux.Lock()
|
||||
if d.sessionExpiresAt.Equal(deadline) {
|
||||
d.mux.Unlock()
|
||||
return
|
||||
}
|
||||
d.sessionExpiresAt = deadline
|
||||
d.mux.Unlock()
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
|
||||
// or the zero value when no deadline is tracked. A deadline that has already
|
||||
// slipped into the past reports as "none": once the session has expired it is
|
||||
// no longer a meaningful countdown, and the sessionwatch.Watcher does not
|
||||
// arm a timer at the deadline itself to clear it (only the two pre-expiry
|
||||
// warnings). Without this guard the UI would keep painting a stale
|
||||
// "expires in …" against a moment that has passed until the next login,
|
||||
// extend, or teardown rewrote the value.
|
||||
func (d *Status) GetSessionExpiresAt() time.Time {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
|
||||
return time.Time{}
|
||||
}
|
||||
return d.sessionExpiresAt
|
||||
}
|
||||
|
||||
// AddLocalPeerStateRoute adds a route to the local peer state
|
||||
@@ -876,19 +827,11 @@ func (d *Status) CleanLocalPeerState() {
|
||||
d.mux.Unlock()
|
||||
|
||||
d.notifier.localAddressChanged(fqdn, ip)
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// MarkManagementDisconnected sets ManagementState to disconnected
|
||||
func (d *Status) MarkManagementDisconnected(err error) {
|
||||
d.mux.Lock()
|
||||
// Health checks re-mark the same state on every probe; skip the fan-out
|
||||
// when nothing actually changed so we don't flood SubscribeStatus
|
||||
// consumers with identical snapshots.
|
||||
if !d.managementState && errors.Is(d.managementError, err) {
|
||||
d.mux.Unlock()
|
||||
return
|
||||
}
|
||||
d.managementState = false
|
||||
d.managementError = err
|
||||
mgm := d.managementState
|
||||
@@ -896,16 +839,11 @@ func (d *Status) MarkManagementDisconnected(err error) {
|
||||
d.mux.Unlock()
|
||||
|
||||
d.notifier.updateServerStates(mgm, sig)
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// MarkManagementConnected sets ManagementState to connected
|
||||
func (d *Status) MarkManagementConnected() {
|
||||
d.mux.Lock()
|
||||
if d.managementState && d.managementError == nil {
|
||||
d.mux.Unlock()
|
||||
return
|
||||
}
|
||||
d.managementState = true
|
||||
d.managementError = nil
|
||||
mgm := d.managementState
|
||||
@@ -913,7 +851,6 @@ func (d *Status) MarkManagementConnected() {
|
||||
d.mux.Unlock()
|
||||
|
||||
d.notifier.updateServerStates(mgm, sig)
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// UpdateSignalAddress update the address of the signal server
|
||||
@@ -947,10 +884,6 @@ func (d *Status) UpdateLazyConnection(enabled bool) {
|
||||
// MarkSignalDisconnected sets SignalState to disconnected
|
||||
func (d *Status) MarkSignalDisconnected(err error) {
|
||||
d.mux.Lock()
|
||||
if !d.signalState && errors.Is(d.signalError, err) {
|
||||
d.mux.Unlock()
|
||||
return
|
||||
}
|
||||
d.signalState = false
|
||||
d.signalError = err
|
||||
mgm := d.managementState
|
||||
@@ -958,16 +891,11 @@ func (d *Status) MarkSignalDisconnected(err error) {
|
||||
d.mux.Unlock()
|
||||
|
||||
d.notifier.updateServerStates(mgm, sig)
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// MarkSignalConnected sets SignalState to connected
|
||||
func (d *Status) MarkSignalConnected() {
|
||||
d.mux.Lock()
|
||||
if d.signalState && d.signalError == nil {
|
||||
d.mux.Unlock()
|
||||
return
|
||||
}
|
||||
d.signalState = true
|
||||
d.signalError = nil
|
||||
mgm := d.managementState
|
||||
@@ -975,7 +903,6 @@ func (d *Status) MarkSignalConnected() {
|
||||
d.mux.Unlock()
|
||||
|
||||
d.notifier.updateServerStates(mgm, sig)
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
|
||||
@@ -1097,14 +1024,17 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
|
||||
return d.relayStates
|
||||
}
|
||||
|
||||
// extend the list of stun, turn servers with relay address
|
||||
// extend the list of stun, turn servers with the relay server connections
|
||||
relayStates := slices.Clone(d.relayStates)
|
||||
|
||||
// if the server connection is not established then we will use the general address
|
||||
// in case of connection we will use the instance specific address
|
||||
instanceAddr, _, err := d.relayMgr.RelayInstanceAddress()
|
||||
if err != nil {
|
||||
// TODO add their status
|
||||
states := d.relayMgr.RelayStates()
|
||||
if len(states) == 0 {
|
||||
// no relay connection tracked yet; surface configured servers as
|
||||
// unavailable with the real reconnect error when known
|
||||
err := relayClient.ErrRelayClientNotConnected
|
||||
if connErr := d.relayMgr.RelayConnectError(); connErr != nil {
|
||||
err = connErr
|
||||
}
|
||||
for _, r := range d.relayMgr.ServerURLs() {
|
||||
relayStates = append(relayStates, relay.ProbeResult{
|
||||
URI: r,
|
||||
@@ -1114,10 +1044,14 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
|
||||
return relayStates
|
||||
}
|
||||
|
||||
relayState := relay.ProbeResult{
|
||||
URI: instanceAddr,
|
||||
for _, rs := range states {
|
||||
relayStates = append(relayStates, relay.ProbeResult{
|
||||
URI: rs.URL,
|
||||
Err: rs.Err,
|
||||
Transport: rs.Transport,
|
||||
})
|
||||
}
|
||||
return append(relayStates, relayState)
|
||||
return relayStates
|
||||
}
|
||||
|
||||
func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
||||
@@ -1173,19 +1107,16 @@ func (d *Status) GetFullStatus() FullStatus {
|
||||
// ClientStart will notify all listeners about the new service state
|
||||
func (d *Status) ClientStart() {
|
||||
d.notifier.clientStart()
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// ClientStop will notify all listeners about the new service state
|
||||
func (d *Status) ClientStop() {
|
||||
d.notifier.clientStop()
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// ClientTeardown will notify all listeners about the service is under teardown
|
||||
func (d *Status) ClientTeardown() {
|
||||
d.notifier.clientTearDown()
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// SetConnectionListener set a listener to the notifier
|
||||
@@ -1327,82 +1258,6 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent {
|
||||
return d.eventQueue.GetAll()
|
||||
}
|
||||
|
||||
// SubscribeToStateChanges hands back a channel that receives a tick on
|
||||
// every connection-state change (connected / disconnected / connecting /
|
||||
// address change / peers-list change). The channel is buffered to one
|
||||
// pending tick so a coalesced burst still wakes the consumer exactly
|
||||
// once. Pass the returned id to UnsubscribeFromStateChanges to detach.
|
||||
func (d *Status) SubscribeToStateChanges() (string, <-chan struct{}) {
|
||||
d.stateChangeMux.Lock()
|
||||
defer d.stateChangeMux.Unlock()
|
||||
|
||||
id := uuid.New().String()
|
||||
ch := make(chan struct{}, 1)
|
||||
d.stateChangeStreams[id] = ch
|
||||
return id, ch
|
||||
}
|
||||
|
||||
// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel
|
||||
// and closes it so any consumer goroutine selecting on the channel
|
||||
// unblocks cleanly.
|
||||
func (d *Status) UnsubscribeFromStateChanges(id string) {
|
||||
d.stateChangeMux.Lock()
|
||||
defer d.stateChangeMux.Unlock()
|
||||
|
||||
if ch, ok := d.stateChangeStreams[id]; ok {
|
||||
close(ch)
|
||||
delete(d.stateChangeStreams, id)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops
|
||||
// the tick if a subscriber's buffer is full — by definition the consumer
|
||||
// is already going to fetch the latest snapshot, so multiple pending ticks
|
||||
// would be redundant.
|
||||
func (d *Status) notifyStateChange() {
|
||||
if _, file, line, ok := runtime.Caller(1); ok {
|
||||
log.Infof("--- notifyStateChange from %s:%d", file, line)
|
||||
}
|
||||
d.stateChangeMux.Lock()
|
||||
defer d.stateChangeMux.Unlock()
|
||||
|
||||
for _, ch := range d.stateChangeStreams {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyStateChange is the public wake-the-subscribers entry point used by
|
||||
// callers that mutate state outside the peer recorder — most importantly
|
||||
// the connect-state machine, which writes StatusNeedsLogin into the
|
||||
// shared contextState (client/internal/state.go) without touching any
|
||||
// recorder field. Without this push the SubscribeStatus stream stays on
|
||||
// the previous snapshot until an unrelated peer/management/signal
|
||||
// change happens to fire notifyStateChange, leaving the UI's status
|
||||
// out of sync with the daemon.
|
||||
func (d *Status) NotifyStateChange() {
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// BumpNetworksRevision increments the routed-networks revision and wakes every
|
||||
// SubscribeStatus subscriber. The route manager calls it when a network map
|
||||
// changes the available routes or when a selection is applied — the peer
|
||||
// status itself only records actively-routed (chosen) networks, so without
|
||||
// this bump a candidate route appearing/disappearing would never reach the UI.
|
||||
func (d *Status) BumpNetworksRevision() {
|
||||
d.networksRevision.Add(1)
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// GetNetworksRevision returns the current routed-networks revision, surfaced in
|
||||
// the status snapshot so the UI can detect route/selection changes (see
|
||||
// BumpNetworksRevision).
|
||||
func (d *Status) GetNetworksRevision() uint64 {
|
||||
return d.networksRevision.Load()
|
||||
}
|
||||
|
||||
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
@@ -1518,6 +1373,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
|
||||
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
|
||||
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
|
||||
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
|
||||
pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
|
||||
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
|
||||
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
|
||||
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
|
||||
@@ -1556,6 +1412,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
|
||||
pbRelayState := &proto.RelayState{
|
||||
URI: relayState.URI,
|
||||
Available: relayState.Err == nil,
|
||||
Transport: relayState.Transport,
|
||||
}
|
||||
if err := relayState.Err; err != nil {
|
||||
pbRelayState.Error = err.Error()
|
||||
|
||||
@@ -90,6 +90,45 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
|
||||
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
||||
}
|
||||
|
||||
// TestStatus_PeerStateByIP_IgnoresOfflinePeers documents that peers
|
||||
// moved into the offline slice via ReplaceOfflinePeers are intentionally
|
||||
// not resolvable by IP: only active peers can carry traffic, so callers
|
||||
// (DNS filter, embed.Client.IdentityForIP) treat them as unknown.
|
||||
func TestStatus_PeerStateByIP_IgnoresOfflinePeers(t *testing.T) {
|
||||
status := NewRecorder("https://mgm")
|
||||
req := require.New(t)
|
||||
|
||||
status.ReplaceOfflinePeers([]State{
|
||||
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
|
||||
})
|
||||
|
||||
_, ok := status.PeerStateByIP("100.64.0.20")
|
||||
req.False(ok, "offline peer must not resolve by IPv4 tunnel address")
|
||||
|
||||
_, ok = status.PeerStateByIP("fd00::20")
|
||||
req.False(ok, "offline peer must not resolve by IPv6 tunnel address")
|
||||
}
|
||||
|
||||
// TestStatus_PeerStateByIP_RemovedPeer verifies RemovePeer drops the
|
||||
// IP index entries for both address families.
|
||||
func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) {
|
||||
status := NewRecorder("https://mgm")
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
|
||||
|
||||
_, ok := status.PeerStateByIP("100.64.0.10")
|
||||
req.True(ok, "active peer must resolve before removal")
|
||||
|
||||
req.NoError(status.RemovePeer("pk-1"))
|
||||
|
||||
_, ok = status.PeerStateByIP("100.64.0.10")
|
||||
req.False(ok, "removed peer must not resolve by IPv4 tunnel address")
|
||||
|
||||
_, ok = status.PeerStateByIP("fd00::1")
|
||||
req.False(ok, "removed peer must not resolve by IPv6 tunnel address")
|
||||
}
|
||||
|
||||
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
||||
key := "abc"
|
||||
fqdn := "peer-a.netbird.local"
|
||||
@@ -275,39 +314,3 @@ func TestGetFullStatus(t *testing.T) {
|
||||
assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal")
|
||||
assert.ElementsMatch(t, []State{peerState1, peerState2}, fullStatus.Peers, "peers states should match")
|
||||
}
|
||||
|
||||
// notified reports whether a state-change tick is pending on ch, draining it.
|
||||
func notified(ch <-chan struct{}) bool {
|
||||
select {
|
||||
case <-ch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkServerStateDoesNotNotifyWhenUnchanged(t *testing.T) {
|
||||
status := NewRecorder("https://mgm")
|
||||
_, ch := status.SubscribeToStateChanges()
|
||||
|
||||
// First transition is a real change and must notify.
|
||||
status.MarkManagementConnected()
|
||||
require.True(t, notified(ch), "first connect should notify")
|
||||
|
||||
// Re-marking the same state must not notify again.
|
||||
status.MarkManagementConnected()
|
||||
assert.False(t, notified(ch), "redundant connect should not notify")
|
||||
|
||||
// Same for signal.
|
||||
status.MarkSignalConnected()
|
||||
require.True(t, notified(ch), "first signal connect should notify")
|
||||
status.MarkSignalConnected()
|
||||
assert.False(t, notified(ch), "redundant signal connect should not notify")
|
||||
|
||||
// A genuine change (disconnect with an error) notifies again.
|
||||
err := errors.New("boom")
|
||||
status.MarkManagementDisconnected(err)
|
||||
require.True(t, notified(ch), "disconnect should notify")
|
||||
status.MarkManagementDisconnected(err)
|
||||
assert.False(t, notified(ch), "redundant disconnect should not notify")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -165,10 +164,6 @@ func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HA
|
||||
return
|
||||
}
|
||||
|
||||
if candidateViaRoutes(candidate, haRoutes) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.agent.AddRemoteCandidate(candidate); err != nil {
|
||||
w.log.Errorf("error while handling remote candidate")
|
||||
return
|
||||
@@ -589,34 +584,6 @@ func extraSrflxCandidate(candidate ice.Candidate) (*ice.CandidateServerReflexive
|
||||
return ec, nil
|
||||
}
|
||||
|
||||
func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool {
|
||||
addr, err := netip.ParseAddr(candidate.Address())
|
||||
if err != nil {
|
||||
log.Errorf("Failed to parse IP address %s: %v", candidate.Address(), err)
|
||||
return false
|
||||
}
|
||||
|
||||
var routePrefixes []netip.Prefix
|
||||
for _, routes := range clientRoutes {
|
||||
if len(routes) > 0 && routes[0] != nil {
|
||||
routePrefixes = append(routePrefixes, routes[0].Network)
|
||||
}
|
||||
}
|
||||
|
||||
for _, prefix := range routePrefixes {
|
||||
// default route is handled by route exclusion / ip rules
|
||||
if prefix.Bits() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if prefix.Contains(addr) {
|
||||
log.Debugf("Ignoring candidate [%s], its address is part of routed network %s", candidate.String(), prefix)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isRelayCandidate(candidate ice.Candidate) bool {
|
||||
return candidate.Type() == ice.CandidateTypeRelay
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -57,6 +58,10 @@ var DefaultInterfaceBlacklist = []string{
|
||||
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
||||
}
|
||||
|
||||
// loadMDMPolicy is the package-level indirection used by apply() to read the
|
||||
// active MDM policy. Tests override this to inject a fake policy.
|
||||
var loadMDMPolicy = mdm.LoadPolicy
|
||||
|
||||
// ConfigInput carries configuration changes to the client
|
||||
type ConfigInput struct {
|
||||
ManagementURL string
|
||||
@@ -103,6 +108,10 @@ type ConfigInput struct {
|
||||
|
||||
// Config Configuration type
|
||||
type Config struct {
|
||||
// Name is the human-readable profile name shown in CLI/UI listings.
|
||||
// It is independent of the profile's on-disk filename (which is the ID).
|
||||
Name string
|
||||
|
||||
// Wireguard private key of local peer
|
||||
PrivateKey string
|
||||
PreSharedKey string
|
||||
@@ -174,6 +183,23 @@ type Config struct {
|
||||
LazyConnectionEnabled bool
|
||||
|
||||
MTU uint16
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values for
|
||||
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
|
||||
// and reset on every apply() invocation. Never persisted to disk.
|
||||
// Callers query enforcement state via Policy() and the mdm.Policy API
|
||||
// (HasKey, ManagedKeys, IsEmpty).
|
||||
policy *mdm.Policy `json:"-"`
|
||||
}
|
||||
|
||||
// Policy returns the MDM policy applied to this Config. Returns a non-nil
|
||||
// empty Policy when MDM enforcement is inactive; callers can always invoke
|
||||
// HasKey / ManagedKeys / IsEmpty without a nil check.
|
||||
func (config *Config) Policy() *mdm.Policy {
|
||||
if config == nil || config.policy == nil {
|
||||
return mdm.NewPolicy(nil)
|
||||
}
|
||||
return config.policy
|
||||
}
|
||||
|
||||
var ConfigDirOverride string
|
||||
@@ -248,6 +274,16 @@ func createNewConfig(input ConfigInput) (*Config, error) {
|
||||
}
|
||||
|
||||
func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
if config.Name != "" {
|
||||
sanitized, err := sanitizeDisplayName(config.Name)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid profile name: %w", err)
|
||||
}
|
||||
if sanitized != config.Name {
|
||||
config.Name = sanitized
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
if config.ManagementURL == nil {
|
||||
log.Infof("using default Management URL %s", DefaultManagementURL)
|
||||
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
|
||||
@@ -612,10 +648,93 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
// MDM is the last override layer: any key present in the policy
|
||||
// supersedes defaults, on-disk config, env vars and CLI input.
|
||||
config.applyMDMPolicy(loadMDMPolicy())
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// parseURL parses and validates a service URL
|
||||
// applyMDMPolicy overlays MDM-supplied values on top of the resolved Config.
|
||||
// The provided Policy is also stored on the Config so callers can later query
|
||||
// which fields are enforced. Invalid values (e.g. malformed URLs) are logged
|
||||
// and skipped to avoid bricking the client; the field keeps its previous
|
||||
// resolved value but is still marked as managed (Policy.HasKey returns true
|
||||
// for the key, so per-field rejection of user writes still applies).
|
||||
func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
config.policy = policy
|
||||
if policy.IsEmpty() {
|
||||
return
|
||||
}
|
||||
|
||||
// Helper: log the application of a single MDM-managed key. Values for
|
||||
// keys in mdm.SecretKeys are redacted.
|
||||
logApplied := func(key string, displayValue any) {
|
||||
if _, secret := mdm.SecretKeys[key]; secret {
|
||||
log.Infof("MDM override %s = ********** (secret)", key)
|
||||
return
|
||||
}
|
||||
log.Infof("MDM override %s = %v", key, displayValue)
|
||||
}
|
||||
|
||||
if v, ok := policy.GetString(mdm.KeyManagementURL); ok {
|
||||
if u, err := parseURL("Management URL", v); err != nil {
|
||||
log.Warnf("MDM management URL %q invalid: %v; keeping previous value", v, err)
|
||||
} else {
|
||||
config.ManagementURL = u
|
||||
logApplied(mdm.KeyManagementURL, u.String())
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := policy.GetString(mdm.KeyPreSharedKey); ok {
|
||||
// Defensive: refuse the redaction mask in case it round-tripped
|
||||
// through a manifest by mistake.
|
||||
if !isPreSharedKeyHidden(&v) {
|
||||
config.PreSharedKey = v
|
||||
logApplied(mdm.KeyPreSharedKey, "")
|
||||
}
|
||||
}
|
||||
|
||||
// applyBool collapses the per-key "read + set + log" boilerplate
|
||||
// for every plain bool MDM key into a single helper. Keeps the
|
||||
// outer function's cognitive complexity below SonarCube's
|
||||
// threshold; functional behaviour is identical to the inlined
|
||||
// branches it replaces.
|
||||
applyBool := func(key string, setter func(bool)) {
|
||||
v, ok := policy.GetBool(key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
setter(v)
|
||||
logApplied(key, v)
|
||||
}
|
||||
|
||||
applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
|
||||
applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
|
||||
applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
|
||||
applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
|
||||
applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
|
||||
applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
|
||||
applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
|
||||
|
||||
if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
|
||||
// REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
|
||||
// upper bound and reject obviously-invalid values to avoid the
|
||||
// engine binding to an unusable port if the admin pushes garbage.
|
||||
if v >= 1 && v <= 65535 {
|
||||
config.WgPort = int(v)
|
||||
logApplied(mdm.KeyWireguardPort, v)
|
||||
} else {
|
||||
log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseURL parses and validates the URL for the named service. The URL
|
||||
// must use the http or https scheme; if no port is present, ":443" is
|
||||
// appended for https or ":80" for http. The serviceName parameter is
|
||||
// used to contextualise error messages. On success returns the parsed
|
||||
// *url.URL; on failure returns a non-nil error.
|
||||
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
||||
if err != nil {
|
||||
|
||||
152
client/internal/profilemanager/config_mdm_test.go
Normal file
152
client/internal/profilemanager/config_mdm_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
|
||||
// apply() observes the supplied Policy. The original loader is restored at
|
||||
// test cleanup.
|
||||
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
||||
t.Helper()
|
||||
prev := loadMDMPolicy
|
||||
loadMDMPolicy = func() *mdm.Policy { return policy }
|
||||
t.Cleanup(func() { loadMDMPolicy = prev })
|
||||
}
|
||||
|
||||
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
|
||||
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
assert.Empty(t, cfg.Policy().ManagedKeys())
|
||||
|
||||
// Default management URL still resolves.
|
||||
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||
}
|
||||
|
||||
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
|
||||
const mdmURL = "https://corp.mdm.example.com:443"
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
mdm.KeyDisableClientRoutes: true,
|
||||
mdm.KeyBlockInbound: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
assert.True(t, cfg.DisableClientRoutes)
|
||||
assert.True(t, cfg.BlockInbound)
|
||||
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyBlockInbound))
|
||||
assert.False(t, cfg.Policy().HasKey(mdm.KeyAllowServerSSH))
|
||||
}
|
||||
|
||||
func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
||||
const mdmURL = "https://mdm.example.com:443"
|
||||
const cliURL = "https://cli.example.com:443"
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
ManagementURL: cliURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// MDM wins over CLI-supplied management URL.
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
}
|
||||
|
||||
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: "not-a-url",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Invalid MDM URL is logged and skipped: default URL stays in place
|
||||
// to keep the client functional.
|
||||
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||
|
||||
// But the key is still considered MDM-managed (admin intent is to
|
||||
// enforce, daemon rejects user writes to this field — phase-1 scaffolding
|
||||
// reflects this by keeping Policy.HasKey true even on parse failure).
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
}
|
||||
|
||||
func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
DisableClientRoutes: boolPtr(false),
|
||||
RosenpassEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now enable MDM enforcement for these keys.
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyDisableClientRoutes: true,
|
||||
mdm.KeyRosenpassEnabled: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
|
||||
assert.True(t, cfg.RosenpassEnabled)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
|
||||
}
|
||||
|
||||
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
||||
const maskSentinel = "**********"
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyPreSharedKey: maskSentinel,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Mask sentinel must not be persisted as the actual PSK.
|
||||
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
|
||||
// Key still marked managed so user writes are still rejected.
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
118
client/internal/profilemanager/id.go
Normal file
118
client/internal/profilemanager/id.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// profileIDByteLen is the number of random bytes generated for a new
|
||||
// profile ID. The resulting hex string is twice this length.
|
||||
profileIDByteLen = 16
|
||||
|
||||
// shortIDLen is the number of leading characters of an ID we render in
|
||||
// list output. Profiles per device are few, so 8 chars is collision-safe
|
||||
// in practice and easy to type as a prefix.
|
||||
shortIDLen = 8
|
||||
|
||||
// maxProfileNameLen caps the human-readable profile name to keep table
|
||||
// output legible and prevent denial-of-service via huge JSON fields.
|
||||
maxProfileNameLen = 128
|
||||
|
||||
// maxProfileIDLen bounds the on-disk filename we'll accept. New
|
||||
// IDs are 32 hex chars, legacy stems are sanitized profile names. The
|
||||
// cap is generous enough to cover both without permitting absurdly
|
||||
// long filenames.
|
||||
maxProfileIDLen = 64
|
||||
)
|
||||
|
||||
type ID string
|
||||
|
||||
// generateProfileID returns a new random hex ID for a profile file.
|
||||
func generateProfileID() (ID, error) {
|
||||
buf := make([]byte, profileIDByteLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("read random bytes: %w", err)
|
||||
}
|
||||
return ID(hex.EncodeToString(buf)), nil
|
||||
}
|
||||
|
||||
// IsValidProfileFilenameStem reports whether id is safe to use as the stem
|
||||
// of a profile JSON filename.
|
||||
func IsValidProfileFilenameStem(id ID) bool {
|
||||
s := id.String()
|
||||
if s == "" || len(s) > maxProfileIDLen {
|
||||
return false
|
||||
}
|
||||
if s == defaultProfileName {
|
||||
return true
|
||||
}
|
||||
if strings.ContainsAny(s, `/\`) || strings.Contains(s, "..") {
|
||||
return false
|
||||
}
|
||||
// filepath.Base catches any leftover separators on platforms with
|
||||
// exotic path conventions.
|
||||
if filepath.Base(s) != s {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sanitizeDisplayName normalizes a user-supplied profile display name for
|
||||
// storage. It strips ASCII control characters, rejects invalid UTF-8, and
|
||||
// caps the length. Emojis, spaces, punctuation, and non-ASCII letters are
|
||||
// preserved. Returns an error if nothing usable remains.
|
||||
func sanitizeDisplayName(name string) (string, error) {
|
||||
if !utf8.ValidString(name) {
|
||||
return "", fmt.Errorf("name is not valid UTF-8")
|
||||
}
|
||||
name = StripCtrlChars(name)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("name is empty after sanitization")
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxProfileNameLen {
|
||||
return "", fmt.Errorf("name exceeds %d characters", maxProfileNameLen)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// StripCtrlChars control characters from a name before printing it.
|
||||
func StripCtrlChars(name string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(name))
|
||||
for _, r := range name {
|
||||
// Skip C0 controls and DEL, plus C1 controls (0x80–0x9F).
|
||||
if r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ShortID truncates an ID for display.
|
||||
func (id ID) ShortID() string {
|
||||
if id == DefaultProfileName {
|
||||
return DefaultProfileName
|
||||
}
|
||||
runes := []rune(id)
|
||||
if len(runes) <= shortIDLen {
|
||||
return id.String()
|
||||
}
|
||||
return string(runes[:shortIDLen])
|
||||
}
|
||||
|
||||
func (id ID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
@@ -19,19 +19,41 @@ const (
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Name string
|
||||
// ID is the on-disk filename stem (without .json). For new profiles
|
||||
// it is a 32-char hex string; legacy profiles created before the
|
||||
// ID-keyed layout keep their original name as their ID. The reserved
|
||||
// value "default" identifies the special default profile.
|
||||
ID ID
|
||||
// Name is the human-readable display name. Falls back to ID when the
|
||||
// underlying JSON has no "name" field set.
|
||||
Name string
|
||||
// Path is the absolute path to the profile JSON. Populated by the
|
||||
// loader so callers do not have to reconstruct it from ID + dir.
|
||||
Path string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
func (p *Profile) FilePath() (string, error) {
|
||||
if p.Name == "" {
|
||||
return "", fmt.Errorf("active profile name is empty")
|
||||
if p.Path != "" {
|
||||
return p.Path, nil
|
||||
}
|
||||
|
||||
if p.Name == defaultProfileName {
|
||||
id := p.ID
|
||||
if id == "" {
|
||||
id = ID(p.Name)
|
||||
}
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("profile ID is empty")
|
||||
}
|
||||
|
||||
if id == defaultProfileName {
|
||||
return DefaultConfigPath, nil
|
||||
}
|
||||
|
||||
if !IsValidProfileFilenameStem(id) {
|
||||
return "", fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
|
||||
username, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get current user: %w", err)
|
||||
@@ -42,10 +64,13 @@ func (p *Profile) FilePath() (string, error) {
|
||||
return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err)
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, p.Name+".json"), nil
|
||||
return filepath.Join(configDir, id.String()+".json"), nil
|
||||
}
|
||||
|
||||
func (p *Profile) IsDefault() bool {
|
||||
if p.ID != "" {
|
||||
return p.ID == defaultProfileName
|
||||
}
|
||||
return p.Name == defaultProfileName
|
||||
}
|
||||
|
||||
@@ -57,18 +82,24 @@ func NewProfileManager() *ProfileManager {
|
||||
return &ProfileManager{}
|
||||
}
|
||||
|
||||
// GetActiveProfile returns the active profile as recorded in the local
|
||||
// user state file. Only ID is populated.
|
||||
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
prof := pm.getActiveProfileState()
|
||||
return &Profile{Name: prof}, nil
|
||||
id := pm.getActiveProfileState()
|
||||
return &Profile{ID: id}, nil
|
||||
}
|
||||
|
||||
func (pm *ProfileManager) SwitchProfile(profileName string) error {
|
||||
profileName = sanitizeProfileName(profileName)
|
||||
// SwitchProfile records the given profile ID as active in the local user
|
||||
// state file.
|
||||
func (pm *ProfileManager) SwitchProfile(id ID) error {
|
||||
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||
return fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
|
||||
if err := pm.setActiveProfileState(profileName); err != nil {
|
||||
if err := pm.setActiveProfileState(id); err != nil {
|
||||
return fmt.Errorf("failed to switch profile: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -85,7 +116,7 @@ func sanitizeProfileName(name string) string {
|
||||
}, name)
|
||||
}
|
||||
|
||||
func (pm *ProfileManager) getActiveProfileState() string {
|
||||
func (pm *ProfileManager) getActiveProfileState() ID {
|
||||
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
@@ -113,10 +144,10 @@ func (pm *ProfileManager) getActiveProfileState() string {
|
||||
return defaultProfileName
|
||||
}
|
||||
|
||||
return profileName
|
||||
return ID(profileName)
|
||||
}
|
||||
|
||||
func (pm *ProfileManager) setActiveProfileState(profileName string) error {
|
||||
func (pm *ProfileManager) setActiveProfileState(id ID) error {
|
||||
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
@@ -125,7 +156,7 @@ func (pm *ProfileManager) setActiveProfileState(profileName string) error {
|
||||
|
||||
statePath := filepath.Join(configDir, activeProfileStateFilename)
|
||||
|
||||
err = os.WriteFile(statePath, []byte(profileName), 0600)
|
||||
err = os.WriteFile(statePath, []byte(id), 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write active profile state: %w", err)
|
||||
}
|
||||
@@ -142,7 +173,7 @@ func GetLoginHint() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
profileState, err := pm.GetProfileState(activeProf.Name)
|
||||
profileState, err := pm.GetProfileState(activeProf.ID)
|
||||
if err != nil {
|
||||
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||
return ""
|
||||
|
||||
@@ -50,14 +50,14 @@ func TestServiceManager_CreateAndGetDefaultProfile(t *testing.T) {
|
||||
|
||||
state, err := sm.GetActiveProfileState()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, state.Name, defaultProfileName) // No active profile state yet
|
||||
assert.Equal(t, defaultProfileName, state.ID.String()) // No active profile state yet
|
||||
|
||||
err = sm.SetActiveProfileStateToDefault()
|
||||
assert.NoError(t, err)
|
||||
|
||||
active, err := sm.GetActiveProfileState()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "default", active.Name)
|
||||
assert.Equal(t, "default", active.ID.String())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -92,14 +92,14 @@ func TestServiceManager_SetActiveProfileState(t *testing.T) {
|
||||
currUser, err := user.Current()
|
||||
assert.NoError(t, err)
|
||||
sm := &ServiceManager{}
|
||||
state := &ActiveProfileState{Name: "foo", Username: currUser.Username}
|
||||
state := &ActiveProfileState{ID: "foo", Username: currUser.Username}
|
||||
err = sm.SetActiveProfileState(state)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should error on nil or incomplete state
|
||||
err = sm.SetActiveProfileState(nil)
|
||||
assert.Error(t, err)
|
||||
err = sm.SetActiveProfileState(&ActiveProfileState{Name: "", Username: ""})
|
||||
err = sm.SetActiveProfileState(&ActiveProfileState{ID: "", Username: ""})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package profilemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -23,12 +24,43 @@ var (
|
||||
DefaultConfigPathDir = ""
|
||||
DefaultConfigPath = ""
|
||||
ActiveProfileStatePath = ""
|
||||
)
|
||||
|
||||
var (
|
||||
ErrorOldDefaultConfigNotFound = errors.New("old default config not found")
|
||||
)
|
||||
|
||||
// ErrAmbiguousHandle is returned when a profile handle (ID prefix or name)
|
||||
// matches more than one profile. Callers can render Candidates to help the
|
||||
// user disambiguate.
|
||||
type ErrAmbiguousHandle struct {
|
||||
Handle string
|
||||
Candidates []Profile
|
||||
Kind AmbiguityKind
|
||||
}
|
||||
|
||||
// AmbiguityKind describes which matcher produced the ambiguity, so callers
|
||||
// can tailor the error message.
|
||||
type AmbiguityKind int
|
||||
|
||||
const (
|
||||
AmbiguityKindIDPrefix AmbiguityKind = iota
|
||||
AmbiguityKindName
|
||||
)
|
||||
|
||||
// profileMeta is the minimal slice of a profile JSON we need, so we avoid
|
||||
// reading all fields
|
||||
type profileMeta struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (e *ErrAmbiguousHandle) Error() string {
|
||||
switch e.Kind {
|
||||
case AmbiguityKindIDPrefix:
|
||||
return fmt.Sprintf("ID prefix %q is ambiguous (matches %d profiles)", e.Handle, len(e.Candidates))
|
||||
default:
|
||||
return fmt.Sprintf("name %q is ambiguous (%d profiles share this name)", e.Handle, len(e.Candidates))
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
DefaultConfigPathDir = "/var/lib/netbird/"
|
||||
@@ -54,25 +86,34 @@ func init() {
|
||||
}
|
||||
|
||||
type ActiveProfileState struct {
|
||||
Name string `json:"name"`
|
||||
// ID is the on-disk filename stem of the active profile. The JSON tag stays
|
||||
// as "name" for backwards compatibility with active state files written
|
||||
// before the ID-based config files. Legacy values were profile names, which
|
||||
// were also the legacy filename stems, so they still resolve to the correct
|
||||
// file on disk.
|
||||
ID ID `json:"name"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (a *ActiveProfileState) FilePath() (string, error) {
|
||||
if a.Name == "" {
|
||||
return "", fmt.Errorf("active profile name is empty")
|
||||
if a.ID == "" {
|
||||
return "", fmt.Errorf("active profile ID is empty")
|
||||
}
|
||||
|
||||
if a.Name == defaultProfileName {
|
||||
if a.ID == defaultProfileName {
|
||||
return DefaultConfigPath, nil
|
||||
}
|
||||
|
||||
if !IsValidProfileFilenameStem(a.ID) {
|
||||
return "", fmt.Errorf("invalid profile ID: %q", a.ID)
|
||||
}
|
||||
|
||||
configDir, err := getConfigDirForUser(a.Username)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err)
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, a.Name+".json"), nil
|
||||
return filepath.Join(configDir, a.ID.String()+".json"), nil
|
||||
}
|
||||
|
||||
type ServiceManager struct {
|
||||
@@ -178,7 +219,7 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
|
||||
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
|
||||
}
|
||||
return &ActiveProfileState{
|
||||
Name: "default",
|
||||
ID: defaultProfileName,
|
||||
Username: "",
|
||||
}, nil
|
||||
} else {
|
||||
@@ -186,12 +227,12 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if activeProfile.Name == "" {
|
||||
if activeProfile.ID == "" {
|
||||
if err := s.SetActiveProfileStateToDefault(); err != nil {
|
||||
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
|
||||
}
|
||||
return &ActiveProfileState{
|
||||
Name: "default",
|
||||
ID: defaultProfileName,
|
||||
Username: "",
|
||||
}, nil
|
||||
}
|
||||
@@ -216,25 +257,29 @@ func (s *ServiceManager) setDefaultActiveState() error {
|
||||
}
|
||||
|
||||
func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error {
|
||||
if a == nil || a.Name == "" {
|
||||
if a == nil || a.ID == "" {
|
||||
return errors.New("invalid active profile state")
|
||||
}
|
||||
|
||||
if a.Name != defaultProfileName && a.Username == "" {
|
||||
return fmt.Errorf("username must be set for non-default profiles, got: %s", a.Name)
|
||||
if a.ID != defaultProfileName && a.Username == "" {
|
||||
return fmt.Errorf("username must be set for non-default profiles, got: %s", a.ID)
|
||||
}
|
||||
|
||||
if a.ID != defaultProfileName && !IsValidProfileFilenameStem(a.ID) {
|
||||
return fmt.Errorf("invalid profile ID: %q", a.ID)
|
||||
}
|
||||
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil {
|
||||
return fmt.Errorf("failed to write active profile state: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("active profile set to %s for %s", a.Name, a.Username)
|
||||
log.Infof("active profile set to %s for %s", a.ID, a.Username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServiceManager) SetActiveProfileStateToDefault() error {
|
||||
return s.SetActiveProfileState(&ActiveProfileState{
|
||||
Name: "default",
|
||||
ID: defaultProfileName,
|
||||
Username: "",
|
||||
})
|
||||
}
|
||||
@@ -243,57 +288,117 @@ func (s *ServiceManager) DefaultProfilePath() string {
|
||||
return DefaultConfigPath
|
||||
}
|
||||
|
||||
func (s *ServiceManager) AddProfile(profileName, username string) error {
|
||||
// AddProfile creates a new profile with a generated ID. The user-supplied
|
||||
// displayName is stored inside the JSON's name field, the on-disk filename
|
||||
// uses the generated ID.
|
||||
//
|
||||
// The returned Profile carries the freshly-generated ID so callers can
|
||||
// show it to the user (and so the gRPC AddProfileResponse can include
|
||||
// it).
|
||||
func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) {
|
||||
configDir, err := s.getConfigDir(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config directory: %w", err)
|
||||
return nil, fmt.Errorf("failed to get config directory: %w", err)
|
||||
}
|
||||
|
||||
profileName = sanitizeProfileName(profileName)
|
||||
|
||||
if profileName == defaultProfileName {
|
||||
return fmt.Errorf("cannot create profile with reserved name: %s", defaultProfileName)
|
||||
}
|
||||
|
||||
profPath := filepath.Join(configDir, profileName+".json")
|
||||
profileExists, err := fileExists(profPath)
|
||||
displayName, err = sanitizeDisplayName(displayName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if profile exists: %w", err)
|
||||
}
|
||||
if profileExists {
|
||||
return ErrProfileAlreadyExists
|
||||
return nil, fmt.Errorf("invalid profile name: %w", err)
|
||||
}
|
||||
|
||||
id, err := generateProfileID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate profile id: %w", err)
|
||||
}
|
||||
|
||||
profPath := filepath.Join(configDir, id.String()+".json")
|
||||
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create new config: %w", err)
|
||||
return nil, fmt.Errorf("failed to create new config: %w", err)
|
||||
}
|
||||
cfg.Name = displayName
|
||||
|
||||
if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to write profile config: %w", err)
|
||||
}
|
||||
|
||||
err = util.WriteJson(context.Background(), profPath, cfg)
|
||||
return &Profile{
|
||||
ID: id,
|
||||
Name: displayName,
|
||||
Path: profPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error {
|
||||
displayName, err := sanitizeDisplayName(newName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write profile config: %w", err)
|
||||
return fmt.Errorf("invalid profile name: %w", err)
|
||||
}
|
||||
|
||||
if !IsValidProfileFilenameStem(id) {
|
||||
return fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
|
||||
profiles, err := s.loadAllProfiles(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load profiles: %w", err)
|
||||
}
|
||||
|
||||
var target *Profile
|
||||
for i := range profiles {
|
||||
if profiles[i].ID == id {
|
||||
target = &profiles[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return ErrProfileNotFound
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(target.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Name = displayName
|
||||
|
||||
if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil {
|
||||
return fmt.Errorf("failed to write profile name: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServiceManager) RemoveProfile(profileName, username string) error {
|
||||
configDir, err := s.getConfigDir(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config directory: %w", err)
|
||||
// RemoveProfile deletes the profile identified by id. Callers must have
|
||||
// already resolved any user-supplied handle to a concrete ID via
|
||||
// ResolveProfile.
|
||||
func (s *ServiceManager) RemoveProfile(id ID, username string) error {
|
||||
if id == defaultProfileName {
|
||||
defaultName := readProfileName(DefaultConfigPath)
|
||||
if defaultName == "" {
|
||||
defaultName = defaultProfileName
|
||||
}
|
||||
return fmt.Errorf("cannot remove default profile with name: %s", defaultName)
|
||||
}
|
||||
if !IsValidProfileFilenameStem(id) {
|
||||
return fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
|
||||
profileName = sanitizeProfileName(profileName)
|
||||
|
||||
if profileName == defaultProfileName {
|
||||
return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName)
|
||||
}
|
||||
profPath := filepath.Join(configDir, profileName+".json")
|
||||
profileExists, err := fileExists(profPath)
|
||||
profiles, err := s.loadAllProfiles(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if profile exists: %w", err)
|
||||
return fmt.Errorf("load profiles: %w", err)
|
||||
}
|
||||
if !profileExists {
|
||||
|
||||
var target *Profile
|
||||
for i := range profiles {
|
||||
if profiles[i].ID == id {
|
||||
target = &profiles[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return ErrProfileNotFound
|
||||
}
|
||||
|
||||
@@ -301,57 +406,26 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error {
|
||||
if err != nil && !errors.Is(err, ErrNoActiveProfile) {
|
||||
return fmt.Errorf("failed to get active profile: %w", err)
|
||||
}
|
||||
|
||||
if activeProf != nil && activeProf.Name == profileName {
|
||||
return fmt.Errorf("cannot remove active profile: %s", profileName)
|
||||
if activeProf != nil && activeProf.ID == id {
|
||||
return fmt.Errorf("cannot remove active profile: %s", id)
|
||||
}
|
||||
|
||||
err = util.RemoveJson(profPath)
|
||||
if err != nil {
|
||||
if err := util.RemoveJson(target.Path); err != nil {
|
||||
return fmt.Errorf("failed to remove profile config: %w", err)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(filepath.Dir(target.Path), id.String()+".state.json")
|
||||
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
|
||||
log.Warnf("failed to remove profile state file %s: %v", stateFile, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProfiles returns every profile for the given user, including the
|
||||
// default profile, with IsActive flags set.
|
||||
func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) {
|
||||
configDir, err := s.getConfigDir(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config directory: %w", err)
|
||||
}
|
||||
|
||||
files, err := util.ListFiles(configDir, "*.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list profile files: %w", err)
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, file := range files {
|
||||
if strings.HasSuffix(file, "state.json") {
|
||||
continue // skip state files
|
||||
}
|
||||
filtered = append(filtered, file)
|
||||
}
|
||||
sort.Strings(filtered)
|
||||
|
||||
var activeProfName string
|
||||
activeProf, err := s.GetActiveProfileState()
|
||||
if err == nil {
|
||||
activeProfName = activeProf.Name
|
||||
}
|
||||
|
||||
var profiles []Profile
|
||||
// add default profile always
|
||||
profiles = append(profiles, Profile{Name: defaultProfileName, IsActive: activeProfName == "" || activeProfName == defaultProfileName})
|
||||
for _, file := range filtered {
|
||||
profileName := strings.TrimSuffix(filepath.Base(file), ".json")
|
||||
var isActive bool
|
||||
if activeProfName != "" && activeProfName == profileName {
|
||||
isActive = true
|
||||
}
|
||||
profiles = append(profiles, Profile{Name: profileName, IsActive: isActive})
|
||||
}
|
||||
|
||||
return profiles, nil
|
||||
return s.loadAllProfiles(username)
|
||||
}
|
||||
|
||||
// GetStatePath returns the path to the state file based on the operating system
|
||||
@@ -369,7 +443,12 @@ func (s *ServiceManager) GetStatePath() string {
|
||||
return defaultStatePath
|
||||
}
|
||||
|
||||
if activeProf.Name == defaultProfileName {
|
||||
if activeProf.ID == defaultProfileName {
|
||||
return defaultStatePath
|
||||
}
|
||||
|
||||
if !IsValidProfileFilenameStem(activeProf.ID) {
|
||||
log.Warnf("invalid active profile ID %q, using default state path", activeProf.ID)
|
||||
return defaultStatePath
|
||||
}
|
||||
|
||||
@@ -379,7 +458,7 @@ func (s *ServiceManager) GetStatePath() string {
|
||||
return defaultStatePath
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, activeProf.Name+".state.json")
|
||||
return filepath.Join(configDir, activeProf.ID.String()+".state.json")
|
||||
}
|
||||
|
||||
// getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser
|
||||
@@ -390,3 +469,169 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) {
|
||||
|
||||
return getConfigDirForUser(username)
|
||||
}
|
||||
|
||||
// loadAllProfiles returns every profile visible to the daemon for the
|
||||
// given user, including the default profile. The returned slice is sorted
|
||||
// by ID for a stable display order.
|
||||
//
|
||||
// Each Profile is fully populated: ID is the filename stem, Name comes
|
||||
// from the JSON's "name" field (falling back to the filename stem when absent)
|
||||
// and Path is built from a basename read off disk.
|
||||
func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) {
|
||||
activeID, activeIsDefault := s.activeProfileID()
|
||||
defaultName := readProfileName(DefaultConfigPath)
|
||||
if defaultName == "" {
|
||||
defaultName = defaultProfileName
|
||||
}
|
||||
|
||||
profiles := []Profile{{
|
||||
ID: defaultProfileName,
|
||||
Name: defaultName,
|
||||
Path: DefaultConfigPath,
|
||||
IsActive: activeIsDefault,
|
||||
}}
|
||||
|
||||
configDir, err := s.getConfigDir(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config directory: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(configDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return profiles, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read profile directory: %w", err)
|
||||
}
|
||||
|
||||
var fileProfiles []Profile
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
base := entry.Name()
|
||||
if !strings.HasSuffix(base, ".json") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(base, ".state.json") {
|
||||
continue
|
||||
}
|
||||
stem := ID(strings.TrimSuffix(base, ".json"))
|
||||
if stem == defaultProfileName {
|
||||
// default lives at the top-level config dir, not under /<user>
|
||||
continue
|
||||
}
|
||||
if !IsValidProfileFilenameStem(ID(stem)) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(configDir, base)
|
||||
name := readProfileName(path)
|
||||
if name == "" {
|
||||
name = stem.String()
|
||||
}
|
||||
fileProfiles = append(fileProfiles, Profile{
|
||||
ID: stem,
|
||||
Name: name,
|
||||
Path: path,
|
||||
IsActive: stem == ID(activeID),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(fileProfiles, func(i, j int) bool {
|
||||
if fileProfiles[i].Name != fileProfiles[j].Name {
|
||||
return fileProfiles[i].Name < fileProfiles[j].Name
|
||||
}
|
||||
// Sort tie-break on ID so duplicate names always render in the same order.
|
||||
return fileProfiles[i].ID < fileProfiles[j].ID
|
||||
})
|
||||
profiles = append(profiles, fileProfiles...)
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// readProfileName parses just the "name" field from the profile Json.
|
||||
func readProfileName(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var meta profileMeta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return ""
|
||||
}
|
||||
return meta.Name
|
||||
}
|
||||
|
||||
// activeProfileID returns the currently-active profile's ID. The second
|
||||
// return value is true when the active profile is the default one.
|
||||
func (s *ServiceManager) activeProfileID() (ID, bool) {
|
||||
state, err := s.GetActiveProfileState()
|
||||
if err != nil || state == nil {
|
||||
return defaultProfileName, true
|
||||
}
|
||||
if state.ID == "" || state.ID == defaultProfileName {
|
||||
return defaultProfileName, true
|
||||
}
|
||||
return state.ID, false
|
||||
}
|
||||
|
||||
// ResolveProfile turns a user-supplied handle into a Profile. Resolution
|
||||
// precedence is: exact ID match, then unique exact name, then unique ID
|
||||
// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can
|
||||
// surface the candidates.
|
||||
func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, error) {
|
||||
if handle == "" {
|
||||
return nil, fmt.Errorf("profile handle is empty")
|
||||
}
|
||||
|
||||
profiles, err := s.loadAllProfiles(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range profiles {
|
||||
if profiles[i].ID == ID(handle) {
|
||||
return &profiles[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
var nameMatches []Profile
|
||||
for i := range profiles {
|
||||
if profiles[i].Name == handle {
|
||||
nameMatches = append(nameMatches, profiles[i])
|
||||
}
|
||||
}
|
||||
if len(nameMatches) == 1 {
|
||||
return &nameMatches[0], nil
|
||||
}
|
||||
if len(nameMatches) > 1 {
|
||||
return nil, &ErrAmbiguousHandle{
|
||||
Handle: handle,
|
||||
Candidates: nameMatches,
|
||||
Kind: AmbiguityKindName,
|
||||
}
|
||||
}
|
||||
|
||||
// ID prefix match. Skip the default profile so `select d` does not
|
||||
// accidentally pick it via prefix.
|
||||
var prefixMatches []Profile
|
||||
for i := range profiles {
|
||||
if profiles[i].ID == defaultProfileName {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(profiles[i].ID.String(), handle) {
|
||||
prefixMatches = append(prefixMatches, profiles[i])
|
||||
}
|
||||
}
|
||||
if len(prefixMatches) == 1 {
|
||||
return &prefixMatches[0], nil
|
||||
}
|
||||
if len(prefixMatches) > 1 {
|
||||
return nil, &ErrAmbiguousHandle{
|
||||
Handle: handle,
|
||||
Candidates: prefixMatches,
|
||||
Kind: AmbiguityKindIDPrefix,
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrProfileNotFound
|
||||
}
|
||||
|
||||
230
client/internal/profilemanager/service_test.go
Normal file
230
client/internal/profilemanager/service_test.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// withTestSM wires up patched globals + a clean config dir and returns a
|
||||
// fully initialized ServiceManager plus the username we are scoped to.
|
||||
func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
|
||||
t.Helper()
|
||||
withTempConfigDir(t, func(configDir string) {
|
||||
withPatchedGlobals(t, configDir, func() {
|
||||
u, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
sm := &ServiceManager{}
|
||||
require.NoError(t, sm.CreateDefaultProfile())
|
||||
fn(sm, u.Username)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceProfile_ExactID(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := sm.ResolveProfile(created.ID.String(), username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.ID, got.ID)
|
||||
assert.Equal(t, "work", got.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceProfile_IDPrefix(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
|
||||
prefix := created.ID[:4]
|
||||
got, err := sm.ResolveProfile(prefix.String(), username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.ID, got.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
// Plant two profiles whose IDs share a known prefix by writing
|
||||
// the files directly, since generated IDs are random.
|
||||
configDir, err := sm.getConfigDir(username)
|
||||
require.NoError(t, err)
|
||||
for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} {
|
||||
path := filepath.Join(configDir, id+".json")
|
||||
require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id}))
|
||||
}
|
||||
|
||||
_, err = sm.ResolveProfile("abcd", username)
|
||||
var amb *ErrAmbiguousHandle
|
||||
require.ErrorAs(t, err, &amb)
|
||||
assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind)
|
||||
assert.Len(t, amb.Candidates, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceProfile_ExactNameUnique(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
_, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := sm.ResolveProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "work", got.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceProfile_AmbiguousName(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
_, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
_, err = sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = sm.ResolveProfile("work", username)
|
||||
var amb *ErrAmbiguousHandle
|
||||
require.ErrorAs(t, err, &amb)
|
||||
assert.Equal(t, AmbiguityKindName, amb.Kind)
|
||||
assert.Len(t, amb.Candidates, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceProfile_NotFound(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
_, err := sm.ResolveProfile("nope", username)
|
||||
assert.ErrorIs(t, err, ErrProfileNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceProfile_DefaultByExactID(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
got, err := sm.ResolveProfile(defaultProfileName, username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, defaultProfileName, got.ID.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
|
||||
// Legacy profiles stored as <name>.json with no "name" JSON field
|
||||
// should still be discoverable by name and removable by name.
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
configDir, err := sm.getConfigDir(username)
|
||||
require.NoError(t, err)
|
||||
path := filepath.Join(configDir, "legacy.json")
|
||||
require.NoError(t, util.WriteJson(context.Background(), path, &Config{}))
|
||||
|
||||
got, err := sm.ResolveProfile("legacy", username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "legacy", got.ID.String())
|
||||
// Name falls back to the filename stem when JSON omits it.
|
||||
assert.Equal(t, "legacy", got.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
first, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, first.ID, second.ID)
|
||||
assert.Equal(t, "work", second.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddProfile_RejectsInvalidNames(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
cases := []string{
|
||||
"", // empty
|
||||
"\x00\x01", // only control chars (becomes empty)
|
||||
strings.Repeat("a", maxProfileNameLen+1), // too long
|
||||
}
|
||||
for _, name := range cases {
|
||||
_, err := sm.AddProfile(name, username)
|
||||
assert.Error(t, err, "expected error for %q", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveProfile_RejectsInvalidID(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
err := sm.RemoveProfile("../escape", username)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSanitizeDisplayName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"work", "work", false},
|
||||
{"My Work Account", "My Work Account", false},
|
||||
{"emoji 🚀 ok", "emoji 🚀 ok", false},
|
||||
{"漢字テスト", "漢字テスト", false},
|
||||
{"with\x00null", "withnull", false},
|
||||
{"\x01\x02\x03", "", true},
|
||||
{"", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := sanitizeDisplayName(tc.in)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err, "case %q", tc.in)
|
||||
continue
|
||||
}
|
||||
assert.NoError(t, err, "case %q", tc.in)
|
||||
assert.Equal(t, tc.want, got, "case %q", tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidProfileFilenameStem(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"default", true},
|
||||
{"abc123def456", true},
|
||||
{"legacy-name", true},
|
||||
{"legacy_name", true},
|
||||
{"", false},
|
||||
{"..", false},
|
||||
{"../etc", false},
|
||||
{"foo/bar", false},
|
||||
{`foo\bar`, false},
|
||||
{"with space", false},
|
||||
{"with.dot", false},
|
||||
{strings.Repeat("a", maxProfileIDLen+1), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsValidProfileFilenameStem(ID(tc.in))
|
||||
assert.Equal(t, tc.want, got, "case %q", tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProfile_DeletesStateFile(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
|
||||
configDir, err := sm.getConfigDir(username)
|
||||
require.NoError(t, err)
|
||||
statePath := filepath.Join(configDir, created.ID.String()+".state.json")
|
||||
require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600))
|
||||
|
||||
require.NoError(t, sm.RemoveProfile(created.ID, username))
|
||||
_, err = os.Stat(statePath)
|
||||
assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed")
|
||||
})
|
||||
}
|
||||
@@ -13,13 +13,20 @@ type ProfileState struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, error) {
|
||||
// GetProfileState reads the per-profile state file keyed by profile ID.
|
||||
// The state file lives in the user's config directory. Legacy state files
|
||||
// keyed by the old profile name remain readable.
|
||||
func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config directory: %w", err)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, profileName+".state.json")
|
||||
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||
return nil, fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||
stateFileExists, err := fileExists(stateFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if profile state file exists: %w", err)
|
||||
@@ -51,7 +58,12 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
return fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, activeProf.Name+".state.json")
|
||||
id := activeProf.ID
|
||||
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||
return fmt.Errorf("invalid active profile ID: %q", id)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write profile state: %w", err)
|
||||
|
||||
@@ -32,6 +32,9 @@ type ProbeResult struct {
|
||||
URI string
|
||||
Err error
|
||||
Addr string
|
||||
// Transport is the negotiated relay transport, empty
|
||||
// for stun/turn probes or when not connected.
|
||||
Transport string
|
||||
}
|
||||
|
||||
type StunTurnProbe struct {
|
||||
|
||||
@@ -22,14 +22,14 @@ type removePeerCall struct {
|
||||
}
|
||||
|
||||
type mockServer struct {
|
||||
mu sync.Mutex
|
||||
addCalls []addPeerCall
|
||||
removed []removePeerCall
|
||||
nextID rp.PeerID
|
||||
addErr error
|
||||
removeErr error
|
||||
closed bool
|
||||
ran bool
|
||||
mu sync.Mutex
|
||||
addCalls []addPeerCall
|
||||
removed []removePeerCall
|
||||
nextID rp.PeerID
|
||||
addErr error
|
||||
removeErr error
|
||||
closed bool
|
||||
ran bool
|
||||
}
|
||||
|
||||
func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) {
|
||||
@@ -51,7 +51,7 @@ func (m *mockServer) RemovePeer(id rp.PeerID) error {
|
||||
return m.removeErr
|
||||
}
|
||||
|
||||
func (m *mockServer) Run() error { m.ran = true; return nil }
|
||||
func (m *mockServer) Run() error { m.ran = true; return nil }
|
||||
func (m *mockServer) Close() error { m.closed = true; return nil }
|
||||
|
||||
type setPSKCall struct {
|
||||
|
||||
@@ -41,4 +41,3 @@ func TestDeterministicSeedKey_TooShortKey_ReturnsError(t *testing.T) {
|
||||
_, err = DeterministicSeedKey(long, short)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func newExitNodeTestManager() *DefaultManager {
|
||||
return &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||
}
|
||||
|
||||
func exitRoute(netID, peer string, skipAutoApply bool) *route.Route {
|
||||
return &route.Route{
|
||||
NetID: route.NetID(netID),
|
||||
Network: netip.MustParsePrefix("0.0.0.0/0"),
|
||||
Peer: peer,
|
||||
SkipAutoApply: skipAutoApply,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickPreferredExitNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info exitNodeInfo
|
||||
want route.NetID
|
||||
}{
|
||||
{
|
||||
name: "persisted user selection wins over management",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"a", "b", "c"},
|
||||
userSelected: []route.NetID{"b"},
|
||||
selectedByManagement: []route.NetID{"a"},
|
||||
},
|
||||
want: "b",
|
||||
},
|
||||
{
|
||||
name: "multiple user-selected self-heal to deterministic min",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"a", "b", "c"},
|
||||
userSelected: []route.NetID{"c", "a"},
|
||||
},
|
||||
want: "a",
|
||||
},
|
||||
{
|
||||
name: "explicit opt-out keeps none",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"a", "b"},
|
||||
userDeselected: []route.NetID{"a", "b"},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "fresh defaults to management auto-apply pick",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"a", "b", "c"},
|
||||
selectedByManagement: []route.NetID{"b"},
|
||||
},
|
||||
want: "b",
|
||||
},
|
||||
{
|
||||
name: "no user pick and no management auto-apply selects none",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"c", "a", "b"},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "user-deselect does not block a management auto-apply sibling",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"a", "b"},
|
||||
userDeselected: []route.NetID{"a"},
|
||||
selectedByManagement: []route.NetID{"b"},
|
||||
},
|
||||
want: "b",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, pickPreferredExitNode(tt.info), "preferred exit node")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceSingleExitNode(t *testing.T) {
|
||||
m := newExitNodeTestManager()
|
||||
all := []route.NetID{"a", "b", "c"}
|
||||
|
||||
m.enforceSingleExitNode("b", all)
|
||||
assert.False(t, m.routeSelector.IsSelected("a"), "a should be deselected")
|
||||
assert.True(t, m.routeSelector.IsSelected("b"), "b should be the only selected exit node")
|
||||
assert.False(t, m.routeSelector.IsSelected("c"), "c should be deselected")
|
||||
|
||||
// Switching the preferred node moves the single selection.
|
||||
m.enforceSingleExitNode("c", all)
|
||||
assert.False(t, m.routeSelector.IsSelected("a"), "a stays deselected")
|
||||
assert.False(t, m.routeSelector.IsSelected("b"), "b should now be deselected")
|
||||
assert.True(t, m.routeSelector.IsSelected("c"), "c should now be selected")
|
||||
|
||||
// Empty preferred turns every exit node off.
|
||||
m.enforceSingleExitNode("", all)
|
||||
for _, id := range all {
|
||||
assert.False(t, m.routeSelector.IsSelected(id), "no exit node should be selected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceSingleExitNode_RespectsDeselectAll(t *testing.T) {
|
||||
m := newExitNodeTestManager()
|
||||
m.routeSelector.DeselectAllRoutes()
|
||||
|
||||
m.enforceSingleExitNode("b", []route.NetID{"a", "b"})
|
||||
|
||||
assert.True(t, m.routeSelector.IsDeselectAllActive(), "global deselect-all must stay in effect")
|
||||
assert.False(t, m.routeSelector.IsSelected("b"), "no exit node should be forced on while deselect-all is set")
|
||||
}
|
||||
|
||||
func TestUpdateRouteSelectorFromManagement_FreshSelectsOne(t *testing.T) {
|
||||
m := newExitNodeTestManager()
|
||||
routes := route.HAMap{
|
||||
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
||||
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
||||
"lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
|
||||
"exitC|0.0.0.0/0": {exitRoute("exitC", "p4", false)},
|
||||
}
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
// Exactly one exit node (the deterministic first) is selected.
|
||||
assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA is the deterministic default")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB must not also be selected")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitC"), "exitC must not also be selected")
|
||||
// Non-exit routes are left at their default-on state.
|
||||
assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
|
||||
}
|
||||
|
||||
func TestUpdateRouteSelectorFromManagement_HonorsPersistedPick(t *testing.T) {
|
||||
m := newExitNodeTestManager()
|
||||
routes := route.HAMap{
|
||||
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
||||
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
||||
}
|
||||
all := []route.NetID{"exitA", "exitB"}
|
||||
|
||||
// Simulate the state the runtime select path leaves behind: exactly one
|
||||
// exit node explicitly selected, its sibling deselected.
|
||||
require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exitB"}, true, all))
|
||||
require.NoError(t, m.routeSelector.DeselectRoutes([]route.NetID{"exitA"}, all))
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
assert.True(t, m.routeSelector.IsSelected("exitB"), "persisted pick must stay selected")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitA"), "the other exit node stays deselected")
|
||||
}
|
||||
|
||||
func TestUpdateRouteSelectorFromManagement_OptOutKeepsNone(t *testing.T) {
|
||||
m := newExitNodeTestManager()
|
||||
routes := route.HAMap{
|
||||
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
||||
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
||||
}
|
||||
all := []route.NetID{"exitA", "exitB"}
|
||||
|
||||
// User deselected exit nodes and selected none.
|
||||
require.NoError(t, m.routeSelector.DeselectRoutes(all, all))
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
assert.False(t, m.routeSelector.IsSelected("exitA"), "opt-out keeps exitA off")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitB"), "opt-out keeps exitB off")
|
||||
}
|
||||
|
||||
func TestUpdateRouteSelectorFromManagement_NoAutoApplySelectsNone(t *testing.T) {
|
||||
m := newExitNodeTestManager()
|
||||
// SkipAutoApply=true: management offers the exit nodes but doesn't request
|
||||
// auto-activation, so none should be selected until the user picks one.
|
||||
routes := route.HAMap{
|
||||
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)},
|
||||
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)},
|
||||
}
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
assert.False(t, m.routeSelector.IsSelected("exitA"), "no auto-apply keeps exitA off")
|
||||
assert.False(t, m.routeSelector.IsSelected("exitB"), "no auto-apply keeps exitB off")
|
||||
}
|
||||
@@ -333,6 +333,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) {
|
||||
}
|
||||
}
|
||||
|
||||
m.notifier.Close()
|
||||
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
m.clientRoutes = nil
|
||||
@@ -440,11 +442,6 @@ func (m *DefaultManager) UpdateRoutes(
|
||||
|
||||
m.updateClientNetworks(updateSerial, filteredClientRoutes)
|
||||
m.notifier.OnNewRoutes(filteredClientRoutes)
|
||||
// A new network map can add or drop route/exit-node candidates without
|
||||
// touching any peer's chosen-route state, so the peer status alone
|
||||
// wouldn't notify SubscribeStatus subscribers. Bump the revision so the
|
||||
// UI re-fetches ListNetworks.
|
||||
m.statusRecorder.BumpNetworksRevision()
|
||||
}
|
||||
m.clientRoutes = clientRoutes
|
||||
|
||||
@@ -585,10 +582,6 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) {
|
||||
if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil {
|
||||
log.Errorf("failed to update state: %v", err)
|
||||
}
|
||||
|
||||
// A selection change flips Network.selected without altering the candidate
|
||||
// set, so bump the revision to push the new state to the UI.
|
||||
m.statusRecorder.BumpNetworksRevision()
|
||||
}
|
||||
|
||||
// stopObsoleteClients stops the client network watcher for the networks that are not in the new list
|
||||
@@ -708,22 +701,42 @@ func resolveURLsToIPs(urls []string) []net.IP {
|
||||
return ips
|
||||
}
|
||||
|
||||
// updateRouteSelectorFromManagement reconciles exit-node selection on every
|
||||
// network map: it keeps at most one exit node selected — the user's persisted
|
||||
// pick, else whatever management marks for auto-apply (SkipAutoApply=false),
|
||||
// else none. We never auto-activate an exit node the map doesn't request; it
|
||||
// stays off until the user picks it. Exit nodes are mutually exclusive, but the
|
||||
// RouteSelector stores routes with default-on semantics, so without this every
|
||||
// available exit node would report selected at once.
|
||||
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
|
||||
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
|
||||
info := m.collectExitNodeInfo(clientRoutes)
|
||||
if len(info.allIDs) == 0 {
|
||||
m.mirrorV6ExitPairSelections(clientRoutes)
|
||||
|
||||
// An explicit user "deselect all" must not be overridden by management auto-apply.
|
||||
// Auto-applying an exit node here would call SelectRoutes, which clears the
|
||||
// deselect-all flag and re-enables every route the user turned off.
|
||||
if m.routeSelector.IsDeselectAll() {
|
||||
return
|
||||
}
|
||||
|
||||
preferred := pickPreferredExitNode(info)
|
||||
m.enforceSingleExitNode(preferred, info.allIDs)
|
||||
m.logExitNodeUpdate(info, preferred)
|
||||
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
|
||||
if len(exitNodeInfo.allIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
m.updateExitNodeSelections(exitNodeInfo)
|
||||
m.logExitNodeUpdate(exitNodeInfo)
|
||||
}
|
||||
|
||||
// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection
|
||||
// consistent with its v4 base. The v4/v6 exit pair is a single toggle, so the v6
|
||||
// entry always follows the base: deselecting the v4 exit node also drops its ::/0
|
||||
// pair, and any stale (orphaned) explicit selection on the v6 entry is reset. This
|
||||
// runs before selection is read so both collectExitNodeInfo and FilterSelectedExitNodes
|
||||
// see consistent state, including pairs loaded from persisted selector state.
|
||||
func (m *DefaultManager) mirrorV6ExitPairSelections(clientRoutes route.HAMap) {
|
||||
routesByNetID := make(map[route.NetID][]*route.Route, len(clientRoutes))
|
||||
for haID, routes := range clientRoutes {
|
||||
routesByNetID[haID.NetID()] = routes
|
||||
}
|
||||
|
||||
for v6ID := range route.V6ExitMergeSet(routesByNetID) {
|
||||
baseID := route.NetID(strings.TrimSuffix(string(v6ID), route.V6ExitSuffix))
|
||||
m.routeSelector.SyncPairedSelection(baseID, v6ID)
|
||||
}
|
||||
}
|
||||
|
||||
type exitNodeInfo struct {
|
||||
@@ -733,10 +746,6 @@ type exitNodeInfo struct {
|
||||
userDeselected []route.NetID
|
||||
}
|
||||
|
||||
// collectExitNodeInfo categorises the available exit nodes by their persisted
|
||||
// selection state. It keys on the base (v4) NetID and skips the synthesized
|
||||
// "-v6" partner, which inherits its base's selection through the RouteSelector
|
||||
// — counting it separately would double-count the pair.
|
||||
func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeInfo {
|
||||
var info exitNodeInfo
|
||||
|
||||
@@ -746,9 +755,6 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI
|
||||
}
|
||||
|
||||
netID := haID.NetID()
|
||||
if strings.HasSuffix(string(netID), route.V6ExitSuffix) {
|
||||
continue
|
||||
}
|
||||
info.allIDs = append(info.allIDs, netID)
|
||||
|
||||
if m.routeSelector.HasUserSelectionForRoute(netID) {
|
||||
@@ -785,69 +791,45 @@ func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID r
|
||||
}
|
||||
}
|
||||
|
||||
// pickPreferredExitNode chooses the single exit node to keep selected. In order:
|
||||
// - a persisted user selection wins (deterministic if several survive from
|
||||
// legacy state, so the set self-heals down to one);
|
||||
// - otherwise activate only what management marks for auto-apply
|
||||
// (SkipAutoApply=false); the lexicographically first if it marks several.
|
||||
//
|
||||
// Returns "" when neither holds — we never force an arbitrary exit node on. A
|
||||
// route the map doesn't auto-apply stays off until the user selects it.
|
||||
// info.userDeselected is informational only: an explicit deselect simply keeps
|
||||
// that route out of both lists above, so it can't be picked.
|
||||
func pickPreferredExitNode(info exitNodeInfo) route.NetID {
|
||||
if len(info.userSelected) > 0 {
|
||||
return minNetID(info.userSelected)
|
||||
}
|
||||
if len(info.selectedByManagement) > 0 {
|
||||
return minNetID(info.selectedByManagement)
|
||||
}
|
||||
return ""
|
||||
func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) {
|
||||
routesToDeselect := m.getRoutesToDeselect(info.allIDs)
|
||||
m.deselectExitNodes(routesToDeselect)
|
||||
m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs)
|
||||
}
|
||||
|
||||
// enforceSingleExitNode makes preferred the only selected exit node: every other
|
||||
// available exit node is deselected and preferred (if any) is selected, without
|
||||
// disturbing non-exit route selections. A global deselect-all is left untouched
|
||||
// so the user's "all off" stays in effect.
|
||||
func (m *DefaultManager) enforceSingleExitNode(preferred route.NetID, allIDs []route.NetID) {
|
||||
if m.routeSelector.IsDeselectAllActive() {
|
||||
func (m *DefaultManager) getRoutesToDeselect(allIDs []route.NetID) []route.NetID {
|
||||
var routesToDeselect []route.NetID
|
||||
for _, netID := range allIDs {
|
||||
if !m.routeSelector.HasUserSelectionForRoute(netID) {
|
||||
routesToDeselect = append(routesToDeselect, netID)
|
||||
}
|
||||
}
|
||||
return routesToDeselect
|
||||
}
|
||||
|
||||
func (m *DefaultManager) deselectExitNodes(routesToDeselect []route.NetID) {
|
||||
if len(routesToDeselect) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
others := make([]route.NetID, 0, len(allIDs))
|
||||
for _, id := range allIDs {
|
||||
if id != preferred {
|
||||
others = append(others, id)
|
||||
}
|
||||
}
|
||||
if len(others) > 0 {
|
||||
if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil {
|
||||
log.Warnf("deselect other exit nodes: %v", err)
|
||||
}
|
||||
}
|
||||
if preferred != "" {
|
||||
if err := m.routeSelector.SelectRoutes([]route.NetID{preferred}, true, allIDs); err != nil {
|
||||
log.Warnf("select preferred exit node %q: %v", preferred, err)
|
||||
}
|
||||
err := m.routeSelector.DeselectRoutes(routesToDeselect, routesToDeselect)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to deselect exit nodes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.NetID) {
|
||||
log.Debugf("Exit node selection: %d available, preferred=%q (%d user-selected, %d user-deselected, %d management-selected)",
|
||||
len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement))
|
||||
func (m *DefaultManager) selectExitNodesByManagement(selectedByManagement []route.NetID, allIDs []route.NetID) {
|
||||
if len(selectedByManagement) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := m.routeSelector.SelectRoutes(selectedByManagement, true, allIDs)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to select exit nodes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// minNetID returns the lexicographically smallest NetID, for a deterministic
|
||||
// default pick that stays stable across restarts.
|
||||
func minNetID(ids []route.NetID) route.NetID {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
best := ids[0]
|
||||
for _, id := range ids[1:] {
|
||||
if id < best {
|
||||
best = id
|
||||
}
|
||||
}
|
||||
return best
|
||||
func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo) {
|
||||
log.Debugf("Updated route selector: %d exit nodes available, %d selected by management, %d user-selected, %d user-deselected",
|
||||
len(info.allIDs), len(info.selectedByManagement), len(info.userSelected), len(info.userDeselected))
|
||||
}
|
||||
|
||||
47
client/internal/routemanager/manager_v6exit_test.go
Normal file
47
client/internal/routemanager/manager_v6exit_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
// TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair reproduces the bug seen
|
||||
// in netbird-engine.log: persisted selector state has the v4 exit node deselected
|
||||
// but its synthesized "-v6" pair explicitly selected (orphaned), so the ::/0 route
|
||||
// leaked onto the tunnel. The management update must mirror the v4 deselect onto the
|
||||
// v6 pair so FilterSelectedExitNodes drops it.
|
||||
func TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair(t *testing.T) {
|
||||
const (
|
||||
v4ID = route.NetID("Exit Node (raspberrypi)")
|
||||
v6ID = route.NetID("Exit Node (raspberrypi)-v6")
|
||||
)
|
||||
all := []route.NetID{v4ID, v6ID}
|
||||
|
||||
rs := routeselector.NewRouteSelector()
|
||||
// Orphan the v6 selection: select the pair, then deselect only the v4 base.
|
||||
require.NoError(t, rs.SelectRoutes([]route.NetID{v4ID, v6ID}, true, all))
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{v4ID}, all))
|
||||
require.True(t, rs.IsSelected(v6ID), "precondition: orphaned v6 selection survives v4 deselect")
|
||||
|
||||
m := &DefaultManager{routeSelector: rs}
|
||||
|
||||
v4Route := &route.Route{NetID: v4ID, Network: netip.MustParsePrefix("0.0.0.0/0")}
|
||||
v6Route := &route.Route{NetID: v6ID, Network: netip.MustParsePrefix("::/0")}
|
||||
clientRoutes := route.HAMap{
|
||||
"Exit Node (raspberrypi)|0.0.0.0/0": {v4Route},
|
||||
"Exit Node (raspberrypi)-v6|::/0": {v6Route},
|
||||
}
|
||||
|
||||
m.updateRouteSelectorFromManagement(clientRoutes)
|
||||
|
||||
assert.False(t, rs.IsSelected(v6ID), "v6 pair must follow the v4 base deselect after the management update")
|
||||
|
||||
filtered := rs.FilterSelectedExitNodes(clientRoutes)
|
||||
assert.Empty(t, filtered, "deselected v4 exit node must not leak its ::/0 pair onto the tunnel")
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
type Notifier struct {
|
||||
initialRoutes []*route.Route
|
||||
currentRoutes []*route.Route
|
||||
fakeIPRoutes []*route.Route
|
||||
fakeIPRoutes []*route.Route
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
listenerMux sync.Mutex
|
||||
@@ -119,3 +119,7 @@ func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
sort.Strings(initialStrings)
|
||||
return initialStrings
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -14,19 +15,26 @@ import (
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
currentPrefixes []string
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
listenerMux sync.Mutex
|
||||
listener listener.NetworkChangeListener
|
||||
queue *list.List
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewNotifier() *Notifier {
|
||||
return &Notifier{}
|
||||
n := &Notifier{
|
||||
queue: list.New(),
|
||||
}
|
||||
n.cond = sync.NewCond(&n.mu)
|
||||
go n.deliverLoop()
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
@@ -43,32 +51,52 @@ func (n *Notifier) OnNewRoutes(route.HAMap) {
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
||||
newNets := make([]string, 0)
|
||||
newNets := make([]string, 0, len(prefixes))
|
||||
for _, prefix := range prefixes {
|
||||
newNets = append(newNets, prefix.String())
|
||||
}
|
||||
|
||||
sort.Strings(newNets)
|
||||
|
||||
n.mu.Lock()
|
||||
if slices.Equal(n.currentPrefixes, newNets) {
|
||||
n.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
n.currentPrefixes = newNets
|
||||
n.notify()
|
||||
routes := strings.Join(n.currentPrefixes, ",")
|
||||
n.queue.PushBack(routes)
|
||||
n.cond.Signal()
|
||||
n.mu.Unlock()
|
||||
}
|
||||
func (n *Notifier) notify() {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
if n.listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged(strings.Join(n.currentPrefixes, ","))
|
||||
}(n.listener)
|
||||
func (n *Notifier) Close() {
|
||||
n.mu.Lock()
|
||||
n.closed = true
|
||||
n.cond.Signal()
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notifier) deliverLoop() {
|
||||
for {
|
||||
n.mu.Lock()
|
||||
for n.queue.Len() == 0 && !n.closed {
|
||||
n.cond.Wait()
|
||||
}
|
||||
if n.closed && n.queue.Len() == 0 {
|
||||
n.mu.Unlock()
|
||||
return
|
||||
}
|
||||
routes := n.queue.Remove(n.queue.Front()).(string)
|
||||
l := n.listener
|
||||
n.mu.Unlock()
|
||||
|
||||
if l != nil {
|
||||
l.OnNetworkChanged(routes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,3 +38,7 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
71
client/internal/routemanager/selector_management_test.go
Normal file
71
client/internal/routemanager/selector_management_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap {
|
||||
haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0")
|
||||
return route.HAMap{
|
||||
haID: []*route.Route{
|
||||
{
|
||||
ID: "r-" + route.ID(netID),
|
||||
NetID: netID,
|
||||
Network: netip.MustParsePrefix("0.0.0.0/0"),
|
||||
NetworkType: route.IPv4Network,
|
||||
Enabled: true,
|
||||
SkipAutoApply: skipAutoApply,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRouteSelectorFromManagement(t *testing.T) {
|
||||
t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) {
|
||||
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||
routes := exitNodeRoutes("exit1", false)
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected")
|
||||
require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter")
|
||||
})
|
||||
|
||||
t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) {
|
||||
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||
routes := exitNodeRoutes("exit1", true)
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected")
|
||||
require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out")
|
||||
})
|
||||
|
||||
t.Run("user selection is not overridden by management", func(t *testing.T) {
|
||||
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||
require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"}))
|
||||
routes := exitNodeRoutes("exit1", true)
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply")
|
||||
require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter")
|
||||
})
|
||||
|
||||
t.Run("deselect-all is preserved across a management sync", func(t *testing.T) {
|
||||
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||
m.routeSelector.DeselectAllRoutes()
|
||||
routes := exitNodeRoutes("exit1", false)
|
||||
|
||||
m.updateRouteSelectorFromManagement(routes)
|
||||
|
||||
require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply")
|
||||
require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set")
|
||||
})
|
||||
}
|
||||
@@ -121,9 +121,12 @@ func (r *SysOps) addRouteToNonVPNIntf(prefix netip.Prefix, vpnIntf wgIface, init
|
||||
return Nexthop{}, vars.ErrRouteNotAllowed
|
||||
}
|
||||
|
||||
// Check if the prefix is part of any local subnets
|
||||
if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal {
|
||||
return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed)
|
||||
// BSDs blackhole a /32 added inside a directly-connected subnet; Linux/Windows need it to beat the wt0 route.
|
||||
switch runtime.GOOS {
|
||||
case "darwin", "freebsd", "netbsd", "openbsd", "dragonfly":
|
||||
if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal {
|
||||
return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the exit interface and next hop for the prefix, so we can add a specific route
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
@@ -116,6 +115,14 @@ func (rs *RouteSelector) DeselectAllRoutes() {
|
||||
clear(rs.selectedRoutes)
|
||||
}
|
||||
|
||||
// IsDeselectAll reports whether the user has explicitly deselected all routes.
|
||||
func (rs *RouteSelector) IsDeselectAll() bool {
|
||||
rs.mu.RLock()
|
||||
defer rs.mu.RUnlock()
|
||||
|
||||
return rs.deselectAll
|
||||
}
|
||||
|
||||
// IsSelected checks if a specific route is selected.
|
||||
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
|
||||
rs.mu.RLock()
|
||||
@@ -124,14 +131,31 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
|
||||
return rs.isSelectedLocked(routeID)
|
||||
}
|
||||
|
||||
// IsDeselectAllActive reports whether the global "deselect all" flag is set,
|
||||
// i.e. the user disabled every route. Callers enforcing per-route invariants
|
||||
// (e.g. single exit node) should leave the selection untouched when it is.
|
||||
func (rs *RouteSelector) IsDeselectAllActive() bool {
|
||||
rs.mu.RLock()
|
||||
defer rs.mu.RUnlock()
|
||||
// SyncPairedSelection forces pairedID's explicit selection state to match baseID's,
|
||||
// so a synthesized "-v6" exit route always follows its v4 base: selecting or
|
||||
// deselecting the v4 exit node governs the ::/0 pair, and any stale (orphaned)
|
||||
// explicit state on the v6 entry is reset. The v4/v6 exit pair is treated as a single
|
||||
// toggle, so the v6 entry carries no independent selection of its own.
|
||||
func (rs *RouteSelector) SyncPairedSelection(baseID, pairedID route.NetID) {
|
||||
rs.mu.Lock()
|
||||
defer rs.mu.Unlock()
|
||||
|
||||
return rs.deselectAll
|
||||
if rs.deselectAll {
|
||||
return
|
||||
}
|
||||
|
||||
_, baseSelected := rs.selectedRoutes[baseID]
|
||||
_, baseDeselected := rs.deselectedRoutes[baseID]
|
||||
|
||||
delete(rs.selectedRoutes, pairedID)
|
||||
delete(rs.deselectedRoutes, pairedID)
|
||||
|
||||
switch {
|
||||
case baseSelected:
|
||||
rs.selectedRoutes[pairedID] = struct{}{}
|
||||
case baseDeselected:
|
||||
rs.deselectedRoutes[pairedID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// FilterSelected removes unselected routes from the provided map.
|
||||
@@ -153,14 +177,13 @@ func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
|
||||
}
|
||||
|
||||
// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route.
|
||||
// Intended for exit-node code paths: a v6 exit-node pair (e.g. "MyExit-v6") with no explicit state of
|
||||
// its own inherits its v4 base's state, so legacy persisted selections that predate v6 pairing
|
||||
// transparently apply to the synthesized v6 entry.
|
||||
// The lookup is literal; v4/v6 exit pairs are kept consistent at write time via SyncPairedSelection,
|
||||
// so a synthesized "-v6" entry carries the same explicit state as its v4 base.
|
||||
func (rs *RouteSelector) HasUserSelectionForRoute(routeID route.NetID) bool {
|
||||
rs.mu.RLock()
|
||||
defer rs.mu.RUnlock()
|
||||
|
||||
return rs.hasUserSelectionForRouteLocked(rs.effectiveNetID(routeID))
|
||||
return rs.hasUserSelectionForRouteLocked(routeID)
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap {
|
||||
@@ -189,83 +212,6 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap
|
||||
return filtered
|
||||
}
|
||||
|
||||
// effectiveNetID returns the v4 base for a "-v6" exit pair entry that has no explicit
|
||||
// state of its own, so selections made on the v4 entry govern the v6 entry automatically.
|
||||
// Only call this from exit-node-specific code paths: applying it to a non-exit "-v6" route
|
||||
// would make it inherit unrelated v4 state. Must be called with rs.mu held.
|
||||
func (rs *RouteSelector) effectiveNetID(id route.NetID) route.NetID {
|
||||
name := string(id)
|
||||
if !strings.HasSuffix(name, route.V6ExitSuffix) {
|
||||
return id
|
||||
}
|
||||
if _, ok := rs.selectedRoutes[id]; ok {
|
||||
return id
|
||||
}
|
||||
if _, ok := rs.deselectedRoutes[id]; ok {
|
||||
return id
|
||||
}
|
||||
return route.NetID(strings.TrimSuffix(name, route.V6ExitSuffix))
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
|
||||
if rs.deselectAll {
|
||||
return false
|
||||
}
|
||||
_, deselected := rs.deselectedRoutes[routeID]
|
||||
return !deselected
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
|
||||
if rs.deselectAll {
|
||||
return true
|
||||
}
|
||||
_, deselected := rs.deselectedRoutes[netID]
|
||||
return deselected
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
|
||||
_, selected := rs.selectedRoutes[routeID]
|
||||
_, deselected := rs.deselectedRoutes[routeID]
|
||||
return selected || deselected
|
||||
}
|
||||
|
||||
func isExitNode(rt []*route.Route) bool {
|
||||
return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) applyExitNodeFilter(
|
||||
id route.HAUniqueID,
|
||||
netID route.NetID,
|
||||
rt []*route.Route,
|
||||
out route.HAMap,
|
||||
) {
|
||||
// Exit-node path: apply the v4/v6 pair mirror so a deselect on the v4 base also
|
||||
// drops the synthesized v6 entry that lacks its own explicit state.
|
||||
effective := rs.effectiveNetID(netID)
|
||||
if rs.hasUserSelectionForRouteLocked(effective) {
|
||||
if rs.isSelectedLocked(effective) {
|
||||
out[id] = rt
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// no explicit selection for this route: defer to management's SkipAutoApply flag
|
||||
sel := collectSelected(rt)
|
||||
if len(sel) > 0 {
|
||||
out[id] = sel
|
||||
}
|
||||
}
|
||||
|
||||
func collectSelected(rt []*route.Route) []*route.Route {
|
||||
var sel []*route.Route
|
||||
for _, r := range rt {
|
||||
if !r.SkipAutoApply {
|
||||
sel = append(sel, r)
|
||||
}
|
||||
}
|
||||
return sel
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface
|
||||
func (rs *RouteSelector) MarshalJSON() ([]byte, error) {
|
||||
rs.mu.RLock()
|
||||
@@ -319,3 +265,59 @@ func (rs *RouteSelector) UnmarshalJSON(data []byte) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
|
||||
if rs.deselectAll {
|
||||
return false
|
||||
}
|
||||
_, deselected := rs.deselectedRoutes[routeID]
|
||||
return !deselected
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
|
||||
if rs.deselectAll {
|
||||
return true
|
||||
}
|
||||
_, deselected := rs.deselectedRoutes[netID]
|
||||
return deselected
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
|
||||
_, selected := rs.selectedRoutes[routeID]
|
||||
_, deselected := rs.deselectedRoutes[routeID]
|
||||
return selected || deselected
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) applyExitNodeFilter(
|
||||
id route.HAUniqueID,
|
||||
netID route.NetID,
|
||||
rt []*route.Route,
|
||||
out route.HAMap,
|
||||
) {
|
||||
if rs.hasUserSelectionForRouteLocked(netID) {
|
||||
if rs.isSelectedLocked(netID) {
|
||||
out[id] = rt
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// no explicit selection for this route: defer to management's SkipAutoApply flag
|
||||
sel := collectSelected(rt)
|
||||
if len(sel) > 0 {
|
||||
out[id] = sel
|
||||
}
|
||||
}
|
||||
|
||||
func isExitNode(rt []*route.Route) bool {
|
||||
return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
|
||||
}
|
||||
|
||||
func collectSelected(rt []*route.Route) []*route.Route {
|
||||
var sel []*route.Route
|
||||
for _, r := range rt {
|
||||
if !r.SkipAutoApply {
|
||||
sel = append(sel, r)
|
||||
}
|
||||
}
|
||||
return sel
|
||||
}
|
||||
|
||||
@@ -330,39 +330,73 @@ func TestRouteSelector_FilterSelectedExitNodes(t *testing.T) {
|
||||
assert.Len(t, filtered, 0) // No routes should be selected
|
||||
}
|
||||
|
||||
// TestRouteSelector_V6ExitPairInherits covers the v4/v6 exit-node pair selection
|
||||
// mirror. The mirror is scoped to exit-node code paths: HasUserSelectionForRoute
|
||||
// and FilterSelectedExitNodes resolve a "-v6" entry without explicit state to its
|
||||
// v4 base, so legacy persisted selections that predate v6 pairing transparently
|
||||
// apply to the synthesized v6 entry. General lookups (IsSelected, FilterSelected)
|
||||
// stay literal so unrelated routes named "*-v6" don't inherit unrelated state.
|
||||
func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
|
||||
// TestRouteSelector_V6ExitPairSync covers SyncPairedSelection, which keeps a v4
|
||||
// exit node and its synthesized "-v6" counterpart consistent. The selector itself
|
||||
// is literal and never infers a v6 entry's state from its v4 base; callers that know
|
||||
// the pairing (exit-node code paths) call SyncPairedSelection to force the v6 entry
|
||||
// to follow the base, treating the pair as a single toggle.
|
||||
func TestRouteSelector_V6ExitPairSync(t *testing.T) {
|
||||
all := []route.NetID{"exit1", "exit1-v6", "exit2", "exit2-v6", "corp", "corp-v6"}
|
||||
|
||||
t.Run("HasUserSelectionForRoute mirrors deselected v4 base", func(t *testing.T) {
|
||||
t.Run("selector lookups stay literal without sync", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
|
||||
assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 pair sees v4 base's user selection")
|
||||
// The selector does not pair-resolve: the v6 entry is independent until synced.
|
||||
assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 entry has no state of its own")
|
||||
assert.True(t, rs.IsSelected("exit1-v6"), "unsynced v6 entry stays selected by default")
|
||||
|
||||
// unrelated v6 with no v4 base touched is unaffected
|
||||
assert.False(t, rs.HasUserSelectionForRoute("exit2-v6"))
|
||||
// A route literally named "exit1-something" must never pair-resolve either.
|
||||
assert.False(t, rs.HasUserSelectionForRoute("exit1-something"))
|
||||
})
|
||||
|
||||
t.Run("IsSelected stays literal for non-exit lookups", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
|
||||
|
||||
// A non-exit route literally named "corp-v6" must not inherit "corp"'s state
|
||||
// via the mirror; the mirror only applies in exit-node code paths.
|
||||
assert.False(t, rs.IsSelected("corp"))
|
||||
assert.True(t, rs.IsSelected("corp-v6"), "non-exit *-v6 routes must not inherit unrelated v4 state")
|
||||
})
|
||||
|
||||
t.Run("explicit v6 state overrides v4 base in filter", func(t *testing.T) {
|
||||
t.Run("sync mirrors deselected v4 base onto v6", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
|
||||
rs.SyncPairedSelection("exit1", "exit1-v6")
|
||||
|
||||
assert.False(t, rs.IsSelected("exit1"))
|
||||
assert.False(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base deselect")
|
||||
assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 carries explicit deselect after sync")
|
||||
})
|
||||
|
||||
t.Run("sync mirrors selected v4 base onto v6", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1"}, false, all))
|
||||
|
||||
rs.SyncPairedSelection("exit1", "exit1-v6")
|
||||
|
||||
assert.True(t, rs.IsSelected("exit1"))
|
||||
assert.True(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base select")
|
||||
})
|
||||
|
||||
t.Run("sync clears v6 state when base has no explicit selection", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1-v6"}, true, all))
|
||||
require.True(t, rs.HasUserSelectionForRoute("exit1-v6"))
|
||||
|
||||
rs.SyncPairedSelection("exit1", "exit1-v6")
|
||||
|
||||
assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"),
|
||||
"v6 explicit state is cleared so it follows management like its base")
|
||||
})
|
||||
|
||||
// Regression for the observed bug (see netbird-engine.log): persisted state has
|
||||
// the v4 base deselected but the v6 sibling explicitly selected (orphaned). The
|
||||
// sync must reset the orphan so the ::/0 route does not leak onto the tunnel.
|
||||
t.Run("sync clears orphaned explicit v6 selection on deselected base", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
|
||||
// Prior state: both explicitly selected, then only the v4 base deselected,
|
||||
// leaving the v6 entry as a stale explicit selection.
|
||||
require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1", "exit1-v6"}, true, all))
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
require.True(t, rs.IsSelected("exit1-v6"), "precondition: orphaned v6 selection")
|
||||
|
||||
rs.SyncPairedSelection("exit1", "exit1-v6")
|
||||
|
||||
assert.False(t, rs.IsSelected("exit1-v6"), "orphaned v6 selection reset to follow v4 deselect")
|
||||
|
||||
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
|
||||
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
|
||||
@@ -370,23 +404,14 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
|
||||
"exit1|0.0.0.0/0": {v4Route},
|
||||
"exit1-v6|::/0": {v6Route},
|
||||
}
|
||||
|
||||
filtered := rs.FilterSelectedExitNodes(routes)
|
||||
assert.NotContains(t, filtered, route.HAUniqueID("exit1|0.0.0.0/0"))
|
||||
assert.Contains(t, filtered, route.HAUniqueID("exit1-v6|::/0"), "explicit v6 select wins over v4 base")
|
||||
assert.Empty(t, filtered, "deselecting v4 base must drop the v6 pair even if it was explicitly selected before")
|
||||
})
|
||||
|
||||
t.Run("non-v6-suffix routes unaffected", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
|
||||
// A route literally named "exit1-something" must not pair-resolve.
|
||||
assert.False(t, rs.HasUserSelectionForRoute("exit1-something"))
|
||||
})
|
||||
|
||||
t.Run("filter v6 paired with deselected v4 base", func(t *testing.T) {
|
||||
t.Run("filter drops synced v6 pair of deselected v4 base", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
rs.SyncPairedSelection("exit1", "exit1-v6")
|
||||
|
||||
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
|
||||
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
|
||||
@@ -399,6 +424,15 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
|
||||
assert.Empty(t, filtered, "deselecting v4 base must also drop the v6 pair")
|
||||
})
|
||||
|
||||
t.Run("deselectAll makes sync a no-op", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
rs.DeselectAllRoutes()
|
||||
|
||||
rs.SyncPairedSelection("exit1", "exit1-v6")
|
||||
|
||||
assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "sync must not write explicit state under deselectAll")
|
||||
})
|
||||
|
||||
t.Run("non-exit *-v6 routes pass through FilterSelectedExitNodes", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
|
||||
|
||||
@@ -2,10 +2,7 @@ package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type StatusType string
|
||||
@@ -36,37 +33,17 @@ func CtxGetState(ctx context.Context) *contextState {
|
||||
}
|
||||
|
||||
type contextState struct {
|
||||
err error
|
||||
status StatusType
|
||||
mutex sync.Mutex
|
||||
onChange func()
|
||||
}
|
||||
|
||||
// SetOnChange installs a callback fired after every successful Set. Used by
|
||||
// the daemon to wire the status recorder's notifyStateChange so any
|
||||
// state.Set in the connect/login paths pushes a fresh snapshot to
|
||||
// SubscribeStatus subscribers without each callsite having to opt in.
|
||||
// The callback runs outside the contextState mutex to avoid a lock-order
|
||||
// dependency with the recorder's stateChangeMux.
|
||||
func (c *contextState) SetOnChange(fn func()) {
|
||||
c.mutex.Lock()
|
||||
c.onChange = fn
|
||||
c.mutex.Unlock()
|
||||
err error
|
||||
status StatusType
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func (c *contextState) Set(update StatusType) {
|
||||
if _, file, line, ok := runtime.Caller(1); ok {
|
||||
log.Infof("--- state.Set(%s) from %s:%d", update, file, line)
|
||||
}
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
c.status = update
|
||||
c.err = nil
|
||||
cb := c.onChange
|
||||
c.mutex.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *contextState) Status() (StatusType, error) {
|
||||
@@ -80,17 +57,6 @@ func (c *contextState) Status() (StatusType, error) {
|
||||
return c.status, nil
|
||||
}
|
||||
|
||||
// CurrentStatus returns the last status set via Set, ignoring any wrapped
|
||||
// error. Use when the status is needed for reporting purposes (e.g. the
|
||||
// status snapshot stream) and a transient wrapped error from a retry loop
|
||||
// shouldn't blank out the underlying status.
|
||||
func (c *contextState) CurrentStatus() StatusType {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
return c.status
|
||||
}
|
||||
|
||||
func (c *contextState) Wrap(err error) error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
99
client/internal/syncstore/disk.go
Normal file
99
client/internal/syncstore/disk.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package syncstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// syncResponseFileName is the name of the file the sync response is serialized
|
||||
// to, placed inside the configured directory (the state directory).
|
||||
const syncResponseFileName = "networkmap.pb"
|
||||
|
||||
// diskStore serializes the latest sync response to a file on disk instead of
|
||||
// keeping it in memory. This trades disk I/O for a much smaller memory
|
||||
// footprint, which matters on memory-constrained platforms (iOS).
|
||||
type diskStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
}
|
||||
|
||||
// NewDiskStore returns a Store that serializes the sync response to a file in
|
||||
// the given directory. If dir is empty it falls back to the OS temp directory.
|
||||
//
|
||||
// Any file left over from a previous run is removed on construction so a fresh
|
||||
// store never reads stale data (e.g. another profile's network map).
|
||||
func NewDiskStore(dir string) Store {
|
||||
if dir == "" {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
s := &diskStore{
|
||||
path: filepath.Join(dir, syncResponseFileName),
|
||||
}
|
||||
if err := s.Clear(); err != nil {
|
||||
log.Warnf("failed to clear stale sync response file: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *diskStore) Set(resp *mgmProto.SyncResponse) error {
|
||||
if resp == nil {
|
||||
return s.Clear()
|
||||
}
|
||||
|
||||
bs, err := proto.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal sync response: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := util.WriteBytesWithRestrictedPermission(context.Background(), s.path, bs); err != nil {
|
||||
return fmt.Errorf("write sync response to %s: %w", s.path, err)
|
||||
}
|
||||
|
||||
log.Debugf("sync response persisted to %s (%d bytes)", s.path, len(bs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *diskStore) Get() (*mgmProto.SyncResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
bs, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
//nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read sync response from %s: %w", s.path, err)
|
||||
}
|
||||
|
||||
resp := &mgmProto.SyncResponse{}
|
||||
if err := proto.Unmarshal(bs, resp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal sync response: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("retrieving latest sync response from %s (%d bytes)", s.path, len(bs))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *diskStore) Clear() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove sync response file %s: %w", s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
9
client/internal/syncstore/factory_ios.go
Normal file
9
client/internal/syncstore/factory_ios.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build ios
|
||||
|
||||
package syncstore
|
||||
|
||||
// New returns the platform default store. On iOS the sync response is
|
||||
// serialized to disk (in dir) to keep it out of the constrained process memory.
|
||||
func New(dir string) Store {
|
||||
return NewDiskStore(dir)
|
||||
}
|
||||
9
client/internal/syncstore/factory_other.go
Normal file
9
client/internal/syncstore/factory_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !ios
|
||||
|
||||
package syncstore
|
||||
|
||||
// New returns the platform default store. On all non-iOS platforms the sync
|
||||
// response is kept in memory; dir is unused.
|
||||
func New(_ string) Store {
|
||||
return NewMemoryStore()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user