mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-10 09:59:55 +00:00
Compare commits
105 Commits
ui-refacto
...
embedded-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2c79201b3 | ||
|
|
2fdc3aea4c | ||
|
|
a40028092d | ||
|
|
13200265d8 | ||
|
|
ed7a9363aa | ||
|
|
d56859dc5d | ||
|
|
367d37050b | ||
|
|
106527182f | ||
|
|
8e1d5b78c2 | ||
|
|
d3b63c6be9 | ||
|
|
60d2fa08b0 | ||
|
|
1e7b16db0a | ||
|
|
b377d99933 | ||
|
|
144dfbc12c | ||
|
|
6c9465df54 | ||
|
|
6cd5d6084f | ||
|
|
3bcacffd2c | ||
|
|
65f302b698 | ||
|
|
2f67841b1e | ||
|
|
bf2fb2fd44 | ||
|
|
4e3e3ce6d3 | ||
|
|
5e2830be8a | ||
|
|
f557e665a5 | ||
|
|
fa57eedaf5 | ||
|
|
7cb6388349 | ||
|
|
1f912be673 | ||
|
|
8d329da591 | ||
|
|
8e72967bbe | ||
|
|
c29ef638f4 | ||
|
|
97b7b010f5 | ||
|
|
030c57150f | ||
|
|
0f03c612d1 | ||
|
|
1cc5967198 | ||
|
|
412193c602 | ||
|
|
5e67febf57 | ||
|
|
ee348ba007 | ||
|
|
3d3055dc7f | ||
|
|
2f4ddf0796 | ||
|
|
98d533c8e8 | ||
|
|
ef4ea2e311 | ||
|
|
b41d11bbbe | ||
|
|
f37e228cc2 | ||
|
|
640a267556 | ||
|
|
17359cdc1e | ||
|
|
7e5846a1ee | ||
|
|
517bea0daf | ||
|
|
896530fd82 | ||
|
|
354fd004c7 | ||
|
|
c28e41e82b | ||
|
|
02b9fe704b | ||
|
|
5e200fa571 | ||
|
|
7d61975f6c | ||
|
|
62b36112ea | ||
|
|
df9a6fb020 | ||
|
|
b1b04f9ec6 | ||
|
|
fe15688f20 | ||
|
|
2285db2b62 | ||
|
|
b3f0f53a23 | ||
|
|
5eec9962ba | ||
|
|
393c102f45 | ||
|
|
b41fbad5e1 | ||
|
|
24a5f2252c | ||
|
|
9d189bb3e8 | ||
|
|
8e2505b59c | ||
|
|
97bc1eebde | ||
|
|
32a5a061b8 | ||
|
|
d927ef468a | ||
|
|
d3f3e08035 | ||
|
|
6bb66e0fad | ||
|
|
bc407527f4 | ||
|
|
5543404188 | ||
|
|
c2fdf62f1f | ||
|
|
b9f5264e36 | ||
|
|
97d0a6776f | ||
|
|
7e7e056f3a | ||
|
|
785f94d13f | ||
|
|
bfb6750b13 | ||
|
|
f5e1057127 | ||
|
|
ee393d0e62 | ||
|
|
0b8fc5da59 | ||
|
|
2d0a54f31a | ||
|
|
61ec8d67de | ||
|
|
76add0b9b2 | ||
|
|
a11341f57a | ||
|
|
b135d462d6 | ||
|
|
da37a28951 | ||
|
|
4f884d9f30 | ||
|
|
2bed8b641b | ||
|
|
b4f696272a | ||
|
|
6d937af7a0 | ||
|
|
db5b6cfbb7 | ||
|
|
e75948753a | ||
|
|
047cc958b5 | ||
|
|
cd005ef9a9 | ||
|
|
44ed0c1992 | ||
|
|
d6d3fa95c7 | ||
|
|
fa90283781 | ||
|
|
8bf13b0d0c | ||
|
|
a8541a1529 | ||
|
|
94068d3ebc | ||
|
|
738c585ee7 | ||
|
|
9b5541d17d | ||
|
|
7123e6d1f4 | ||
|
|
62cf9e873b | ||
|
|
9f0aa1ce26 |
@@ -1,18 +0,0 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
language: en-US
|
||||
reviews:
|
||||
profile: chill
|
||||
request_changes_workflow: false
|
||||
high_level_summary: true
|
||||
poem: false
|
||||
review_status: true
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
path_filters:
|
||||
- "!**/*.tsx"
|
||||
- "!**/*.ts"
|
||||
- "!**/*.js"
|
||||
- "!**/*.svg"
|
||||
chat:
|
||||
auto_reply: true
|
||||
@@ -6,6 +6,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
iptables=1.8.9-2 \
|
||||
libgl1-mesa-dev=22.3.6-1+deb12u1 \
|
||||
xorg-dev=1:7.7+23 \
|
||||
libayatana-appindicator3-dev=0.5.92-1 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& go install -v golang.org/x/tools/gopls@latest
|
||||
|
||||
10
.github/workflows/golang-test-darwin.yml
vendored
10
.github/workflows/golang-test-darwin.yml
vendored
@@ -45,15 +45,7 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- 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 -coverprofile=coverage.txt -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
|
||||
|
||||
17
.github/workflows/golang-test-linux.yml
vendored
17
.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 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 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,7 @@ 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 -coverprofile=coverage.txt -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'
|
||||
@@ -176,6 +168,7 @@ jobs:
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,client
|
||||
|
||||
|
||||
test_client_on_docker:
|
||||
name: "Client (Docker) / Unit"
|
||||
needs: [build-cache]
|
||||
@@ -236,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:
|
||||
|
||||
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
|
||||
|
||||
21
.github/workflows/golangci-lint.yml
vendored
21
.github/workflows/golangci-lint.yml
vendored
@@ -22,15 +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. List each translated locale
|
||||
# dir below and add new ones as languages are added under
|
||||
# client/ui/i18n/locales/. Single-star globs are matched per path
|
||||
# segment by codespell and behave the same across versions; the
|
||||
# recursive "**" form did not take effect with the codespell shipped
|
||||
# by this action.
|
||||
skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/hu/*
|
||||
skip: go.mod,go.sum,**/proxy/web/**
|
||||
golangci:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -62,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 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:
|
||||
|
||||
86
.github/workflows/release.yml
vendored
86
.github/workflows/release.yml
vendored
@@ -29,10 +29,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
|
||||
@@ -194,9 +194,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
|
||||
@@ -356,21 +356,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
|
||||
# GTK4/WebKitGTK 6.0 dev libs for the default build + GTK3/WebKit2GTK 4.1
|
||||
# dev libs for the legacy -tags gtk3 build (netbird-ui-gtk3). Both stacks
|
||||
# coexist on the same runner; goreleaser builds both Linux variants here.
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-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
|
||||
@@ -389,16 +376,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
|
||||
@@ -466,20 +447,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
|
||||
@@ -567,6 +534,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:
|
||||
@@ -589,28 +573,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 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$
|
||||
|
||||
@@ -196,7 +196,6 @@ nfpms:
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_deb
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
@@ -211,7 +210,6 @@ nfpms:
|
||||
description: Netbird client.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_rpm
|
||||
bindir: /usr/bin
|
||||
builds:
|
||||
|
||||
@@ -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
|
||||
dir: client/ui
|
||||
@@ -24,25 +15,6 @@ builds:
|
||||
- -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 }}"
|
||||
|
||||
# Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). -tags gtk3 flips Wails to
|
||||
# the GTK3 stack and drops our GTK4-only XEmbed tray host (see
|
||||
# xembed_host_gtk3_linux.go). Removed upstream in Wails v3.1.
|
||||
- id: netbird-ui-gtk3
|
||||
dir: client/ui
|
||||
binary: netbird-ui
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -tags=gtk3
|
||||
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 }}"
|
||||
|
||||
- id: netbird-ui-windows-amd64
|
||||
dir: client/ui
|
||||
binary: netbird-ui
|
||||
@@ -79,10 +51,6 @@ archives:
|
||||
name_template: "{{ .ProjectName }}-linux_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui
|
||||
- id: linux-gtk3-arch
|
||||
name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
- id: windows-arch
|
||||
name_template: "{{ .ProjectName }}-windows_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
@@ -93,8 +61,6 @@ nfpms:
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_deb
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
@@ -104,20 +70,16 @@ nfpms:
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
- src: client/ui/build/netbird.desktop
|
||||
dst: /usr/share/applications/netbird.desktop
|
||||
- src: client/ui/assets/netbird.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- libgtk-4-1
|
||||
- libwebkitgtk-6.0-4
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_rpm
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
@@ -127,67 +89,12 @@ nfpms:
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
- src: client/ui/build/netbird.desktop
|
||||
dst: /usr/share/applications/netbird.desktop
|
||||
- src: client/ui/assets/netbird.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- gtk4
|
||||
- webkitgtk6.0
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
# Legacy GTK3 deb for Ubuntu 22.04 / Debian 12 (no WebKitGTK 6.0). Same
|
||||
# package_name as the GTK4 deb -- the two are mutually-exclusive alternatives
|
||||
# served from the matching distro repo; the package manager resolves by deps.
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_deb_gtk3
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- deb
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
|
||||
# Legacy GTK3 rpm for RHEL 9 / Fedora <=39 (no WebKitGTK 6.0).
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_rpm_gtk3
|
||||
package_name: netbird-ui
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- rpm
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- gtk3
|
||||
- webkit2gtk4.1
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
@@ -208,22 +115,3 @@ uploads:
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
# Legacy GTK3 packages share the netbird-ui name, so they must live in a repo
|
||||
# path the old distros point at -- here a dedicated `gtk3` distribution/path.
|
||||
# TODO: confirm the final repo layout with the pkgs.wiretrustee.com owner.
|
||||
- name: debian-gtk3
|
||||
ids:
|
||||
- netbird_ui_deb_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
- name: yum-gtk3
|
||||
ids:
|
||||
- netbird_ui_rpm_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -291,6 +291,8 @@ go test -exec sudo ./...
|
||||
```
|
||||
> On Windows use a powershell with administrator privileges
|
||||
|
||||
> Non-GTK environments will need the `libayatana-appindicator3-dev` (debian/ubuntu) package installed
|
||||
|
||||
## Checklist before submitting a PR
|
||||
As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
|
||||
- Keep functions as simple as possible, with a single purpose
|
||||
|
||||
@@ -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 {
|
||||
@@ -168,65 +150,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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
106
client/cmd/up.go
106
client/cmd/up.go
@@ -361,6 +361,12 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
req.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
req.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
||||
req.DisableVNCApproval = &disableVNCApproval
|
||||
}
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
req.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
@@ -467,30 +473,14 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
ic.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
ic.EnableSSHRoot = &enableSSHRoot
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
ic.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
||||
ic.DisableVNCApproval = &disableVNCApproval
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||
ic.EnableSSHSFTP = &enableSSHSFTP
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||
ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||
ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
ic.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
ic.SSHJWTCacheTTL = &sshJWTCacheTTL
|
||||
}
|
||||
applySSHFlagsToConfig(cmd, &ic)
|
||||
|
||||
if cmd.Flag(interfaceNameFlag).Changed {
|
||||
if err := parseInterfaceName(interfaceName); err != nil {
|
||||
@@ -566,6 +556,49 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
||||
return &ic, nil
|
||||
}
|
||||
|
||||
func applySSHFlagsToConfig(cmd *cobra.Command, ic *profilemanager.ConfigInput) {
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
ic.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||
ic.EnableSSHSFTP = &enableSSHSFTP
|
||||
}
|
||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||
ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||
}
|
||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||
ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
ic.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
ic.SSHJWTCacheTTL = &sshJWTCacheTTL
|
||||
}
|
||||
}
|
||||
|
||||
func applySSHFlagsToLogin(cmd *cobra.Command, req *proto.LoginRequest) {
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
req.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||
req.EnableSSHSFTP = &enableSSHSFTP
|
||||
}
|
||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||
req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||
}
|
||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||
req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
req.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
ttl := int32(sshJWTCacheTTL)
|
||||
req.SshJWTCacheTTL = &ttl
|
||||
}
|
||||
}
|
||||
|
||||
func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) {
|
||||
loginRequest := proto.LoginRequest{
|
||||
SetupKey: providedSetupKey,
|
||||
@@ -595,31 +628,14 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
loginRequest.EnableSSHRoot = &enableSSHRoot
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
loginRequest.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
||||
loginRequest.DisableVNCApproval = &disableVNCApproval
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||
loginRequest.EnableSSHSFTP = &enableSSHSFTP
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||
loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||
loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
loginRequest.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
||||
loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32
|
||||
}
|
||||
applySSHFlagsToLogin(cmd, &loginRequest)
|
||||
|
||||
if cmd.Flag(disableAutoConnectFlag).Changed {
|
||||
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
||||
|
||||
100
client/cmd/vnc_agent.go
Normal file
100
client/cmd/vnc_agent.go
Normal file
@@ -0,0 +1,100 @@
|
||||
//go:build windows || (darwin && !ios)
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
var (
|
||||
vncAgentSocket string
|
||||
vncAgentTargetUID uint32
|
||||
)
|
||||
|
||||
func init() {
|
||||
vncAgentCmd.Flags().StringVar(&vncAgentSocket, "socket", "", "Unix-domain socket path the agent listens on (required)")
|
||||
vncAgentCmd.Flags().Uint32Var(&vncAgentTargetUID, "target-uid", 0, "uid the agent should drop privileges to before listening (darwin only; 0 = stay as current uid)")
|
||||
rootCmd.AddCommand(vncAgentCmd)
|
||||
}
|
||||
|
||||
// vncAgentCmd runs a VNC server inside the user's interactive session,
|
||||
// listening on a Unix-domain socket. The NetBird service spawns it: on
|
||||
// Windows via CreateProcessAsUser into the console session, on macOS via
|
||||
// launchctl asuser into the Aqua session.
|
||||
var vncAgentCmd = &cobra.Command{
|
||||
Use: "vnc-agent",
|
||||
Short: "Run VNC capture agent (internal, spawned by service)",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
log.SetReportCaller(true)
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
log.SetOutput(os.Stderr)
|
||||
|
||||
if vncAgentSocket == "" {
|
||||
return fmt.Errorf("--socket is required")
|
||||
}
|
||||
|
||||
token := os.Getenv("NB_VNC_AGENT_TOKEN")
|
||||
if token == "" {
|
||||
return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service")
|
||||
}
|
||||
// Purge the token from env so it doesn't leak via /proc/<pid>/environ.
|
||||
if err := os.Unsetenv("NB_VNC_AGENT_TOKEN"); err != nil {
|
||||
log.Debugf("unset NB_VNC_AGENT_TOKEN: %v", err)
|
||||
}
|
||||
|
||||
// Drop root privileges to the target console user BEFORE creating
|
||||
// the listening socket: keeps a post-auth bug in the encoder /
|
||||
// input / capture paths confined to the user's own privileges
|
||||
// rather than escalating to host root, and makes the daemon's
|
||||
// LOCAL_PEERCRED check see the right uid. No-op on Windows
|
||||
// (both processes run as SYSTEM) and when --target-uid is 0.
|
||||
if vncAgentTargetUID != 0 {
|
||||
if err := dropAgentPrivileges(vncAgentTargetUID); err != nil {
|
||||
return fmt.Errorf("drop privileges to uid %d: %w", vncAgentTargetUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Remove(vncAgentSocket); err != nil && !os.IsNotExist(err) {
|
||||
log.Debugf("remove stale socket %s: %v", vncAgentSocket, err)
|
||||
}
|
||||
ln, err := net.Listen("unix", vncAgentSocket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", vncAgentSocket, err)
|
||||
}
|
||||
if err := os.Chmod(vncAgentSocket, 0o600); err != nil {
|
||||
log.Debugf("chmod %s: %v", vncAgentSocket, err)
|
||||
}
|
||||
|
||||
capturer, injector, err := newAgentResources()
|
||||
if err != nil {
|
||||
_ = ln.Close()
|
||||
return err
|
||||
}
|
||||
srv := vncserver.New(vncserver.Config{
|
||||
Capturer: capturer,
|
||||
Injector: injector,
|
||||
DisableAuth: true,
|
||||
AgentTokenHex: token,
|
||||
Listener: ln,
|
||||
})
|
||||
|
||||
if err := srv.Start(cmd.Context(), netip.AddrPort{}, netip.Prefix{}); err != nil {
|
||||
return fmt.Errorf("start vnc server: %w", err)
|
||||
}
|
||||
log.Infof("vnc-agent listening on %s, ready", vncAgentSocket)
|
||||
|
||||
<-cmd.Context().Done()
|
||||
log.Info("vnc-agent context cancelled, shutting down")
|
||||
return srv.Stop()
|
||||
},
|
||||
SilenceUsage: true,
|
||||
}
|
||||
18
client/cmd/vnc_agent_darwin.go
Normal file
18
client/cmd/vnc_agent_darwin.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
capturer := vncserver.NewMacPoller()
|
||||
injector, err := vncserver.NewMacInputInjector()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("macOS input injector: %w", err)
|
||||
}
|
||||
return capturer, injector, nil
|
||||
}
|
||||
74
client/cmd/vnc_agent_dropprivs_darwin.go
Normal file
74
client/cmd/vnc_agent_dropprivs_darwin.go
Normal file
@@ -0,0 +1,74 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// dropAgentPrivileges drops the vnc-agent process from root (its
|
||||
// launchctl-asuser-inherited starting uid) to the target console user
|
||||
// before any other initialisation runs. Without this the agent runs as
|
||||
// root for the lifetime of the session; any post-auth memory-safety
|
||||
// issue in the capture/input/encode paths would then be a root-level
|
||||
// RCE on the host instead of a user-level one. Also makes the daemon's
|
||||
// LOCAL_PEERCRED check correctly identify the agent as the console user,
|
||||
// not as root.
|
||||
//
|
||||
// Returns an error when the agent is running as a non-root uid that
|
||||
// differs from targetUID: non-root can only setuid to itself, so a
|
||||
// mismatch here means the spawn went to the wrong session.
|
||||
func dropAgentPrivileges(targetUID uint32) error {
|
||||
if targetUID == 0 {
|
||||
return fmt.Errorf("refusing to keep agent running as root (target uid 0)")
|
||||
}
|
||||
cur := uint32(os.Getuid())
|
||||
if cur == targetUID {
|
||||
return nil
|
||||
}
|
||||
if cur != 0 {
|
||||
return fmt.Errorf("agent uid %d does not match expected %d and we lack root to fix it", cur, targetUID)
|
||||
}
|
||||
// Resolve the target user's real primary group rather than reusing
|
||||
// targetUID as the gid: a user's primary group on macOS is typically
|
||||
// staff(20), not gid==uid. Fail closed if the lookup fails.
|
||||
targetGID, err := primaryGroupID(targetUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Drop supplementary groups first: setgid alone doesn't touch the
|
||||
// auxiliary group list, leaving root's groups attached would let the
|
||||
// dropped process write to root-only group-writable files.
|
||||
if err := syscall.Setgroups([]int{}); err != nil {
|
||||
return fmt.Errorf("setgroups([]): %w", err)
|
||||
}
|
||||
if err := syscall.Setgid(targetGID); err != nil {
|
||||
return fmt.Errorf("setgid(%d): %w", targetGID, err)
|
||||
}
|
||||
if err := syscall.Setuid(int(targetUID)); err != nil {
|
||||
return fmt.Errorf("setuid(%d): %w", targetUID, err)
|
||||
}
|
||||
if uint32(os.Getuid()) != targetUID || uint32(os.Geteuid()) != targetUID {
|
||||
return fmt.Errorf("setuid verification: uid=%d euid=%d, expected %d", os.Getuid(), os.Geteuid(), targetUID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// primaryGroupID resolves the real primary group id of the user with the
|
||||
// given uid. Fails closed: a lookup or parse error returns an error so the
|
||||
// caller never falls back to using uid as the gid.
|
||||
func primaryGroupID(targetUID uint32) (int, error) {
|
||||
u, err := user.LookupId(strconv.Itoa(int(targetUID)))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("look up uid %d: %w", targetUID, err)
|
||||
}
|
||||
gid, err := strconv.Atoi(u.Gid)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse gid %q for uid %d: %w", u.Gid, targetUID, err)
|
||||
}
|
||||
return gid, nil
|
||||
}
|
||||
55
client/cmd/vnc_agent_dropprivs_darwin_test.go
Normal file
55
client/cmd/vnc_agent_dropprivs_darwin_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDropAgentPrivileges_RefusesRootTarget locks in the contract that
|
||||
// dropAgentPrivileges must never be a no-op when asked to keep the
|
||||
// agent as root (target uid 0). A future caller that passes 0 by
|
||||
// mistake would otherwise leave the post-auth attack surface running
|
||||
// with full root privileges.
|
||||
func TestDropAgentPrivileges_RefusesRootTarget(t *testing.T) {
|
||||
err := dropAgentPrivileges(0)
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal for target uid 0, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "root") {
|
||||
t.Fatalf("error should mention root, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropAgentPrivileges_NoOpWhenAlreadyTarget covers the dev path
|
||||
// where the agent is launched by hand as the target user (no root
|
||||
// available, no setuid needed). The helper must succeed silently
|
||||
// instead of trying (and failing) a setuid to its current uid.
|
||||
func TestDropAgentPrivileges_NoOpWhenAlreadyTarget(t *testing.T) {
|
||||
// Skip when running as root: the early-return path we want to
|
||||
// cover only fires when current uid == target uid.
|
||||
uid := currentUIDForTest()
|
||||
if uid == 0 {
|
||||
t.Skip("test must not run as root; cannot exercise the no-op early-return")
|
||||
}
|
||||
if err := dropAgentPrivileges(uid); err != nil {
|
||||
t.Fatalf("expected no-op when current uid == target, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropAgentPrivileges_RefusesMismatchedNonRoot guards the "non-root
|
||||
// caller tries to setuid to a different uid" path: setuid would fail
|
||||
// with EPERM anyway, but the helper should surface a clear error
|
||||
// before issuing the syscall so a misconfigured spawn (wrong --target-uid
|
||||
// flag) is debuggable.
|
||||
func TestDropAgentPrivileges_RefusesMismatchedNonRoot(t *testing.T) {
|
||||
uid := currentUIDForTest()
|
||||
if uid == 0 {
|
||||
t.Skip("test must not run as root; covered case requires non-root caller")
|
||||
}
|
||||
err := dropAgentPrivileges(uid + 1)
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal when non-root caller asks to setuid elsewhere")
|
||||
}
|
||||
}
|
||||
11
client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go
Normal file
11
client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package cmd
|
||||
|
||||
import "os"
|
||||
|
||||
// currentUIDForTest exposes os.Getuid for the darwin dropprivs tests
|
||||
// without leaking an os import into the test file itself.
|
||||
func currentUIDForTest() uint32 {
|
||||
return uint32(os.Getuid())
|
||||
}
|
||||
14
client/cmd/vnc_agent_dropprivs_windows.go
Normal file
14
client/cmd/vnc_agent_dropprivs_windows.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
// dropAgentPrivileges is a no-op on Windows: the agent and the daemon
|
||||
// both run as SYSTEM (the daemon spawns the agent into the interactive
|
||||
// session via CreateProcessAsUser with an impersonation token, but the
|
||||
// resulting process still runs under SYSTEM, not under the user's
|
||||
// account). The Windows path relies on the DACL-restricted socket
|
||||
// directory, the unpredictable per-spawn socket name, the listen-readiness
|
||||
// gate, and the per-spawn token for integrity instead.
|
||||
func dropAgentPrivileges(_ uint32) error {
|
||||
return nil
|
||||
}
|
||||
15
client/cmd/vnc_agent_windows.go
Normal file
15
client/cmd/vnc_agent_windows.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
sessionID := vncserver.GetCurrentSessionID()
|
||||
log.Infof("VNC agent running in Windows session %d", sessionID)
|
||||
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), nil
|
||||
}
|
||||
16
client/cmd/vnc_flags.go
Normal file
16
client/cmd/vnc_flags.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package cmd
|
||||
|
||||
const (
|
||||
serverVNCAllowedFlag = "allow-server-vnc"
|
||||
disableVNCApprovalFlag = "disable-vnc-approval"
|
||||
)
|
||||
|
||||
var (
|
||||
serverVNCAllowed bool
|
||||
disableVNCApproval bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
upCmd.PersistentFlags().BoolVar(&serverVNCAllowed, serverVNCAllowedFlag, false, "Allow embedded VNC server on peer")
|
||||
upCmd.PersistentFlags().BoolVar(&disableVNCApproval, disableVNCApprovalFlag, false, "Disable per-connection user approval prompts for the embedded VNC server")
|
||||
}
|
||||
@@ -6,19 +6,30 @@ import (
|
||||
"runtime"
|
||||
)
|
||||
|
||||
var StateDir string
|
||||
var (
|
||||
// StateDir holds persistent state (config, profiles, install metadata).
|
||||
StateDir string
|
||||
// RuntimeDir holds ephemeral artifacts that should not survive reboot,
|
||||
// such as Unix sockets for daemon and per-session IPC. Empty on
|
||||
// platforms without a conventional /var/run-style location.
|
||||
RuntimeDir string
|
||||
)
|
||||
|
||||
func init() {
|
||||
StateDir = os.Getenv("NB_STATE_DIR")
|
||||
if StateDir != "" {
|
||||
return
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
StateDir = filepath.Join(os.Getenv("PROGRAMDATA"), "Netbird")
|
||||
case "darwin", "linux":
|
||||
StateDir = "/var/lib/netbird"
|
||||
RuntimeDir = "/var/run/netbird"
|
||||
case "freebsd", "openbsd", "netbsd", "dragonfly":
|
||||
StateDir = "/var/db/netbird"
|
||||
RuntimeDir = "/var/run/netbird"
|
||||
}
|
||||
if v := os.Getenv("NB_STATE_DIR"); v != "" {
|
||||
StateDir = v
|
||||
}
|
||||
if v := os.Getenv("NB_RUNTIME_DIR"); v != "" {
|
||||
RuntimeDir = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +464,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
219
client/internal/approval/broker.go
Normal file
219
client/internal/approval/broker.go
Normal file
@@ -0,0 +1,219 @@
|
||||
// Package approval brokers per-attempt user-accept prompts for inbound
|
||||
// remote access (VNC today, SSH and others in the future). A caller pushes
|
||||
// a Prompt; the broker emits a SystemEvent on the daemon→UI stream and
|
||||
// blocks until the UI calls the daemon's RespondApproval RPC, the per-
|
||||
// request timeout fires, or no subscriber is connected. The latter case
|
||||
// fails closed so a backgrounded UI cannot silently bypass the gate.
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// Metadata keys the broker reserves on the emitted SystemEvent. Callers
|
||||
// should not set these themselves; values in Prompt.Metadata that collide
|
||||
// are overwritten by the broker.
|
||||
const (
|
||||
MetaRequestID = "request_id"
|
||||
MetaKind = "kind"
|
||||
MetaExpiresAt = "expires_at"
|
||||
)
|
||||
|
||||
// ShortKeyFingerprint formats a hex-encoded Noise_IK static pubkey as a
|
||||
// short, eyeball-able fingerprint to display in the approval dialog.
|
||||
// The dashboard-supplied display name attached to a SessionPubKey isn't
|
||||
// cryptographically asserted by the connecting client, so the prompt
|
||||
// must also show something that IS: the key fingerprint, a hash of
|
||||
// the static public key the client just proved possession of during the
|
||||
// Noise handshake. Returns the empty string when the input is too short
|
||||
// to plausibly be a hex pubkey, so the row is omitted rather than
|
||||
// rendered as a misleading partial.
|
||||
//
|
||||
// Output format: 16 hex chars grouped as XXXX-XXXX-XXXX-XXXX (64 bits of
|
||||
// fingerprint, resistant to random-prefix collisions and easy for a human
|
||||
// to compare with an out-of-band reference).
|
||||
func ShortKeyFingerprint(hexKey string) string {
|
||||
if len(hexKey) < 8 {
|
||||
return ""
|
||||
}
|
||||
src := hexKey
|
||||
if len(src) > 16 {
|
||||
src = src[:16]
|
||||
}
|
||||
var out []byte
|
||||
for i, c := range src {
|
||||
if i > 0 && i%4 == 0 {
|
||||
out = append(out, '-')
|
||||
}
|
||||
out = append(out, byte(c))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Kind values for the well-known prompt subjects. New subsystems should
|
||||
// add a constant here so the UI can dispatch on a known string.
|
||||
const (
|
||||
KindVNC = "vnc"
|
||||
KindSSH = "ssh"
|
||||
)
|
||||
|
||||
// DefaultTimeout is the wall-clock window the user has to accept or deny a
|
||||
// pending approval before the broker fails closed and returns ErrTimeout.
|
||||
// Kept well under typical VNC client and dashboard connection timeouts so
|
||||
// the RFB rejection actually reaches the browser instead of racing the
|
||||
// browser's own "connection timed out" message.
|
||||
const DefaultTimeout = 15 * time.Second
|
||||
|
||||
// timeoutValue returns the active timeout. It's a var so tests in this
|
||||
// package can shorten the wait without exposing a setter on the public
|
||||
// API. Production code always sees DefaultTimeout.
|
||||
var timeoutValue = func() time.Duration { return DefaultTimeout }
|
||||
|
||||
// ErrNoSubscriber indicates no UI is connected to consume the prompt.
|
||||
// The caller must reject the underlying connection (fail-closed).
|
||||
var ErrNoSubscriber = errors.New("no UI subscriber connected for approval")
|
||||
|
||||
// ErrTimeout indicates the user did not respond within DefaultTimeout.
|
||||
var ErrTimeout = errors.New("approval timed out")
|
||||
|
||||
// ErrDenied indicates the user explicitly denied the connection.
|
||||
var ErrDenied = errors.New("approval denied")
|
||||
|
||||
// EventPublisher is the subset of peer.Status used to emit prompts.
|
||||
type EventPublisher interface {
|
||||
PublishEvent(
|
||||
severity proto.SystemEvent_Severity,
|
||||
category proto.SystemEvent_Category,
|
||||
msg string,
|
||||
userMsg string,
|
||||
metadata map[string]string,
|
||||
)
|
||||
HasEventSubscribers() bool
|
||||
}
|
||||
|
||||
// Prompt describes the pending request shown to the user. Kind selects
|
||||
// the UI dispatch path (e.g. "vnc", "ssh"). Subject is the human-readable
|
||||
// one-liner the UI may show as a title or notification body. Metadata is
|
||||
// passed through verbatim and is the subsystem-specific payload (peer
|
||||
// name, source IP, mode, etc.).
|
||||
type Prompt struct {
|
||||
Kind string
|
||||
Subject string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// Decision carries the user's response to an approval prompt. ViewOnly is
|
||||
// only meaningful when Accept is true; it lets the host grant the
|
||||
// connection but signal the requester that input control is withheld.
|
||||
type Decision struct {
|
||||
Accept bool
|
||||
ViewOnly bool
|
||||
}
|
||||
|
||||
// Broker holds in-flight approval requests keyed by request ID.
|
||||
type Broker struct {
|
||||
pub EventPublisher
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]chan Decision
|
||||
}
|
||||
|
||||
// New returns a broker that publishes prompts via pub.
|
||||
func New(pub EventPublisher) *Broker {
|
||||
return &Broker{
|
||||
pub: pub,
|
||||
pending: make(map[string]chan Decision),
|
||||
}
|
||||
}
|
||||
|
||||
// Request emits a SystemEvent for p and blocks until the UI calls Respond,
|
||||
// ctx is cancelled, or DefaultTimeout elapses. Returns a Decision when
|
||||
// the user replied; ErrDenied / ErrTimeout / ErrNoSubscriber / ctx.Err
|
||||
// otherwise. Callers must treat any non-nil error as a deny.
|
||||
func (b *Broker) Request(ctx context.Context, p Prompt) (Decision, error) {
|
||||
var zero Decision
|
||||
if b == nil || b.pub == nil {
|
||||
return zero, fmt.Errorf("approval broker not configured")
|
||||
}
|
||||
if !b.pub.HasEventSubscribers() {
|
||||
return zero, ErrNoSubscriber
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
resp := make(chan Decision, 1)
|
||||
|
||||
b.mu.Lock()
|
||||
b.pending[id] = resp
|
||||
b.mu.Unlock()
|
||||
|
||||
defer b.dropPending(id)
|
||||
|
||||
timeout := timeoutValue()
|
||||
expiresAt := time.Now().Add(timeout)
|
||||
meta := make(map[string]string, len(p.Metadata)+3)
|
||||
for k, v := range p.Metadata {
|
||||
meta[k] = v
|
||||
}
|
||||
meta[MetaRequestID] = id
|
||||
meta[MetaKind] = p.Kind
|
||||
meta[MetaExpiresAt] = expiresAt.UTC().Format(time.RFC3339)
|
||||
|
||||
subject := p.Subject
|
||||
if subject == "" {
|
||||
subject = fmt.Sprintf("%s connection requires approval", p.Kind)
|
||||
}
|
||||
b.pub.PublishEvent(proto.SystemEvent_INFO, proto.SystemEvent_APPROVAL, subject, subject, meta)
|
||||
log.Debugf("approval request %s (%s) emitted: %s", id, p.Kind, subject)
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case d := <-resp:
|
||||
if !d.Accept {
|
||||
return zero, ErrDenied
|
||||
}
|
||||
return d, nil
|
||||
case <-timer.C:
|
||||
return zero, ErrTimeout
|
||||
case <-ctx.Done():
|
||||
return zero, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Respond delivers the user's decision for id. Returns true when a pending
|
||||
// request matched and was woken, false when id was unknown or already done.
|
||||
func (b *Broker) Respond(id string, d Decision) bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
b.mu.Lock()
|
||||
ch, ok := b.pending[id]
|
||||
if ok {
|
||||
delete(b.pending, id)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case ch <- d:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Broker) dropPending(id string) {
|
||||
b.mu.Lock()
|
||||
delete(b.pending, id)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
434
client/internal/approval/broker_test.go
Normal file
434
client/internal/approval/broker_test.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// fakePublisher records published events and reports whether subscribers
|
||||
// are connected. The subscribers flag is the security-critical signal:
|
||||
// when false the broker must refuse to emit and the gate must fail closed.
|
||||
type fakePublisher struct {
|
||||
mu sync.Mutex
|
||||
subscribers bool
|
||||
events []*proto.SystemEvent
|
||||
}
|
||||
|
||||
func (p *fakePublisher) PublishEvent(
|
||||
severity proto.SystemEvent_Severity,
|
||||
category proto.SystemEvent_Category,
|
||||
msg string,
|
||||
userMsg string,
|
||||
metadata map[string]string,
|
||||
) {
|
||||
p.mu.Lock()
|
||||
p.events = append(p.events, &proto.SystemEvent{
|
||||
Severity: severity,
|
||||
Category: category,
|
||||
Message: msg,
|
||||
UserMessage: userMsg,
|
||||
Metadata: metadata,
|
||||
})
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *fakePublisher) HasEventSubscribers() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.subscribers
|
||||
}
|
||||
|
||||
func (p *fakePublisher) lastEvent(t *testing.T) *proto.SystemEvent {
|
||||
t.Helper()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
require.NotEmpty(t, p.events, "publisher saw no events")
|
||||
return p.events[len(p.events)-1]
|
||||
}
|
||||
|
||||
func (p *fakePublisher) eventCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.events)
|
||||
}
|
||||
|
||||
// TestRequestNoSubscriberFailsClosed is the core fail-closed invariant:
|
||||
// when the UI is not subscribed, the broker must refuse without emitting
|
||||
// an event or arming a waiter. A regression here is a silent bypass.
|
||||
func TestRequestNoSubscriberFailsClosed(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: false}
|
||||
b := New(pub)
|
||||
|
||||
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||
assert.ErrorIs(t, err, ErrNoSubscriber)
|
||||
assert.Equal(t, 0, pub.eventCount(), "no event must be emitted when fail-closed")
|
||||
|
||||
b.mu.Lock()
|
||||
pending := len(b.pending)
|
||||
b.mu.Unlock()
|
||||
assert.Equal(t, 0, pending, "no waiter must be registered on fail-closed")
|
||||
}
|
||||
|
||||
// TestRequestTimeoutDenies verifies that a request without a UI response
|
||||
// returns ErrTimeout (deny) rather than nil (silent accept). Uses a short
|
||||
// per-test broker timeout via Respond after the fact to keep the test fast.
|
||||
func TestRequestTimeoutDenies(t *testing.T) {
|
||||
// Replace DefaultTimeout for the lifetime of this test.
|
||||
orig := DefaultTimeout
|
||||
defaultTimeout(t, 60*time.Millisecond)
|
||||
defer defaultTimeout(t, orig)
|
||||
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
start := time.Now()
|
||||
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||
assert.ErrorIs(t, err, ErrTimeout, "missing user response must yield ErrTimeout, not nil")
|
||||
assert.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond, "timeout fired prematurely")
|
||||
}
|
||||
|
||||
// TestRequestDenied returns ErrDenied when the UI responds with false.
|
||||
func TestRequestDenied(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
var requestID string
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||
}()
|
||||
|
||||
requestID = waitForRequestID(t, pub)
|
||||
require.True(t, b.Respond(requestID, Decision{Accept: false}))
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.ErrorIs(t, err, ErrDenied)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Request did not return after Respond(false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestAccepted is the happy path. Failure here doesn't bypass the
|
||||
// gate but breaks the feature.
|
||||
func TestRequestAccepted(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||
}()
|
||||
|
||||
id := waitForRequestID(t, pub)
|
||||
require.True(t, b.Respond(id, Decision{Accept: true}))
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Request did not return after Respond(true)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestCtxCancelDenies verifies that an upstream cancel (e.g. the
|
||||
// engine shutting down mid-prompt) returns the cancel error rather than
|
||||
// nil. A nil here would be a silent bypass on shutdown races.
|
||||
func TestRequestCtxCancelDenies(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, ctx, Prompt{Kind: KindVNC, Subject: "test"})
|
||||
}()
|
||||
|
||||
// Wait until the prompt is in flight so cancel races a live waiter.
|
||||
_ = waitForRequestID(t, pub)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Request did not return after ctx cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespondUnknownIsNoop ensures a stray RespondApproval RPC cannot
|
||||
// affect or accidentally accept any in-flight request whose id it doesn't
|
||||
// match. Also confirms it doesn't panic.
|
||||
func TestRespondUnknownIsNoop(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
// No in-flight prompts: Respond returns false.
|
||||
assert.False(t, b.Respond("does-not-exist", Decision{Accept: true}))
|
||||
|
||||
// With an in-flight prompt, a wrong id still returns false and the
|
||||
// prompt remains armed (eventually timing out as a deny).
|
||||
defaultTimeout(t, 60*time.Millisecond)
|
||||
defer defaultTimeout(t, DefaultTimeout)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||
}()
|
||||
realID := waitForRequestID(t, pub)
|
||||
assert.False(t, b.Respond("totally-bogus", Decision{Accept: true}), "unknown id must not match")
|
||||
assert.NotEqual(t, "totally-bogus", realID)
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.ErrorIs(t, err, ErrTimeout, "armed prompt must still time out, not accept")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("prompt did not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespondAfterTimeoutNoop confirms a late accept response can't
|
||||
// retroactively flip a denied (timed-out) request. The dropPending defer
|
||||
// in Request must have removed the entry by the time Respond races in.
|
||||
func TestRespondAfterTimeoutNoop(t *testing.T) {
|
||||
defaultTimeout(t, 30*time.Millisecond)
|
||||
defer defaultTimeout(t, DefaultTimeout)
|
||||
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||
}()
|
||||
id := waitForRequestID(t, pub)
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, ErrTimeout)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("prompt did not time out")
|
||||
}
|
||||
|
||||
assert.False(t, b.Respond(id, Decision{Accept: true}), "late respond must be no-op")
|
||||
}
|
||||
|
||||
// TestRespondDoubleNoop ensures a duplicate ack from the UI doesn't leak
|
||||
// past the matched waiter or panic on a closed/full channel.
|
||||
func TestRespondDoubleNoop(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||
}()
|
||||
id := waitForRequestID(t, pub)
|
||||
require.True(t, b.Respond(id, Decision{Accept: true}))
|
||||
assert.False(t, b.Respond(id, Decision{Accept: false}), "second response must be no-op")
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("prompt did not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNilBrokerRequestErrors guards the engine pre-init path where the
|
||||
// broker may not yet exist (or its publisher is nil): Request must
|
||||
// error, never silently accept.
|
||||
func TestNilBrokerRequestErrors(t *testing.T) {
|
||||
var b *Broker
|
||||
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC})
|
||||
assert.Error(t, err, "nil broker must error, never silently accept")
|
||||
|
||||
b2 := New(nil)
|
||||
_, err = b2.Request(context.Background(), Prompt{Kind: KindVNC})
|
||||
assert.Error(t, err, "broker with nil publisher must error, never silently accept")
|
||||
}
|
||||
|
||||
// TestPromptMetadataInjected confirms the broker stamps request_id, kind,
|
||||
// and expires_at on the emitted event. The UI relies on these keys; if
|
||||
// they are dropped, the user cannot route the prompt and the response
|
||||
// path breaks (which fails closed via timeout).
|
||||
func TestPromptMetadataInjected(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- requestErr(b, context.Background(), Prompt{
|
||||
Kind: KindVNC,
|
||||
Subject: "VNC connection from peerA",
|
||||
Metadata: map[string]string{"peer_name": "peerA"},
|
||||
})
|
||||
}()
|
||||
|
||||
id := waitForRequestID(t, pub)
|
||||
ev := pub.lastEvent(t)
|
||||
|
||||
assert.Equal(t, proto.SystemEvent_APPROVAL, ev.Category)
|
||||
assert.Equal(t, KindVNC, ev.Metadata[MetaKind])
|
||||
assert.Equal(t, id, ev.Metadata[MetaRequestID])
|
||||
assert.NotEmpty(t, ev.Metadata[MetaExpiresAt])
|
||||
assert.Equal(t, "peerA", ev.Metadata["peer_name"], "caller metadata must pass through")
|
||||
|
||||
require.True(t, b.Respond(id, Decision{Accept: true}))
|
||||
<-done
|
||||
}
|
||||
|
||||
// TestConcurrentRequests verifies that two concurrent prompts are tracked
|
||||
// independently. A bug that aliases ids would let one Respond unblock
|
||||
// the wrong waiter (a silent accept across prompts).
|
||||
func TestConcurrentRequests(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
const n = 20
|
||||
results := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
results <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||
}()
|
||||
}
|
||||
|
||||
ids := waitForNRequestIDs(t, pub, n)
|
||||
require.Len(t, ids, n)
|
||||
|
||||
// Deny exactly half, accept the rest. Track outcome per id so we can
|
||||
// match each Request's return value against the response we sent.
|
||||
denySet := make(map[string]bool, n)
|
||||
for i, id := range ids {
|
||||
deny := i%2 == 0
|
||||
denySet[id] = deny
|
||||
require.True(t, b.Respond(id, Decision{Accept: !deny}))
|
||||
}
|
||||
|
||||
// Collect all returns and check no nil errors slipped past a deny.
|
||||
var accepted, denied atomic.Int32
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case err := <-results:
|
||||
if err == nil {
|
||||
accepted.Add(1)
|
||||
} else {
|
||||
assert.ErrorIs(t, err, ErrDenied)
|
||||
denied.Add(1)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("only got %d/%d responses", i, n)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, int32(n/2), denied.Load())
|
||||
assert.Equal(t, int32(n/2), accepted.Load())
|
||||
}
|
||||
|
||||
// waitForRequestID blocks until the publisher sees its next event and
|
||||
// returns the request_id stamped on it.
|
||||
func waitForRequestID(t *testing.T, pub *fakePublisher) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
pub.mu.Lock()
|
||||
count := len(pub.events)
|
||||
var id string
|
||||
if count > 0 {
|
||||
id = pub.events[count-1].Metadata[MetaRequestID]
|
||||
}
|
||||
pub.mu.Unlock()
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timeout waiting for emitted event")
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForNRequestIDs(t *testing.T, pub *fakePublisher, n int) []string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
pub.mu.Lock()
|
||||
count := len(pub.events)
|
||||
pub.mu.Unlock()
|
||||
if count >= n {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
pub.mu.Lock()
|
||||
defer pub.mu.Unlock()
|
||||
out := make([]string, 0, len(pub.events))
|
||||
seen := make(map[string]struct{}, len(pub.events))
|
||||
for _, ev := range pub.events {
|
||||
id := ev.Metadata[MetaRequestID]
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
if len(out) < n {
|
||||
t.Fatalf("only got %d/%d request ids", len(out), n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// defaultTimeout swaps the broker's per-request wall-clock window so the
|
||||
// timeout tests run quickly. Restores the prior value on the next call.
|
||||
func defaultTimeout(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
if d <= 0 {
|
||||
t.Fatal("defaultTimeout must be > 0")
|
||||
}
|
||||
timeoutValue = func() time.Duration { return d }
|
||||
}
|
||||
|
||||
// requestErr wraps Broker.Request to drop the Decision when tests only
|
||||
// care about the error path. Keeps the goroutine bodies tight.
|
||||
func requestErr(b *Broker, ctx context.Context, p Prompt) error {
|
||||
_, err := b.Request(ctx, p)
|
||||
return err
|
||||
}
|
||||
|
||||
// TestRequestViewOnly checks the view-only outcome flows through Request's
|
||||
// Decision return without being silently swallowed.
|
||||
func TestRequestViewOnly(t *testing.T) {
|
||||
pub := &fakePublisher{subscribers: true}
|
||||
b := New(pub)
|
||||
|
||||
type result struct {
|
||||
d Decision
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
d, err := b.Request(context.Background(), Prompt{Kind: KindVNC})
|
||||
done <- result{d, err}
|
||||
}()
|
||||
|
||||
id := waitForRequestID(t, pub)
|
||||
require.True(t, b.Respond(id, Decision{Accept: true, ViewOnly: true}))
|
||||
|
||||
select {
|
||||
case r := <-done:
|
||||
assert.NoError(t, r.err)
|
||||
assert.True(t, r.d.Accept)
|
||||
assert.True(t, r.d.ViewOnly, "ViewOnly must survive the round-trip")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("view-only request did not resolve")
|
||||
}
|
||||
}
|
||||
62
client/internal/approval/fingerprint_test.go
Normal file
62
client/internal/approval/fingerprint_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package approval
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestShortKeyFingerprint locks in the format the VNC approval prompt
|
||||
// shows to the user. The fingerprint is the user's only cryptographic
|
||||
// anchor against a malicious management server that pushes a spoofed
|
||||
// display name, so accidental changes to its format would silently
|
||||
// undermine that defence.
|
||||
func TestShortKeyFingerprint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full_32_byte_pubkey",
|
||||
in: "0123456789abcdeffedcba9876543210ffeeddccbbaa99887766554433221100",
|
||||
want: "0123-4567-89ab-cdef",
|
||||
},
|
||||
{
|
||||
name: "exactly_16_chars",
|
||||
in: "0123456789abcdef",
|
||||
want: "0123-4567-89ab-cdef",
|
||||
},
|
||||
{
|
||||
name: "borderline_8_chars",
|
||||
in: "01234567",
|
||||
want: "0123-4567",
|
||||
},
|
||||
{
|
||||
name: "too_short_returns_empty",
|
||||
in: "0123",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty_returns_empty",
|
||||
in: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ShortKeyFingerprint(tc.in)
|
||||
if got != tc.want {
|
||||
t.Fatalf("ShortKeyFingerprint(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortKeyFingerprint_DistinctKeysDistinctOutputs guards against a
|
||||
// formatting bug that would collapse different prefixes onto the same
|
||||
// displayed fingerprint and let an attacker substitute their pubkey for
|
||||
// a victim's while keeping the prompt visually identical.
|
||||
func TestShortKeyFingerprint_DistinctKeysDistinctOutputs(t *testing.T) {
|
||||
a := ShortKeyFingerprint("0123456789abcdef" + "rest_of_pubkey_ignored")
|
||||
b := ShortKeyFingerprint("0123456789abcde0" + "rest_of_pubkey_ignored")
|
||||
if a == b {
|
||||
t.Fatalf("expected distinct outputs for distinct prefixes, both = %q", a)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -344,6 +315,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
||||
a.config.RosenpassEnabled,
|
||||
a.config.RosenpassPermissive,
|
||||
a.config.ServerSSHAllowed,
|
||||
a.config.ServerVNCAllowed,
|
||||
a.config.DisableClientRoutes,
|
||||
a.config.DisableServerRoutes,
|
||||
a.config.DisableDNS,
|
||||
@@ -503,16 +475,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,82 +0,0 @@
|
||||
package sessionwatch
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// internal event kinds are no longer exposed: the watcher drives the Sink
|
||||
// directly (NotifyStateChange on deadline change/clear, PublishEvent at
|
||||
// each warning lead). Tests use a mock Sink to observe what the watcher
|
||||
// emits.
|
||||
|
||||
// Metadata keys attached by the daemon to session-warning SystemEvents.
|
||||
// The UI tray reads these to build a locale-aware notification without
|
||||
// relying on the daemon's locale-less UserMessage string, and to
|
||||
// disambiguate the T-WarningLead notification from the T-FinalWarningLead
|
||||
// fallback that auto-opens the SessionAboutToExpire dialog.
|
||||
const (
|
||||
// MetaSessionWarning is set to "true" on both warning events (T-10 and
|
||||
// T-2) so the UI can detect a session-warning SystemEvent without
|
||||
// matching on the message text. Use MetaSessionFinal to distinguish
|
||||
// the two.
|
||||
MetaSessionWarning = "session_warning"
|
||||
// MetaSessionFinal is set to "true" on the T-FinalWarningLead event
|
||||
// only. Consumers that need to auto-open the SessionAboutToExpire
|
||||
// dialog gate on this; T-WarningLead events leave the field unset.
|
||||
MetaSessionFinal = "session_final_warning"
|
||||
// MetaSessionExpiresAt carries the absolute UTC deadline encoded with
|
||||
// FormatExpiresAt; consumers must decode with ParseExpiresAt so a
|
||||
// future format change stays a single edit.
|
||||
MetaSessionExpiresAt = "session_expires_at"
|
||||
// MetaSessionLeadMinutes carries the lead in whole minutes (WarningLead
|
||||
// for the T-10 event, FinalWarningLead for the T-2 event) so the UI
|
||||
// can show "expires in ~N minutes" without hardcoding either constant.
|
||||
MetaSessionLeadMinutes = "lead_minutes"
|
||||
// MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION
|
||||
// SystemEvent the daemon emits when it discards a deadline from the
|
||||
// management server (pre-epoch, too far in the future, or past the
|
||||
// clock-skew tolerance). The value is the rejection reason string.
|
||||
// userMessage is left empty; the UI detects the event via this key
|
||||
// and builds a localized notification — same pattern as the session
|
||||
// warnings above.
|
||||
MetaSessionDeadlineRejected = "session_deadline_rejected"
|
||||
)
|
||||
|
||||
// expiresAtLayout is the wire format used for MetaSessionExpiresAt.
|
||||
// Producer and consumers both go through FormatExpiresAt/ParseExpiresAt
|
||||
// so this layout stays a single source of truth.
|
||||
const expiresAtLayout = time.RFC3339
|
||||
|
||||
// FormatExpiresAt encodes a deadline for MetaSessionExpiresAt. Always
|
||||
// emits UTC so a consumer in another timezone reads the same wall-clock
|
||||
// deadline.
|
||||
func FormatExpiresAt(t time.Time) string {
|
||||
return t.UTC().Format(expiresAtLayout)
|
||||
}
|
||||
|
||||
// ParseExpiresAt decodes the MetaSessionExpiresAt value back to a UTC
|
||||
// time. Returns an error when the field is empty or malformed; the
|
||||
// caller decides whether to fall back (zero value) or propagate.
|
||||
func ParseExpiresAt(s string) (time.Time, error) {
|
||||
t, err := time.Parse(expiresAtLayout, s)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return t.UTC(), nil
|
||||
}
|
||||
|
||||
// FormatLeadMinutes encodes a lead duration for MetaSessionLeadMinutes
|
||||
// as the integer count of whole minutes. Sub-minute residuals are
|
||||
// truncated — the field is informational ("expires in ~N minutes") and
|
||||
// fractional minutes don't change what the UI displays.
|
||||
func FormatLeadMinutes(d time.Duration) string {
|
||||
return strconv.Itoa(int(d / time.Minute))
|
||||
}
|
||||
|
||||
// ParseLeadMinutes decodes a MetaSessionLeadMinutes value. Returns 0
|
||||
// and the parse error for malformed input; consumers that prefer a
|
||||
// silent fallback can simply ignore the error.
|
||||
func ParseLeadMinutes(s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
// Package sessionwatch tracks the SSO session expiry deadline that the
|
||||
// management server publishes via LoginResponse / SyncResponse and fires
|
||||
// two warning events at fixed lead times before expiry: an interactive
|
||||
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
|
||||
// fallback dialog.
|
||||
//
|
||||
// The watcher is idempotent: Update may be called as often as the network
|
||||
// map snapshots arrive. Repeating the same deadline is a no-op; a new
|
||||
// deadline reschedules the timers and arms a fresh warning cycle.
|
||||
//
|
||||
// Warning firing is edge-detected. Each unique deadline value fires each
|
||||
// warning callback at most once.
|
||||
package sessionwatch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
// Skew tolerates a small clock difference between the management
|
||||
// server and this peer before treating a deadline as "in the past".
|
||||
// Slightly above typical NTP drift; tight enough that the UI doesn't
|
||||
// paint a stale expiry as if it were valid.
|
||||
Skew = 30 * time.Second
|
||||
|
||||
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||
// glitch, and silently arming a 100-year timer would hide the bug.
|
||||
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
|
||||
|
||||
// WarningLead is how far before expiry the first (interactive)
|
||||
// warning fires. Drives the T-10 OS notification with
|
||||
// Extend/Dismiss actions.
|
||||
WarningLead = 10 * time.Minute
|
||||
|
||||
// FinalWarningLead is how far before expiry the fallback final
|
||||
// warning fires. Drives the auto-opened SessionAboutToExpire dialog,
|
||||
// but only when the user has not dismissed the T-WarningLead warning
|
||||
// for the same deadline. Must be strictly less than WarningLead.
|
||||
FinalWarningLead = 2 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDeadlineBeforeEpoch is returned by Update when the supplied
|
||||
// deadline pre-dates 1970-01-01.
|
||||
ErrDeadlineBeforeEpoch = errors.New("session deadline before unix epoch")
|
||||
|
||||
// ErrDeadlineTooFarFuture is returned by Update when the supplied
|
||||
// deadline is more than maxDeadlineHorizon in the future.
|
||||
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
|
||||
|
||||
// ErrDeadlineInPast is returned by Update when the supplied deadline
|
||||
// is more than Skew in the past.
|
||||
ErrDeadlineInPast = errors.New("session deadline in the past")
|
||||
)
|
||||
|
||||
// StatusRecorder is the side-effect surface the watcher drives on every
|
||||
// state transition. Production wires this to peer.Status (SetSessionExpiresAt
|
||||
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
||||
// a fake recorder so the same surface is observable without an engine.
|
||||
//
|
||||
// The watcher is the single owner of the deadline propagated to the
|
||||
// recorder: every set, clear, sanity-check rejection and Close routes the
|
||||
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
|
||||
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
|
||||
// fans out its own state-change notification, so no separate notify is
|
||||
// needed.) The recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
|
||||
// a profile switch) would leave the next session showing the previous one's
|
||||
// stale "expires in" value.
|
||||
//
|
||||
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
|
||||
// composes the metadata internally so the wire format (MetaSession*) is
|
||||
// owned by sessionwatch, not the caller.
|
||||
type StatusRecorder interface {
|
||||
SetSessionExpiresAt(deadline time.Time)
|
||||
PublishEvent(
|
||||
severity cProto.SystemEvent_Severity,
|
||||
category cProto.SystemEvent_Category,
|
||||
message string,
|
||||
userMessage string,
|
||||
metadata map[string]string,
|
||||
)
|
||||
}
|
||||
|
||||
// Watcher observes the latest session deadline and fires two warnings
|
||||
// before it expires: the interactive T-WarningLead notification, and the
|
||||
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
|
||||
// the first one for the same deadline). Safe for concurrent use.
|
||||
type Watcher struct {
|
||||
lead time.Duration
|
||||
finalLead time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
current time.Time
|
||||
timer *time.Timer
|
||||
finalTimer *time.Timer
|
||||
firedAt time.Time // deadline value the T-WarningLead callback last fired against
|
||||
finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against
|
||||
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
|
||||
closed bool
|
||||
recorder StatusRecorder
|
||||
}
|
||||
|
||||
// New returns a watcher with the package defaults WarningLead and
|
||||
// FinalWarningLead. Pass nil for recorder to silence side effects (handy
|
||||
// in unit tests that exercise sanity checks without observing the publish
|
||||
// path).
|
||||
func New(recorder StatusRecorder) *Watcher {
|
||||
return NewWithLeads(WarningLead, FinalWarningLead, recorder)
|
||||
}
|
||||
|
||||
// NewWithLeads returns a watcher with custom lead times. Useful for tests.
|
||||
// final must be strictly less than lead; otherwise both timers fire in the
|
||||
// wrong order or simultaneously and the UI flow breaks. A zero final lead
|
||||
// disables the final-warning timer entirely (see armTimerLocked) so a
|
||||
// millisecond-scale deadline doesn't flush both timers in one tick.
|
||||
func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||
return &Watcher{
|
||||
lead: lead,
|
||||
finalLead: final,
|
||||
recorder: recorder,
|
||||
}
|
||||
}
|
||||
|
||||
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
|
||||
// a Sync push from the server omits the field because login expiration
|
||||
// was disabled).
|
||||
//
|
||||
// Same-value updates are no-ops. A different non-zero value cancels any
|
||||
// pending timer, resets the "already fired" guard, and arms a new one.
|
||||
//
|
||||
// Returns one of the sentinel Err* values when the deadline fails the
|
||||
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
|
||||
// In every error case the watcher first clears its state so it stays
|
||||
// consistent with what the caller will push into its other sinks (e.g.
|
||||
// applySessionDeadline forces a zero deadline into the status recorder
|
||||
// after a non-nil error).
|
||||
func (w *Watcher) Update(deadline time.Time) error {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
if deadline.IsZero() {
|
||||
w.clearLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch {
|
||||
case deadline.Before(time.Unix(0, 0)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v", ErrDeadlineBeforeEpoch, deadline)
|
||||
case deadline.After(now.Add(maxDeadlineHorizon)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
|
||||
case deadline.Before(now.Add(-Skew)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
|
||||
}
|
||||
|
||||
if deadline.Equal(w.current) {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
w.stopTimerLocked()
|
||||
w.current = deadline
|
||||
// Reset every per-deadline guard so a refreshed deadline arms a fresh
|
||||
// warning cycle: both edge triggers and the user Dismiss decision
|
||||
// (the user agreed to the old deadline expiring; a new deadline
|
||||
// restarts the contract).
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
|
||||
w.armTimerLocked(deadline)
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil {
|
||||
recorder.SetSessionExpiresAt(deadline)
|
||||
}
|
||||
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deadline returns the most recently observed deadline. Zero when no
|
||||
// deadline is currently tracked.
|
||||
func (w *Watcher) Deadline() time.Time {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.current
|
||||
}
|
||||
|
||||
// Dismiss records the user's "Dismiss" action against the current deadline
|
||||
// and suppresses the upcoming final-warning callback for that deadline.
|
||||
// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
|
||||
// deadline resets the dismissal so the final-warning cycle re-arms.
|
||||
//
|
||||
// No-op when the watcher holds no deadline or has been closed.
|
||||
func (w *Watcher) Dismiss() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed || w.current.IsZero() {
|
||||
return
|
||||
}
|
||||
if w.dismissedAt.Equal(w.current) {
|
||||
return
|
||||
}
|
||||
w.dismissedAt = w.current
|
||||
// Cancel the armed final-warning timer eagerly. fireFinal would also
|
||||
// gate on dismissedAt, but stopping the timer avoids a wakeup with
|
||||
// nothing to do and makes the intent visible.
|
||||
if w.finalTimer != nil {
|
||||
w.finalTimer.Stop()
|
||||
w.finalTimer = nil
|
||||
}
|
||||
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Close stops any pending timer and drops the deadline on the status
|
||||
// recorder. Update calls after Close are ignored. Clearing the recorder
|
||||
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
|
||||
// from leaving the next session showing this one's stale "expires in"
|
||||
// value — the recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher, so nothing else drops the anchor on teardown.
|
||||
func (w *Watcher) Close() {
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
w.stopTimerLocked()
|
||||
hadDeadline := !w.current.IsZero()
|
||||
w.current = time.Time{}
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil && hadDeadline {
|
||||
recorder.SetSessionExpiresAt(time.Time{})
|
||||
}
|
||||
}
|
||||
|
||||
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||
// downstream consumers (SubscribeStatus stream, UI) drop their anchor.
|
||||
// The caller must hold w.mu; this helper releases it before invoking
|
||||
// the recorder.
|
||||
func (w *Watcher) clearLocked() {
|
||||
if w.current.IsZero() {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.stopTimerLocked()
|
||||
w.current = time.Time{}
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil {
|
||||
recorder.SetSessionExpiresAt(time.Time{})
|
||||
}
|
||||
log.Infof("auth session deadline cleared")
|
||||
}
|
||||
|
||||
func (w *Watcher) stopTimerLocked() {
|
||||
if w.timer != nil {
|
||||
w.timer.Stop()
|
||||
w.timer = nil
|
||||
}
|
||||
if w.finalTimer != nil {
|
||||
w.finalTimer.Stop()
|
||||
w.finalTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) armTimerLocked(deadline time.Time) {
|
||||
w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) })
|
||||
// finalLead <= 0 disables the final-warning timer entirely. Used by
|
||||
// tests that predate the final-warning fallback so a millisecond-scale
|
||||
// deadline does not flush both timers at once.
|
||||
if w.finalLead > 0 {
|
||||
w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) })
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) fire(armedFor time.Time) {
|
||||
w.mu.Lock()
|
||||
if w.closed || !w.current.Equal(armedFor) {
|
||||
// Deadline moved while we were waiting (e.g. a successful extend).
|
||||
// The reschedule path armed a fresh timer; this one is stale.
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.firedAt = armedFor
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
log.Infof("auth session expiry soon warning fired")
|
||||
publishWarning(recorder, armedFor, false)
|
||||
}
|
||||
|
||||
// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra
|
||||
// dismiss-gate: if the user dismissed the T-WarningLead notification for
|
||||
// this deadline, the final warning is suppressed entirely.
|
||||
func (w *Watcher) fireFinal(armedFor time.Time) {
|
||||
w.mu.Lock()
|
||||
if w.closed || !w.current.Equal(armedFor) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if w.dismissedAt.Equal(armedFor) {
|
||||
w.mu.Unlock()
|
||||
log.Infof("auth session final-warning skipped (dismissed by user)")
|
||||
return
|
||||
}
|
||||
w.finalFiredAt = armedFor
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
log.Infof("auth session final-warning fired")
|
||||
publishWarning(recorder, armedFor, true)
|
||||
}
|
||||
|
||||
// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
|
||||
// past it dispatches on the next scheduler tick so a state-change recorder
|
||||
// notification (invoked after w.mu is released) lands first. Caller must
|
||||
// hold w.mu.
|
||||
func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer {
|
||||
delay := time.Until(fireAt)
|
||||
if delay <= 0 {
|
||||
return time.AfterFunc(0, cb)
|
||||
}
|
||||
return time.AfterFunc(delay, cb)
|
||||
}
|
||||
|
||||
// publishWarning composes the SystemEvent for a watcher-fired warning and
|
||||
// pushes it through the recorder. Severity is CRITICAL on both — bypassing
|
||||
// the user's Notifications toggle is deliberate: missing the warning
|
||||
// window forces the post-mortem SessionExpired flow (tunnel torn down,
|
||||
// lock icon, manual re-login), which is the UX we are trying to avoid.
|
||||
func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
|
||||
lead := WarningLead
|
||||
message := "session expiry warning"
|
||||
meta := map[string]string{
|
||||
MetaSessionWarning: "true",
|
||||
MetaSessionExpiresAt: FormatExpiresAt(deadline),
|
||||
}
|
||||
if final {
|
||||
lead = FinalWarningLead
|
||||
message = "session expiry final warning"
|
||||
meta[MetaSessionFinal] = "true"
|
||||
}
|
||||
meta[MetaSessionLeadMinutes] = FormatLeadMinutes(lead)
|
||||
|
||||
recorder.PublishEvent(
|
||||
cProto.SystemEvent_CRITICAL,
|
||||
cProto.SystemEvent_AUTHENTICATION,
|
||||
message,
|
||||
"",
|
||||
meta,
|
||||
)
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
package sessionwatch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// fakeRecorder satisfies StatusRecorder and records every call so tests
|
||||
// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent
|
||||
// land in the same ordered events slice (with the Kind distinguishing
|
||||
// them) so tests that care about ordering still work. lastDeadline holds
|
||||
// the most recent value passed to SetSessionExpiresAt so tests can assert
|
||||
// the recorder ended up cleared/set as expected.
|
||||
type fakeRecorder struct {
|
||||
mu sync.Mutex
|
||||
events []event
|
||||
lastDeadline time.Time
|
||||
}
|
||||
|
||||
type eventKind int
|
||||
|
||||
const (
|
||||
stateChange eventKind = iota
|
||||
publish
|
||||
)
|
||||
|
||||
type event struct {
|
||||
kind eventKind
|
||||
// Set only for publish events.
|
||||
severity cProto.SystemEvent_Severity
|
||||
category cProto.SystemEvent_Category
|
||||
message string
|
||||
meta map[string]string
|
||||
}
|
||||
|
||||
// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op,
|
||||
// a real change records the new value and fans out a state-change (the
|
||||
// production recorder calls notifyStateChange internally). The baseline
|
||||
// is the zero time, so an initial clear before any deadline is set emits
|
||||
// nothing — matching the real recorder.
|
||||
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.lastDeadline.Equal(deadline) {
|
||||
return
|
||||
}
|
||||
r.lastDeadline = deadline
|
||||
r.events = append(r.events, event{kind: stateChange})
|
||||
}
|
||||
|
||||
func (r *fakeRecorder) deadline() time.Time {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.lastDeadline
|
||||
}
|
||||
|
||||
func (r *fakeRecorder) PublishEvent(
|
||||
severity cProto.SystemEvent_Severity,
|
||||
category cProto.SystemEvent_Category,
|
||||
message string,
|
||||
_ string,
|
||||
metadata map[string]string,
|
||||
) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.events = append(r.events, event{
|
||||
kind: publish,
|
||||
severity: severity,
|
||||
category: category,
|
||||
message: message,
|
||||
meta: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *fakeRecorder) snapshot() []event {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]event, len(r.events))
|
||||
copy(out, r.events)
|
||||
return out
|
||||
}
|
||||
|
||||
func (e event) isFinalWarning() bool {
|
||||
return e.kind == publish && e.meta[MetaSessionFinal] == "true"
|
||||
}
|
||||
|
||||
func (e event) isWarning() bool {
|
||||
return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true"
|
||||
}
|
||||
|
||||
func countWhere(events []event, pred func(event) bool) int {
|
||||
n := 0
|
||||
for _, e := range events {
|
||||
if pred(e) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if got := r.snapshot(); len(got) >= want {
|
||||
return got
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
got := r.snapshot()
|
||||
t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got)
|
||||
return nil
|
||||
}
|
||||
|
||||
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
|
||||
// matching the lead-only behaviour the pre-final-warning tests assume.
|
||||
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
||||
return NewWithLeads(lead, 0, r)
|
||||
}
|
||||
|
||||
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
_ = w.Update(time.Time{})
|
||||
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events on initial zero, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNonZeroFiresStateChange(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(time.Hour)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 1)
|
||||
if events[0].kind != stateChange {
|
||||
t.Fatalf("expected stateChange, got %+v", events[0])
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameDeadlineIsNoop(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(time.Hour)
|
||||
_ = w.Update(d)
|
||||
_ = w.Update(d)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 1)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
lead := 50 * time.Millisecond
|
||||
w := newWatcher(lead, r)
|
||||
defer w.Close()
|
||||
|
||||
// Deadline 80ms out — warning should fire after ~30ms.
|
||||
d := time.Now().Add(80 * time.Millisecond)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[0].kind != stateChange {
|
||||
t.Fatalf("event[0] should be stateChange, got %+v", events[0])
|
||||
}
|
||||
if !events[1].isWarning() {
|
||||
t.Fatalf("event[1] should be a warning publish, got %+v", events[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r) // lead > delta => fire immediately
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(10 * time.Millisecond)
|
||||
_ = w.Update(d)
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if !events[1].isWarning() {
|
||||
t.Fatalf("expected immediate warning publish, got %+v", events[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
lead := 50 * time.Millisecond
|
||||
w := newWatcher(lead, r)
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
|
||||
_ = w.Update(first)
|
||||
|
||||
// Replace with a far-future deadline before the warning fires.
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
second := time.Now().Add(time.Hour)
|
||||
_ = w.Update(second)
|
||||
|
||||
// Wait past when first's warning would have fired.
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
|
||||
t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
lead := 30 * time.Millisecond
|
||||
w := newWatcher(lead, r)
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(50 * time.Millisecond)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Wait for stateChange + warning of the first cycle.
|
||||
waitForEvents(t, r, 2)
|
||||
|
||||
// Simulate a successful extend: brand new deadline.
|
||||
second := time.Now().Add(60 * time.Millisecond)
|
||||
_ = w.Update(second)
|
||||
|
||||
// 4 events total: stateChange, warning (first), stateChange, warning (second).
|
||||
events := waitForEvents(t, r, 4)
|
||||
if events[2].kind != stateChange {
|
||||
t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2])
|
||||
}
|
||||
if !events[3].isWarning() {
|
||||
t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(2 * time.Hour)
|
||||
_ = w.Update(d)
|
||||
waitForEvents(t, r, 1)
|
||||
|
||||
_ = w.Update(time.Time{})
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[1].kind != stateChange {
|
||||
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("Deadline should be zero after clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRejectsBeforeEpoch(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
good := time.Now().Add(time.Hour)
|
||||
if err := w.Update(good); err != nil {
|
||||
t.Fatalf("seed Update: %v", err)
|
||||
}
|
||||
|
||||
err := w.Update(time.Unix(-100, 0))
|
||||
if !errors.Is(err, ErrDeadlineBeforeEpoch) {
|
||||
t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err)
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
good := time.Now().Add(time.Hour)
|
||||
if err := w.Update(good); err != nil {
|
||||
t.Fatalf("seed Update: %v", err)
|
||||
}
|
||||
|
||||
err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour))
|
||||
if !errors.Is(err, ErrDeadlineTooFarFuture) {
|
||||
t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err)
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
good := time.Now().Add(time.Hour)
|
||||
if err := w.Update(good); err != nil {
|
||||
t.Fatalf("seed Update: %v", err)
|
||||
}
|
||||
// Drain the stateChange from the seed.
|
||||
waitForEvents(t, r, 1)
|
||||
|
||||
err := w.Update(time.Now().Add(-1 * time.Hour))
|
||||
if !errors.Is(err, ErrDeadlineInPast) {
|
||||
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
|
||||
}
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[1].kind != stateChange {
|
||||
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWithinSkewAccepted(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
|
||||
d := time.Now().Add(-5 * time.Second)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("within-skew Update should succeed, got %v", err)
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseSilencesUpdates(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
w.Close()
|
||||
|
||||
_ = w.Update(time.Now().Add(time.Hour))
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events after Close, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
|
||||
// holding a live deadline must zero the recorder on Close so the next
|
||||
// engine's watcher (and the UI reading the shared server-scoped recorder)
|
||||
// doesn't start out showing the previous session's stale "expires in".
|
||||
func TestCloseClearsRecorderDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r)
|
||||
|
||||
d := time.Now().Add(2 * time.Hour)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("seed Update: %v", err)
|
||||
}
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("recorder deadline after Update = %v, want %v", got, d)
|
||||
}
|
||||
|
||||
w.Close()
|
||||
|
||||
if got := r.deadline(); !got.IsZero() {
|
||||
t.Fatalf("recorder deadline after Close = %v, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric
|
||||
// case: closing a watcher that never held a deadline must not emit a
|
||||
// redundant clear (the recorder may legitimately hold a value written by
|
||||
// some other path; the watcher only owns what it set).
|
||||
func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r)
|
||||
|
||||
w.Close()
|
||||
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events from Close on an empty watcher, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
// Warning fires at deadline-80ms, final at deadline-30ms.
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Expect stateChange + warning + final-warning.
|
||||
events := waitForEvents(t, r, 3)
|
||||
|
||||
if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 {
|
||||
t.Fatalf("expected exactly 1 stateChange, got %+v", events)
|
||||
}
|
||||
if countWhere(events, event.isWarning) != 1 {
|
||||
t.Fatalf("expected exactly 1 warning publish, got %+v", events)
|
||||
}
|
||||
if countWhere(events, event.isFinalWarning) != 1 {
|
||||
t.Fatalf("expected exactly 1 final-warning publish, got %+v", events)
|
||||
}
|
||||
|
||||
// Warning must precede final (same deadline, longer lead fires first).
|
||||
var wIdx, fIdx int
|
||||
for i, e := range events {
|
||||
switch {
|
||||
case e.isWarning():
|
||||
wIdx = i
|
||||
case e.isFinalWarning():
|
||||
fIdx = i
|
||||
}
|
||||
}
|
||||
if wIdx > fIdx {
|
||||
t.Fatalf("warning must publish before final-warning, got order %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissSuppressesFinalWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Wait for the warning publish so we know we're inside the warning
|
||||
// window, then dismiss before the final timer would fire.
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if countWhere(r.snapshot(), event.isWarning) >= 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
if countWhere(r.snapshot(), event.isWarning) < 1 {
|
||||
t.Fatalf("warning did not publish in time, events=%+v", r.snapshot())
|
||||
}
|
||||
|
||||
w.Dismiss()
|
||||
|
||||
// Now wait past when the final would have fired.
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 {
|
||||
t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissResetByNewDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(100 * time.Millisecond)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Dismiss against the first deadline.
|
||||
w.Dismiss()
|
||||
|
||||
// Replace with a fresh deadline before the first's timers complete.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
second := time.Now().Add(100 * time.Millisecond)
|
||||
_ = w.Update(second)
|
||||
|
||||
// The second cycle must publish a final-warning (the dismiss state
|
||||
// did not carry over).
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if countWhere(r.snapshot(), event.isFinalWarning) < 1 {
|
||||
t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
|
||||
w.Dismiss()
|
||||
|
||||
d := time.Now().Add(100 * time.Millisecond)
|
||||
_ = w.Update(d)
|
||||
|
||||
// Final warning should still publish — Dismiss only acts on the current
|
||||
// deadline, and there was none at the time of the call.
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
||||
}
|
||||
@@ -257,15 +257,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)
|
||||
@@ -399,10 +390,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)
|
||||
|
||||
@@ -443,11 +430,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) {
|
||||
@@ -585,6 +568,8 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
RosenpassEnabled: config.RosenpassEnabled,
|
||||
RosenpassPermissive: config.RosenpassPermissive,
|
||||
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
||||
ServerVNCAllowed: config.ServerVNCAllowed != nil && *config.ServerVNCAllowed,
|
||||
DisableVNCApproval: config.DisableVNCApproval,
|
||||
EnableSSHRoot: config.EnableSSHRoot,
|
||||
EnableSSHSFTP: config.EnableSSHSFTP,
|
||||
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
||||
@@ -667,6 +652,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
||||
config.RosenpassEnabled,
|
||||
config.RosenpassPermissive,
|
||||
config.ServerSSHAllowed,
|
||||
config.ServerVNCAllowed,
|
||||
config.DisableClientRoutes,
|
||||
config.DisableServerRoutes,
|
||||
config.DisableDNS,
|
||||
|
||||
@@ -644,6 +644,12 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
if g.internalConfig.SSHJWTCacheTTL != nil {
|
||||
configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL))
|
||||
}
|
||||
if g.internalConfig.ServerVNCAllowed != nil {
|
||||
configContent.WriteString(fmt.Sprintf("ServerVNCAllowed: %v\n", *g.internalConfig.ServerVNCAllowed))
|
||||
}
|
||||
if g.internalConfig.DisableVNCApproval != nil {
|
||||
configContent.WriteString(fmt.Sprintf("DisableVNCApproval: %v\n", *g.internalConfig.DisableVNCApproval))
|
||||
}
|
||||
|
||||
configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes))
|
||||
configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes))
|
||||
@@ -806,6 +812,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)
|
||||
@@ -818,6 +826,27 @@ 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()
|
||||
|
||||
@@ -862,6 +862,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
RosenpassEnabled: true,
|
||||
RosenpassPermissive: true,
|
||||
ServerSSHAllowed: &bTrue,
|
||||
ServerVNCAllowed: &bTrue,
|
||||
DisableVNCApproval: &bTrue,
|
||||
EnableSSHRoot: &bTrue,
|
||||
EnableSSHSFTP: &bTrue,
|
||||
EnableSSHLocalPortForwarding: &bTrue,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/acl"
|
||||
"github.com/netbirdio/netbird/client/internal/approval"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
@@ -124,6 +125,8 @@ type EngineConfig struct {
|
||||
RosenpassPermissive bool
|
||||
|
||||
ServerSSHAllowed bool
|
||||
ServerVNCAllowed bool
|
||||
DisableVNCApproval *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -209,7 +212,9 @@ type Engine struct {
|
||||
|
||||
networkMonitor *networkmonitor.NetworkMonitor
|
||||
|
||||
sshServer sshServer
|
||||
sshServer sshServer
|
||||
vncSrv vncServer
|
||||
approvalBroker *approval.Broker
|
||||
|
||||
statusRecorder *peer.Status
|
||||
|
||||
@@ -260,20 +265,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
|
||||
@@ -309,6 +300,7 @@ func NewEngine(
|
||||
TURNs: []*stun.URI{},
|
||||
networkSerial: 0,
|
||||
statusRecorder: services.StatusRecorder,
|
||||
approvalBroker: approval.New(services.StatusRecorder),
|
||||
stateManager: services.StateManager,
|
||||
portForwardManager: portforward.NewManager(),
|
||||
checks: services.Checks,
|
||||
@@ -318,17 +310,6 @@ func NewEngine(
|
||||
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
|
||||
@@ -356,6 +337,10 @@ func (e *Engine) Stop() error {
|
||||
log.Warnf("failed to stop SSH server: %v", err)
|
||||
}
|
||||
|
||||
if err := e.stopVNCServer(); err != nil {
|
||||
log.Warnf("failed to stop VNC server: %v", err)
|
||||
}
|
||||
|
||||
e.cleanupSSHConfig()
|
||||
|
||||
if e.ingressGatewayMgr != nil {
|
||||
@@ -369,10 +354,6 @@ func (e *Engine) Stop() error {
|
||||
e.srWatcher.Close()
|
||||
}
|
||||
|
||||
if e.sessionWatcher != nil {
|
||||
e.sessionWatcher.Close()
|
||||
}
|
||||
|
||||
if e.updateManager != nil {
|
||||
e.updateManager.SetDownloadOnly()
|
||||
}
|
||||
@@ -905,8 +886,6 @@ 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)
|
||||
}
|
||||
@@ -954,14 +933,7 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// only apply new changes and ignore old ones
|
||||
if err := e.updateNetworkMap(nm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Persist sync response only after updateNetworkMap accepted and applied the update,
|
||||
// so GetLatestSyncResponse() never returns state the engine did not actually apply.
|
||||
// Done under the dedicated lock (syncRespMux), not under syncMsgMux.
|
||||
// Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
|
||||
// A non-nil syncStore is what marks persistence as enabled. Hold the lock 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.
|
||||
@@ -975,6 +947,11 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
}
|
||||
e.syncRespMux.RUnlock()
|
||||
|
||||
// only apply new changes and ignore old ones
|
||||
if err := e.updateNetworkMap(nm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", "", nil)
|
||||
|
||||
return nil
|
||||
@@ -1053,6 +1030,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -1100,6 +1078,10 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.updateVNC(); err != nil {
|
||||
log.Warnf("failed handling VNC server setup: %v", err)
|
||||
}
|
||||
|
||||
state := e.statusRecorder.GetLocalPeerState()
|
||||
state.IP = e.wgInterface.Address().String()
|
||||
state.IPv6 = e.wgInterface.Address().IPv6String()
|
||||
@@ -1187,7 +1169,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
ClientMetrics: e.clientMetrics,
|
||||
DaemonVersion: version.NetbirdVersion(),
|
||||
RefreshStatus: func() {
|
||||
e.RunHealthProbes(e.ctx, true)
|
||||
e.RunHealthProbes(true)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1227,6 +1209,7 @@ func (e *Engine) receiveManagementEvents() {
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -1416,6 +1399,11 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
||||
e.updateSSHServerAuth(networkMap.GetSshAuth())
|
||||
}
|
||||
|
||||
// VNC auth: always sync, including nil so cleared auth on the management
|
||||
// side is applied locally, and so it isn't skipped on the RemotePeersIsEmpty
|
||||
// cleanup path.
|
||||
e.updateVNCServerAuth(networkMap.GetVncAuth())
|
||||
|
||||
// must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
|
||||
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
|
||||
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
||||
@@ -1883,6 +1871,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -2084,20 +2073,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()
|
||||
@@ -2120,9 +2096,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)
|
||||
|
||||
@@ -2680,3 +2656,16 @@ func decodeRelayIP(b []byte) netip.Addr {
|
||||
}
|
||||
return ip.Unmap()
|
||||
}
|
||||
|
||||
// RespondApproval relays the user's decision for a pending approval to
|
||||
// the broker. viewOnly is honoured only when accept is true. Returns
|
||||
// true when the request_id matched a live prompt.
|
||||
func (e *Engine) RespondApproval(requestID string, accept, viewOnly bool) bool {
|
||||
if e == nil || e.approvalBroker == nil {
|
||||
return false
|
||||
}
|
||||
return e.approvalBroker.Respond(requestID, approval.Decision{
|
||||
Accept: accept,
|
||||
ViewOnly: accept && viewOnly,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
)
|
||||
|
||||
// ApplySessionDeadline propagates the absolute SSO session deadline carried on
|
||||
// LoginResponse / SyncResponse to both the watcher (for the edge-triggered
|
||||
// warning) and the status recorder (for the SubscribeStatus / Status RPC
|
||||
// snapshot the UI consumes).
|
||||
//
|
||||
// The wire field is 3-state:
|
||||
// - nil → snapshot carries no info; keep the
|
||||
// previously-anchored deadline (no-op)
|
||||
// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is
|
||||
// disabled; clear both sinks
|
||||
// - valid timestamp → new deadline; arm watcher, expose on
|
||||
// status recorder
|
||||
//
|
||||
// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected
|
||||
// value is treated as a clear on both sinks: the alternative — leaving the
|
||||
// previously-known deadline in place — risks the UI confidently displaying
|
||||
// a stale "expires in X" while the server has actually invalidated it.
|
||||
func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
|
||||
if ts == nil {
|
||||
return
|
||||
}
|
||||
var deadline time.Time
|
||||
// Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled".
|
||||
// Everything else flows through Watcher.Update, whose sanity-checks
|
||||
// reject out-of-range / pre-epoch / far-future / too-stale values and
|
||||
// clear on rejection.
|
||||
if ts.GetSeconds() != 0 || ts.GetNanos() != 0 {
|
||||
deadline = ts.AsTime().UTC()
|
||||
}
|
||||
if e.sessionWatcher == nil {
|
||||
return
|
||||
}
|
||||
// Watcher.Update owns the propagation to the status recorder (the
|
||||
// SubscribeStatus / Status snapshot the UI reads): a set writes the
|
||||
// deadline, a clear or a sanity-check rejection writes the zero value.
|
||||
// Keeping a single writer is what stops the recorder from drifting out
|
||||
// of sync with the warning timers.
|
||||
if err := e.sessionWatcher.Update(deadline); err != nil {
|
||||
log.Errorf("auth session deadline rejected: %v, clearing", err)
|
||||
e.statusRecorder.PublishEvent(
|
||||
cProto.SystemEvent_ERROR,
|
||||
cProto.SystemEvent_AUTHENTICATION,
|
||||
"session deadline rejected",
|
||||
"",
|
||||
map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DismissSessionWarning records the user's "Dismiss" click on the
|
||||
// T-WarningLead interactive notification and suppresses the upcoming
|
||||
// T-FinalWarningLead fallback for the current deadline. No-op when the
|
||||
// watcher is not running or holds no deadline.
|
||||
func (e *Engine) DismissSessionWarning() {
|
||||
if e.sessionWatcher == nil {
|
||||
return
|
||||
}
|
||||
e.sessionWatcher.Dismiss()
|
||||
}
|
||||
|
||||
// ExtendAuthSession asks the management server to refresh the SSO session
|
||||
// expiry deadline using the supplied JWT, then mirrors the new deadline into
|
||||
// the daemon's state. The tunnel is untouched; no resync, no reconnect.
|
||||
//
|
||||
// Returns the new absolute UTC deadline (or zero time when the server
|
||||
// reports the peer is not eligible for extension).
|
||||
func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) {
|
||||
if jwtToken == "" {
|
||||
return time.Time{}, errors.New("jwt token is required")
|
||||
}
|
||||
if e.mgmClient == nil {
|
||||
return time.Time{}, errors.New("management client is not initialised")
|
||||
}
|
||||
|
||||
info, err := system.GetInfoWithChecks(ctx, e.checks)
|
||||
if err != nil {
|
||||
log.Warnf("failed to collect system info for session extend: %v", err)
|
||||
info = system.GetInfo(ctx)
|
||||
}
|
||||
|
||||
resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("extend auth session on management: %w", err)
|
||||
}
|
||||
|
||||
e.ApplySessionDeadline(resp.GetSessionExpiresAt())
|
||||
|
||||
if resp.GetSessionExpiresAt().IsValid() {
|
||||
return resp.GetSessionExpiresAt().AsTime().UTC(), nil
|
||||
}
|
||||
return time.Time{}, nil
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the
|
||||
// wire field carried on LoginResponse / SyncResponse:
|
||||
//
|
||||
// - nil pointer → no info; previously-anchored deadline survives
|
||||
// - explicit zero value → "expiry disabled" sentinel; both sinks cleared
|
||||
// - valid future timestamp → new deadline propagated to both sinks
|
||||
func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
||||
newEngine := func() *Engine {
|
||||
recorder := peer.NewRecorder("")
|
||||
return &Engine{
|
||||
statusRecorder: recorder,
|
||||
sessionWatcher: sessionwatch.New(recorder),
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||
|
||||
e.ApplySessionDeadline(timestamppb.New(deadline))
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline),
|
||||
"status recorder should hold the new deadline")
|
||||
})
|
||||
|
||||
t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||
|
||||
e.ApplySessionDeadline(nil)
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded),
|
||||
"nil snapshot must not disturb the existing deadline")
|
||||
})
|
||||
|
||||
t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||
|
||||
// Explicit zero Timestamp{} (seconds=0, nanos=0) is the
|
||||
// "expiry disabled / not SSO" sentinel.
|
||||
e.ApplySessionDeadline(×tamppb.Timestamp{})
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||
"explicit zero sentinel must clear the deadline")
|
||||
})
|
||||
|
||||
t.Run("invalid timestamp clears the deadline", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||
|
||||
// Out-of-range nanos → IsValid()==false; same-meaning as the
|
||||
// disabled sentinel for downstream sinks.
|
||||
e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1})
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||
"invalid timestamp must clear the deadline")
|
||||
})
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build !js
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// newSessionWatcher returns the real SSO session expiry watcher for every
|
||||
// non-wasm build. The js/wasm build gets a no-op stub from
|
||||
// engine_sessionwatch_js.go so the sessionwatch package (and its timer
|
||||
// machinery) never links into the wasm binary.
|
||||
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
||||
return sessionwatch.New(recorder)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//go:build js
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The
|
||||
// wasm client never runs the engine's session-warning flow (the interactive
|
||||
// T-WarningLead notification and the T-FinalWarningLead fallback dialog live
|
||||
// in the desktop UI), so linking the full sessionwatch package (timers, event
|
||||
// composition) would only bloat the binary.
|
||||
//
|
||||
// It still mirrors the deadline into the status recorder so the SubscribeStatus
|
||||
// / Status snapshot the UI consumes stays correct — only the timer-driven
|
||||
// warnings are dropped.
|
||||
type noopSessionWatcher struct {
|
||||
recorder *peer.Status
|
||||
}
|
||||
|
||||
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
||||
return noopSessionWatcher{recorder: recorder}
|
||||
}
|
||||
|
||||
// Update mirrors the real watcher's recorder propagation without the timers or
|
||||
// sanity-check sentinels: a valid deadline is exposed on the status snapshot,
|
||||
// the zero time clears it.
|
||||
func (w noopSessionWatcher) Update(deadline time.Time) error {
|
||||
if w.recorder != nil {
|
||||
w.recorder.SetSessionExpiresAt(deadline)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (noopSessionWatcher) Dismiss() {
|
||||
// No-op: only suppresses the timer-driven final-warning, which this stub never arms.
|
||||
}
|
||||
|
||||
func (noopSessionWatcher) Close() {
|
||||
// No-op: no timers to stop and no state to unwind; the recorder is cleared via Update(zero).
|
||||
}
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
||||
sshserver "github.com/netbirdio/netbird/client/ssh/server"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
@@ -237,22 +237,18 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
|
||||
return errors.New("wg interface not initialized")
|
||||
}
|
||||
|
||||
wgAddr := e.wgInterface.Address()
|
||||
serverConfig := &sshserver.Config{
|
||||
HostKeyPEM: e.config.SSHKey,
|
||||
JWT: jwtConfig,
|
||||
HostKeyPEM: e.config.SSHKey,
|
||||
JWT: jwtConfig,
|
||||
NetstackNet: e.wgInterface.GetNet(),
|
||||
NetworkValidation: wgAddr,
|
||||
}
|
||||
server := sshserver.New(serverConfig)
|
||||
|
||||
wgAddr := e.wgInterface.Address()
|
||||
server.SetNetworkValidation(wgAddr)
|
||||
|
||||
netbirdIP := wgAddr.IP
|
||||
listenAddr := netip.AddrPortFrom(netbirdIP, sshserver.InternalSSHPort)
|
||||
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
server.SetNetstackNet(netstackNet)
|
||||
}
|
||||
|
||||
e.configureSSHServer(server)
|
||||
|
||||
if err := server.Start(e.ctx, listenAddr); err != nil {
|
||||
|
||||
302
client/internal/engine_vnc.go
Normal file
302
client/internal/engine_vnc.go
Normal file
@@ -0,0 +1,302 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/internal/approval"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/vnc"
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
type vncServer interface {
|
||||
Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error
|
||||
Stop() error
|
||||
ActiveSessions() []vncserver.ActiveSessionInfo
|
||||
}
|
||||
|
||||
func (e *Engine) setupVNCPortRedirection() error {
|
||||
if e.firewall == nil || e.wgInterface == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
localAddr := e.wgInterface.Address().IP
|
||||
if !localAddr.IsValid() {
|
||||
return errors.New("invalid local NetBird address")
|
||||
}
|
||||
|
||||
if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
|
||||
return fmt.Errorf("add VNC port redirection: %w", err)
|
||||
}
|
||||
log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vnc.ExternalPort, localAddr, vnc.InternalPort)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) cleanupVNCPortRedirection() error {
|
||||
if e.firewall == nil || e.wgInterface == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
localAddr := e.wgInterface.Address().IP
|
||||
if !localAddr.IsValid() {
|
||||
return errors.New("invalid local NetBird address")
|
||||
}
|
||||
|
||||
if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
|
||||
return fmt.Errorf("remove VNC port redirection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateVNC handles starting/stopping the VNC server based on the config flag.
|
||||
func (e *Engine) updateVNC() error {
|
||||
if !e.config.ServerVNCAllowed {
|
||||
if e.vncSrv != nil {
|
||||
log.Info("VNC server disabled, stopping")
|
||||
}
|
||||
return e.stopVNCServer()
|
||||
}
|
||||
|
||||
if e.config.BlockInbound {
|
||||
log.Info("VNC server disabled because inbound connections are blocked")
|
||||
return e.stopVNCServer()
|
||||
}
|
||||
|
||||
if e.vncSrv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.startVNCServer()
|
||||
}
|
||||
|
||||
func (e *Engine) startVNCServer() error {
|
||||
if e.wgInterface == nil {
|
||||
return errors.New("wg interface not initialized")
|
||||
}
|
||||
|
||||
capturer, injector, ok := newPlatformVNC()
|
||||
if !ok {
|
||||
log.Debug("VNC server not supported on this platform")
|
||||
return nil
|
||||
}
|
||||
|
||||
netbirdIP := e.wgInterface.Address().IP
|
||||
|
||||
var sessionRecorder func(vncserver.SessionTick)
|
||||
if e.clientMetrics != nil {
|
||||
sessionRecorder = func(t vncserver.SessionTick) {
|
||||
e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{
|
||||
Period: t.Period,
|
||||
BytesOut: t.BytesOut,
|
||||
Writes: t.Writes,
|
||||
FBUs: t.FBUs,
|
||||
MaxFBUBytes: t.MaxFBUBytes,
|
||||
MaxFBURects: t.MaxFBURects,
|
||||
MaxWriteBytes: t.MaxWriteBytes,
|
||||
WriteNanos: t.WriteNanos,
|
||||
})
|
||||
}
|
||||
}
|
||||
serviceMode := vncNeedsServiceMode()
|
||||
if serviceMode {
|
||||
log.Info("VNC: running as system service, enabling service mode (per-session agent proxy)")
|
||||
}
|
||||
requireApproval := e.config.DisableVNCApproval == nil || !*e.config.DisableVNCApproval
|
||||
srv := vncserver.New(vncserver.Config{
|
||||
Capturer: capturer,
|
||||
Injector: injector,
|
||||
IdentityKey: e.config.WgPrivateKey[:],
|
||||
ServiceMode: serviceMode,
|
||||
SessionRecorder: sessionRecorder,
|
||||
NetstackNet: e.wgInterface.GetNet(),
|
||||
RequireApproval: requireApproval,
|
||||
Approver: &vncApprover{broker: e.approvalBroker, statusRecorder: e.statusRecorder},
|
||||
})
|
||||
|
||||
listenAddr := netip.AddrPortFrom(netbirdIP, vnc.InternalPort)
|
||||
network := e.wgInterface.Address().Network
|
||||
if err := srv.Start(e.ctx, listenAddr, network); err != nil {
|
||||
return fmt.Errorf("start VNC server: %w", err)
|
||||
}
|
||||
|
||||
e.vncSrv = srv
|
||||
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
RegisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.RegisterNetstackService(nftypes.TCP, vnc.InternalPort)
|
||||
log.Debugf("registered VNC service with netstack for TCP:%d", vnc.InternalPort)
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.setupVNCPortRedirection(); err != nil {
|
||||
log.Warnf("setup VNC port redirection: %v", err)
|
||||
}
|
||||
|
||||
log.Info("VNC server enabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateVNCServerAuth updates VNC fine-grained access control from management.
|
||||
// A nil vncAuth clears all authorized users and session pubkeys so management
|
||||
// can revoke access by omitting the field on the next sync.
|
||||
func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) {
|
||||
if e.vncSrv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
vncSrv, ok := e.vncSrv.(*vncserver.Server)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if vncAuth == nil {
|
||||
vncSrv.UpdateVNCAuth(&sshauth.Config{})
|
||||
return
|
||||
}
|
||||
|
||||
protoUsers := vncAuth.GetAuthorizedUsers()
|
||||
authorizedUsers := make([]sshuserhash.UserIDHash, len(protoUsers))
|
||||
for i, hash := range protoUsers {
|
||||
if len(hash) != 16 {
|
||||
log.Warnf("invalid VNC auth hash length %d, expected 16", len(hash))
|
||||
return
|
||||
}
|
||||
authorizedUsers[i] = sshuserhash.UserIDHash(hash)
|
||||
}
|
||||
|
||||
machineUsers := make(map[string][]uint32)
|
||||
for osUser, indexes := range vncAuth.GetMachineUsers() {
|
||||
machineUsers[osUser] = indexes.GetIndexes()
|
||||
}
|
||||
|
||||
sessionPubKeys := make([]sshauth.SessionPubKey, 0, len(vncAuth.GetSessionPubKeys()))
|
||||
for _, pk := range vncAuth.GetSessionPubKeys() {
|
||||
pub := pk.GetPubKey()
|
||||
if len(pub) != 32 {
|
||||
log.Warnf("VNC session pubkey wrong length %d", len(pub))
|
||||
continue
|
||||
}
|
||||
hash := pk.GetUserIdHash()
|
||||
if len(hash) != 16 {
|
||||
log.Warnf("VNC session user id hash wrong length %d", len(hash))
|
||||
continue
|
||||
}
|
||||
sessionPubKeys = append(sessionPubKeys, sshauth.SessionPubKey{
|
||||
PubKey: pub,
|
||||
UserIDHash: sshuserhash.UserIDHash(hash),
|
||||
DisplayName: pk.GetDisplayName(),
|
||||
})
|
||||
}
|
||||
|
||||
vncSrv.UpdateVNCAuth(&sshauth.Config{
|
||||
AuthorizedUsers: authorizedUsers,
|
||||
MachineUsers: machineUsers,
|
||||
SessionPubKeys: sessionPubKeys,
|
||||
})
|
||||
}
|
||||
|
||||
// GetVNCServerStatus returns whether the VNC server is running and the list
|
||||
// of active VNC sessions. The pointer is captured under syncMsgMux so a
|
||||
// concurrent updateVNC/stopVNCServer cannot swap it out between the nil
|
||||
// check and the ActiveSessions call.
|
||||
func (e *Engine) GetVNCServerStatus() (enabled bool, sessions []vncserver.ActiveSessionInfo) {
|
||||
e.syncMsgMux.Lock()
|
||||
vncSrv := e.vncSrv
|
||||
e.syncMsgMux.Unlock()
|
||||
if vncSrv == nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, vncSrv.ActiveSessions()
|
||||
}
|
||||
|
||||
func (e *Engine) stopVNCServer() error {
|
||||
if e.vncSrv == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := e.cleanupVNCPortRedirection(); err != nil {
|
||||
log.Warnf("cleanup VNC port redirection: %v", err)
|
||||
}
|
||||
|
||||
if e.wgInterface != nil && e.wgInterface.GetNet() != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.UnregisterNetstackService(nftypes.TCP, vnc.InternalPort)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("stopping VNC server")
|
||||
err := e.vncSrv.Stop()
|
||||
e.vncSrv = nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("stop VNC server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vncApprover adapts the generic approval.Broker for the VNC server.
|
||||
type vncApprover struct {
|
||||
broker *approval.Broker
|
||||
statusRecorder *peer.Status
|
||||
}
|
||||
|
||||
func (a *vncApprover) Request(ctx context.Context, info vncserver.ApprovalInfo) (vncserver.ApprovalDecision, error) {
|
||||
// Resolve the source overlay IP to a peer FQDN for the prompt label.
|
||||
if info.PeerName == "" && info.SourceIP != "" && a.statusRecorder != nil {
|
||||
if fqdn, ok := a.statusRecorder.PeerByIP(info.SourceIP); ok {
|
||||
info.PeerName = fqdn
|
||||
}
|
||||
}
|
||||
subject := fmt.Sprintf("VNC connection from %s", displayPeer(info))
|
||||
meta := map[string]string{
|
||||
"peer_name": info.PeerName,
|
||||
"peer_pubkey": info.PeerPubKey,
|
||||
"source_ip": info.SourceIP,
|
||||
"mode": info.Mode,
|
||||
"username": info.Username,
|
||||
"initiator": info.Initiator,
|
||||
}
|
||||
d, err := a.broker.Request(ctx, approval.Prompt{
|
||||
Kind: approval.KindVNC,
|
||||
Subject: subject,
|
||||
Metadata: meta,
|
||||
})
|
||||
if err != nil {
|
||||
return vncserver.ApprovalDecision{}, err
|
||||
}
|
||||
return vncserver.ApprovalDecision{ViewOnly: d.ViewOnly}, nil
|
||||
}
|
||||
|
||||
func displayPeer(info vncserver.ApprovalInfo) string {
|
||||
if info.Initiator != "" {
|
||||
return info.Initiator
|
||||
}
|
||||
if info.PeerName != "" {
|
||||
return info.PeerName
|
||||
}
|
||||
if info.SourceIP != "" {
|
||||
return info.SourceIP
|
||||
}
|
||||
if info.PeerPubKey != "" {
|
||||
return info.PeerPubKey
|
||||
}
|
||||
return "unknown peer"
|
||||
}
|
||||
31
client/internal/engine_vnc_console_freebsd.go
Normal file
31
client/internal/engine_vnc_console_freebsd.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build freebsd
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
// newConsoleVNC builds the FreeBSD console fallback: vt(4) framebuffer
|
||||
// for capture, /dev/uinput for input. The uinput device requires the
|
||||
// `uinput` kernel module (`kldload uinput`); without it, input init
|
||||
// fails and we drop to a stub injector so the user still gets a
|
||||
// view-only screen mirror.
|
||||
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
poller := vncserver.NewFBPoller("")
|
||||
w, h := poller.Width(), poller.Height()
|
||||
if w == 0 || h == 0 {
|
||||
poller.Close()
|
||||
return nil, nil, fmt.Errorf("vt framebuffer init failed (vt may not allow mmap on this driver)")
|
||||
}
|
||||
if inj, err := vncserver.NewUInputInjector(w, h); err == nil {
|
||||
return poller, inj, nil
|
||||
} else {
|
||||
log.Infof("VNC console: uinput unavailable (%v); view-only mode. Run `kldload uinput` to enable input.", err)
|
||||
return poller, &vncserver.StubInputInjector{}, nil
|
||||
}
|
||||
}
|
||||
30
client/internal/engine_vnc_console_linux.go
Normal file
30
client/internal/engine_vnc_console_linux.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
// newConsoleVNC builds a framebuffer + uinput VNC backend for boxes
|
||||
// without a running X server. Used as the auto-fallback when
|
||||
// newPlatformVNC can't reach X. Returns an error when /dev/fb0 or
|
||||
// /dev/uinput aren't usable so the caller can drop back to a stub.
|
||||
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
poller := vncserver.NewFBPoller("")
|
||||
w, h := poller.Width(), poller.Height()
|
||||
if w == 0 || h == 0 {
|
||||
poller.Close()
|
||||
return nil, nil, fmt.Errorf("framebuffer capturer init failed (is /dev/fb0 readable?)")
|
||||
}
|
||||
inj, err := vncserver.NewUInputInjector(w, h)
|
||||
if err != nil {
|
||||
log.Debugf("uinput unavailable, falling back to view-only VNC: %v", err)
|
||||
return poller, &vncserver.StubInputInjector{}, nil
|
||||
}
|
||||
return poller, inj, nil
|
||||
}
|
||||
34
client/internal/engine_vnc_darwin.go
Normal file
34
client/internal/engine_vnc_darwin.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
capturer := vncserver.NewMacPoller()
|
||||
// Prompt for Screen Recording at server-enable time rather than first
|
||||
// client-connect. The native prompt is far easier for users to act on
|
||||
// in the moment they toggled VNC on than later when "the screen looks
|
||||
// like wallpaper" would otherwise be the only clue.
|
||||
vncserver.PrimeScreenCapturePermission()
|
||||
injector, err := vncserver.NewMacInputInjector()
|
||||
if err != nil {
|
||||
log.Debugf("VNC: macOS input injector: %v", err)
|
||||
return capturer, &vncserver.StubInputInjector{}, true
|
||||
}
|
||||
return capturer, injector, true
|
||||
}
|
||||
|
||||
// vncNeedsServiceMode reports whether the running process is a system
|
||||
// LaunchDaemon (root, parented by launchd). Daemons sit in the global
|
||||
// bootstrap namespace and cannot talk to WindowServer; we route capture
|
||||
// through a per-user agent in that case.
|
||||
func vncNeedsServiceMode() bool {
|
||||
return os.Geteuid() == 0 && os.Getppid() == 1
|
||||
}
|
||||
23
client/internal/engine_vnc_stub.go
Normal file
23
client/internal/engine_vnc_stub.go
Normal file
@@ -0,0 +1,23 @@
|
||||
//go:build js || ios || android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
type vncServer interface{}
|
||||
|
||||
func (e *Engine) updateVNC() error { return nil }
|
||||
|
||||
func (e *Engine) updateVNCServerAuth(auth *mgmProto.VNCAuth) {
|
||||
if auth == nil {
|
||||
return
|
||||
}
|
||||
log.Debugf("ignoring VNC auth push on platform without a VNC server: %d session pubkeys, %d authorized users",
|
||||
len(auth.GetSessionPubKeys()), len(auth.GetAuthorizedUsers()))
|
||||
}
|
||||
|
||||
func (e *Engine) stopVNCServer() error { return nil }
|
||||
13
client/internal/engine_vnc_windows.go
Normal file
13
client/internal/engine_vnc_windows.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package internal
|
||||
|
||||
import vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), true
|
||||
}
|
||||
|
||||
func vncNeedsServiceMode() bool {
|
||||
return vncserver.GetCurrentSessionID() == 0
|
||||
}
|
||||
35
client/internal/engine_vnc_x11.go
Normal file
35
client/internal/engine_vnc_x11.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build (linux && !android) || freebsd
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
// Prefer X11 when an X server is reachable. NewX11InputInjector probes
|
||||
// DISPLAY (and /proc) eagerly, so a non-nil error here means no X.
|
||||
injector, err := vncserver.NewX11InputInjector("", "", "")
|
||||
if err == nil {
|
||||
return vncserver.NewX11Poller("", ""), injector, true
|
||||
}
|
||||
log.Debugf("VNC: X11 not available: %v", err)
|
||||
|
||||
// Fallback for headless / pre-X states (kernel console, login manager
|
||||
// without X, physical server in recovery): stream the framebuffer and
|
||||
// inject input via /dev/uinput.
|
||||
consoleCap, consoleInj, err := newConsoleVNC()
|
||||
if err == nil {
|
||||
log.Infof("VNC: using framebuffer console capture (%dx%d)", consoleCap.Width(), consoleCap.Height())
|
||||
return consoleCap, consoleInj, true
|
||||
}
|
||||
log.Debugf("VNC: framebuffer console fallback unavailable: %v", err)
|
||||
|
||||
return &vncserver.StubCapturer{}, &vncserver.StubInputInjector{}, false
|
||||
}
|
||||
|
||||
func vncNeedsServiceMode() bool {
|
||||
return false
|
||||
}
|
||||
@@ -120,6 +120,36 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
|
||||
m.trimLocked()
|
||||
}
|
||||
|
||||
func (m *influxDBMetrics) RecordVNCSessionTick(_ context.Context, agentInfo AgentInfo, tick VNCSessionTick) {
|
||||
tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s",
|
||||
agentInfo.DeploymentType.String(),
|
||||
agentInfo.Version,
|
||||
agentInfo.OS,
|
||||
agentInfo.Arch,
|
||||
agentInfo.peerID,
|
||||
)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.samples = append(m.samples, influxSample{
|
||||
measurement: "netbird_vnc_traffic",
|
||||
tags: tags,
|
||||
fields: map[string]float64{
|
||||
"period_seconds": tick.Period.Seconds(),
|
||||
"bytes_out": float64(tick.BytesOut),
|
||||
"writes": float64(tick.Writes),
|
||||
"fbus": float64(tick.FBUs),
|
||||
"max_fbu_bytes": float64(tick.MaxFBUBytes),
|
||||
"max_fbu_rects": float64(tick.MaxFBURects),
|
||||
"max_write_bytes": float64(tick.MaxWriteBytes),
|
||||
"write_time_seconds": float64(tick.WriteNanos) / 1e9,
|
||||
},
|
||||
timestamp: time.Now(),
|
||||
})
|
||||
m.trimLocked()
|
||||
}
|
||||
|
||||
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
|
||||
result := "success"
|
||||
if !success {
|
||||
|
||||
@@ -59,6 +59,11 @@ type metricsImplementation interface {
|
||||
// RecordLoginDuration records how long the login to management took
|
||||
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
|
||||
|
||||
// RecordVNCSessionTick records a periodic snapshot of one VNC
|
||||
// session's wire activity. Called once per metricsConn tick interval
|
||||
// (and once at session close), only when the tick saw activity.
|
||||
RecordVNCSessionTick(ctx context.Context, agentInfo AgentInfo, tick VNCSessionTick)
|
||||
|
||||
// Export exports metrics in InfluxDB line protocol format
|
||||
Export(w io.Writer) error
|
||||
|
||||
@@ -78,6 +83,21 @@ type ClientMetrics struct {
|
||||
pushCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// VNCSessionTick is one sampling slice of a VNC session's wire activity.
|
||||
// BytesOut / Writes / FBUs / WriteNanos are deltas observed during this
|
||||
// tick; Max* fields are the high-water marks observed during the tick.
|
||||
// Period is the wall-clock duration the deltas cover.
|
||||
type VNCSessionTick struct {
|
||||
Period time.Duration
|
||||
BytesOut uint64
|
||||
Writes uint64
|
||||
FBUs uint64
|
||||
MaxFBUBytes uint64
|
||||
MaxFBURects uint64
|
||||
MaxWriteBytes uint64
|
||||
WriteNanos uint64
|
||||
}
|
||||
|
||||
// ConnectionStageTimestamps holds timestamps for each connection stage
|
||||
type ConnectionStageTimestamps struct {
|
||||
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
|
||||
@@ -127,6 +147,17 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
|
||||
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
|
||||
}
|
||||
|
||||
// RecordVNCSessionTick records a periodic snapshot of one VNC session.
|
||||
func (c *ClientMetrics) RecordVNCSessionTick(ctx context.Context, tick VNCSessionTick) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
agentInfo := c.agentInfo
|
||||
c.mu.RUnlock()
|
||||
c.impl.RecordVNCSessionTick(ctx, agentInfo, tick)
|
||||
}
|
||||
|
||||
// RecordLoginDuration records how long the login to management server took
|
||||
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
|
||||
if c == nil {
|
||||
|
||||
@@ -73,6 +73,9 @@ func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.
|
||||
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
|
||||
}
|
||||
|
||||
func (m *mockMetrics) RecordVNCSessionTick(_ context.Context, _ AgentInfo, _ VNCSessionTick) {
|
||||
}
|
||||
|
||||
func (m *mockMetrics) Export(w io.Writer) error {
|
||||
if m.exportData != "" {
|
||||
_, err := w.Write([]byte(m.exportData))
|
||||
|
||||
@@ -5,10 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -193,27 +191,21 @@ 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
|
||||
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
|
||||
@@ -229,21 +221,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
|
||||
@@ -257,7 +234,6 @@ func NewRecorder(mgmAddress string) *Status {
|
||||
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,
|
||||
@@ -416,7 +392,6 @@ func (d *Status) UpdatePeerState(receivedState State) error {
|
||||
if notifyRouter {
|
||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -442,7 +417,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
|
||||
}
|
||||
|
||||
@@ -468,7 +442,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
|
||||
}
|
||||
|
||||
@@ -518,7 +491,6 @@ func (d *Status) UpdatePeerICEState(receivedState State) error {
|
||||
if notifyRouter {
|
||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -555,7 +527,6 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error {
|
||||
if notifyRouter {
|
||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -591,7 +562,6 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error
|
||||
if notifyRouter {
|
||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -630,7 +600,6 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error {
|
||||
if notifyRouter {
|
||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||
}
|
||||
d.notifyStateChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -724,7 +693,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 {
|
||||
@@ -783,41 +751,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
|
||||
@@ -886,19 +819,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
|
||||
@@ -906,16 +831,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
|
||||
@@ -923,7 +843,6 @@ func (d *Status) MarkManagementConnected() {
|
||||
d.mux.Unlock()
|
||||
|
||||
d.notifier.updateServerStates(mgm, sig)
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// UpdateSignalAddress update the address of the signal server
|
||||
@@ -957,10 +876,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
|
||||
@@ -968,16 +883,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
|
||||
@@ -985,7 +895,6 @@ func (d *Status) MarkSignalConnected() {
|
||||
d.mux.Unlock()
|
||||
|
||||
d.notifier.updateServerStates(mgm, sig)
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
|
||||
@@ -1183,19 +1092,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
|
||||
@@ -1317,6 +1223,15 @@ func (d *Status) SubscribeToEvents() *EventSubscription {
|
||||
}
|
||||
}
|
||||
|
||||
// HasEventSubscribers reports whether any client is currently subscribed
|
||||
// to the daemon's SystemEvent stream. Used by the VNC approval broker to
|
||||
// fail closed when no UI is connected to prompt the user.
|
||||
func (d *Status) HasEventSubscribers() bool {
|
||||
d.eventMux.Lock()
|
||||
defer d.eventMux.Unlock()
|
||||
return len(d.eventStreams) > 0
|
||||
}
|
||||
|
||||
// UnsubscribeFromEvents removes an event subscription
|
||||
func (d *Status) UnsubscribeFromEvents(sub *EventSubscription) {
|
||||
if sub == nil {
|
||||
@@ -1337,82 +1252,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()
|
||||
|
||||
@@ -297,39 +297,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")
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ type ConfigInput struct {
|
||||
StateFilePath string
|
||||
PreSharedKey *string
|
||||
ServerSSHAllowed *bool
|
||||
ServerVNCAllowed *bool
|
||||
DisableVNCApproval *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -116,6 +118,8 @@ type Config struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed *bool
|
||||
ServerVNCAllowed *bool
|
||||
DisableVNCApproval *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -418,6 +422,33 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.ServerVNCAllowed != nil {
|
||||
if config.ServerVNCAllowed == nil || *input.ServerVNCAllowed != *config.ServerVNCAllowed {
|
||||
if *input.ServerVNCAllowed {
|
||||
log.Infof("enabling VNC server")
|
||||
} else {
|
||||
log.Infof("disabling VNC server")
|
||||
}
|
||||
config.ServerVNCAllowed = input.ServerVNCAllowed
|
||||
updated = true
|
||||
}
|
||||
} else if config.ServerVNCAllowed == nil {
|
||||
config.ServerVNCAllowed = util.False()
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.DisableVNCApproval != nil {
|
||||
if config.DisableVNCApproval == nil || *input.DisableVNCApproval != *config.DisableVNCApproval {
|
||||
if *input.DisableVNCApproval {
|
||||
log.Infof("disabling VNC connection approval prompt")
|
||||
} else {
|
||||
log.Infof("enabling VNC connection approval prompt")
|
||||
}
|
||||
config.DisableVNCApproval = input.DisableVNCApproval
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot {
|
||||
if *input.EnableSSHRoot {
|
||||
log.Infof("enabling SSH root login")
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
@@ -60,22 +59,3 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProfileState deletes the per-profile state file (which holds the
|
||||
// account email used for the SSO login hint and the UI display). Called after
|
||||
// a successful logout so a logged-out profile no longer shows a stale account
|
||||
// email. The state file only stores the email, so deleting it is equivalent to
|
||||
// clearing it; the next SSO login recreates it. A missing file is not an error.
|
||||
func (pm *ProfileManager) RemoveProfileState(profileName string) error {
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get config directory: %w", err)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, profileName+".state.json")
|
||||
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove profile state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
package routemanager
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func newExitNodeTestManager() *DefaultManager {
|
||||
return &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||
}
|
||||
|
||||
func exitRoute(netID, peer string, skipAutoApply bool) *route.Route {
|
||||
return &route.Route{
|
||||
NetID: route.NetID(netID),
|
||||
Network: netip.MustParsePrefix("0.0.0.0/0"),
|
||||
Peer: peer,
|
||||
SkipAutoApply: skipAutoApply,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickPreferredExitNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info exitNodeInfo
|
||||
want route.NetID
|
||||
}{
|
||||
{
|
||||
name: "persisted user selection wins over management",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"a", "b", "c"},
|
||||
userSelected: []route.NetID{"b"},
|
||||
selectedByManagement: []route.NetID{"a"},
|
||||
},
|
||||
want: "b",
|
||||
},
|
||||
{
|
||||
name: "multiple user-selected self-heal to deterministic min",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"a", "b", "c"},
|
||||
userSelected: []route.NetID{"c", "a"},
|
||||
},
|
||||
want: "a",
|
||||
},
|
||||
{
|
||||
name: "explicit opt-out keeps none",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"a", "b"},
|
||||
userDeselected: []route.NetID{"a", "b"},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "fresh defaults to management auto-apply pick",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"a", "b", "c"},
|
||||
selectedByManagement: []route.NetID{"b"},
|
||||
},
|
||||
want: "b",
|
||||
},
|
||||
{
|
||||
name: "no user pick and no management auto-apply selects none",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"c", "a", "b"},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "user-deselect does not block a management auto-apply sibling",
|
||||
info: exitNodeInfo{
|
||||
allIDs: []route.NetID{"a", "b"},
|
||||
userDeselected: []route.NetID{"a"},
|
||||
selectedByManagement: []route.NetID{"b"},
|
||||
},
|
||||
want: "b",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, pickPreferredExitNode(tt.info), "preferred exit node")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceSingleExitNode(t *testing.T) {
|
||||
m := newExitNodeTestManager()
|
||||
all := []route.NetID{"a", "b", "c"}
|
||||
|
||||
m.enforceSingleExitNode("b", all)
|
||||
assert.False(t, m.routeSelector.IsSelected("a"), "a should be deselected")
|
||||
assert.True(t, m.routeSelector.IsSelected("b"), "b should be the only selected exit node")
|
||||
assert.False(t, m.routeSelector.IsSelected("c"), "c should be deselected")
|
||||
|
||||
// Switching the preferred node moves the single selection.
|
||||
m.enforceSingleExitNode("c", all)
|
||||
assert.False(t, m.routeSelector.IsSelected("a"), "a stays deselected")
|
||||
assert.False(t, m.routeSelector.IsSelected("b"), "b should now be deselected")
|
||||
assert.True(t, m.routeSelector.IsSelected("c"), "c should now be selected")
|
||||
|
||||
// Empty preferred turns every exit node off.
|
||||
m.enforceSingleExitNode("", all)
|
||||
for _, id := range all {
|
||||
assert.False(t, m.routeSelector.IsSelected(id), "no exit node should be selected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceSingleExitNode_RespectsDeselectAll(t *testing.T) {
|
||||
m := newExitNodeTestManager()
|
||||
m.routeSelector.DeselectAllRoutes()
|
||||
|
||||
m.enforceSingleExitNode("b", []route.NetID{"a", "b"})
|
||||
|
||||
assert.True(t, m.routeSelector.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")
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -440,11 +439,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 +579,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 +698,22 @@ 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 {
|
||||
// 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)
|
||||
}
|
||||
|
||||
type exitNodeInfo struct {
|
||||
@@ -733,10 +723,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 +732,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 +768,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))
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
@@ -116,6 +116,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,16 +132,6 @@ 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()
|
||||
|
||||
return rs.deselectAll
|
||||
}
|
||||
|
||||
// FilterSelected removes unselected routes from the provided map.
|
||||
func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
|
||||
rs.mu.RLock()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -74,6 +74,14 @@ func New(filePath string) *Manager {
|
||||
}
|
||||
}
|
||||
|
||||
// FilePath returns the path of the underlying state file.
|
||||
func (m *Manager) FilePath() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m.filePath
|
||||
}
|
||||
|
||||
// Start starts the state manager periodic save routine
|
||||
func (m *Manager) Start() {
|
||||
if m == nil {
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
</File>
|
||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\wintun.dll" />
|
||||
<File Id="NetbirdToastIcon" Name="netbird.png" Source=".\client\ui\assets\netbird.png" />
|
||||
<?if $(var.ArchSuffix) = "amd64" ?>
|
||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\opengl32.dll" />
|
||||
<?endif ?>
|
||||
|
||||
<ServiceInstall
|
||||
Id="NetBirdService"
|
||||
@@ -59,14 +62,6 @@
|
||||
<Component Id="NetbirdAumidRegistry" Guid="*">
|
||||
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
||||
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
||||
<!-- Pre-seed the CLSID the Wails notifications service reads on
|
||||
first startup (notifications_windows.go:getGUID looks for
|
||||
the CustomActivator value under this key). Without this
|
||||
the service generates a fresh per-install UUID, which
|
||||
diverges from the ToastActivatorCLSID set on the Start
|
||||
Menu / Desktop shortcuts above and the COM activator
|
||||
never fires when a toast is clicked. -->
|
||||
<RegistryValue Name="CustomActivator" Type="string" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
</StandardDirectory>
|
||||
@@ -90,40 +85,10 @@
|
||||
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
||||
<util:CloseApplication Id="CloseNetBirdUI" CloseMessage="no" Target="netbird-ui.exe" RebootPrompt="no" TerminateProcess="0" />
|
||||
|
||||
<!-- WebView2 evergreen runtime detection.
|
||||
Probe both the per-machine and per-user EdgeUpdate keys; if either
|
||||
reports a non-empty `pv` value the runtime is already installed
|
||||
and we skip the bootstrapper. -->
|
||||
<Property Id="WEBVIEW2_VERSION_HKLM">
|
||||
<RegistrySearch Id="WV2HKLM" Root="HKLM"
|
||||
Key="SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
||||
Name="pv" Type="raw" Bitness="always64" />
|
||||
</Property>
|
||||
<Property Id="WEBVIEW2_VERSION_HKCU">
|
||||
<RegistrySearch Id="WV2HKCU" Root="HKCU"
|
||||
Key="Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
||||
Name="pv" Type="raw" />
|
||||
</Property>
|
||||
|
||||
<!-- Embed the bootstrapper payload. Path is relative to the WiX
|
||||
working directory; sign-pipelines stages it next to client/
|
||||
via `wails3 generate webview2bootstrapper`. -->
|
||||
<Binary Id="WebView2Bootstrapper" SourceFile=".\client\MicrosoftEdgeWebview2Setup.exe" />
|
||||
|
||||
<CustomAction Id="InstallWebView2"
|
||||
BinaryRef="WebView2Bootstrapper"
|
||||
ExeCommand="/silent /install"
|
||||
Execute="deferred"
|
||||
Impersonate="no"
|
||||
Return="check" />
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="InstallWebView2" Before="InstallFinalize"
|
||||
Condition="NOT WEBVIEW2_VERSION_HKLM AND NOT WEBVIEW2_VERSION_HKCU AND NOT REMOVE" />
|
||||
</InstallExecuteSequence>
|
||||
|
||||
<!-- Icons -->
|
||||
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\build\windows\icon.ico" />
|
||||
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\assets\netbird.ico" />
|
||||
<Property Id="ARPPRODUCTICON" Value="NetbirdIcon" />
|
||||
|
||||
</Package>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,12 +24,6 @@ service DaemonService {
|
||||
// Status of the service.
|
||||
rpc Status(StatusRequest) returns (StatusResponse) {}
|
||||
|
||||
// SubscribeStatus pushes a fresh StatusResponse on connection state
|
||||
// changes (Connected / Disconnected / Connecting / address change /
|
||||
// peers list change). The first message on the stream is the current
|
||||
// snapshot, so a freshly-subscribed UI doesn't need to also call Status.
|
||||
rpc SubscribeStatus(StatusRequest) returns (stream StatusResponse) {}
|
||||
|
||||
// Down stops engine work in the daemon.
|
||||
rpc Down(DownRequest) returns (DownResponse) {}
|
||||
|
||||
@@ -115,25 +109,6 @@ service DaemonService {
|
||||
// WaitJWTToken waits for JWT authentication completion
|
||||
rpc WaitJWTToken(WaitJWTTokenRequest) returns (WaitJWTTokenResponse) {}
|
||||
|
||||
// RequestExtendAuthSession initiates an SSO session-extension flow.
|
||||
// The daemon prepares a PKCE/device-code request against the IdP and
|
||||
// returns the verification URI; the UI is expected to open it. The flow
|
||||
// state is kept in the daemon until WaitExtendAuthSession completes it.
|
||||
rpc RequestExtendAuthSession(RequestExtendAuthSessionRequest) returns (RequestExtendAuthSessionResponse) {}
|
||||
|
||||
// WaitExtendAuthSession blocks until the user finishes the SSO step
|
||||
// started by RequestExtendAuthSession, then forwards the resulting JWT
|
||||
// to the management server's ExtendAuthSession RPC. Returns the new
|
||||
// session expiry deadline. The tunnel stays up the entire time.
|
||||
rpc WaitExtendAuthSession(WaitExtendAuthSessionRequest) returns (WaitExtendAuthSessionResponse) {}
|
||||
|
||||
// DismissSessionWarning records that the user clicked "Dismiss" on the
|
||||
// T-WarningLead interactive notification, suppressing the auto-opened
|
||||
// SessionAboutToExpire dialog that would otherwise fire at
|
||||
// T-FinalWarningLead for the current deadline. Idempotent and best-effort:
|
||||
// a missed call only means the fallback dialog will still appear.
|
||||
rpc DismissSessionWarning(DismissSessionWarningRequest) returns (DismissSessionWarningResponse) {}
|
||||
|
||||
// StartCPUProfile starts CPU profiling in the daemon
|
||||
rpc StartCPUProfile(StartCPUProfileRequest) returns (StartCPUProfileResponse) {}
|
||||
|
||||
@@ -144,6 +119,14 @@ service DaemonService {
|
||||
|
||||
// ExposeService exposes a local port via the NetBird reverse proxy
|
||||
rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {}
|
||||
|
||||
// RespondApproval delivers the user's accept/deny decision for a
|
||||
// pending user-approval prompt. The daemon pushes the prompt as a
|
||||
// SystemEvent with category APPROVAL and metadata key "request_id";
|
||||
// the UI calls this RPC with the same request_id to unblock whichever
|
||||
// subsystem (VNC, SSH, ...) is waiting. The "kind" metadata key tells
|
||||
// the UI which subsystem the prompt belongs to.
|
||||
rpc RespondApproval(RespondApprovalRequest) returns (RespondApprovalResponse) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +213,10 @@ message LoginRequest {
|
||||
optional bool disableSSHAuth = 38;
|
||||
optional int32 sshJWTCacheTTL = 39;
|
||||
optional bool disable_ipv6 = 40;
|
||||
|
||||
optional bool serverVNCAllowed = 41;
|
||||
|
||||
optional bool disableVNCApproval = 42;
|
||||
}
|
||||
|
||||
message LoginResponse {
|
||||
@@ -252,12 +239,6 @@ message UpRequest {
|
||||
optional string profileName = 1;
|
||||
optional string username = 2;
|
||||
reserved 3;
|
||||
// async instructs the daemon to start the connection attempt and return
|
||||
// immediately without waiting for the engine to become ready. Status updates
|
||||
// are delivered via the SubscribeStatus stream. When false (the default) the
|
||||
// RPC blocks until the engine is running or gives up, which is the behaviour
|
||||
// needed by the CLI.
|
||||
bool async = 4;
|
||||
}
|
||||
|
||||
message UpResponse {}
|
||||
@@ -275,10 +256,6 @@ message StatusResponse{
|
||||
FullStatus fullStatus = 2;
|
||||
// NetBird daemon version
|
||||
string daemonVersion = 3;
|
||||
// Absolute UTC instant at which the peer's SSO session expires.
|
||||
// Unset when the peer is not SSO-registered or login expiration is disabled.
|
||||
// The UI derives "warning active" from this value and its own clock.
|
||||
google.protobuf.Timestamp sessionExpiresAt = 4;
|
||||
}
|
||||
|
||||
message DownRequest {}
|
||||
@@ -349,6 +326,10 @@ message GetConfigResponse {
|
||||
int32 sshJWTCacheTTL = 26;
|
||||
|
||||
bool disable_ipv6 = 27;
|
||||
|
||||
bool serverVNCAllowed = 28;
|
||||
|
||||
bool disableVNCApproval = 29;
|
||||
}
|
||||
|
||||
// PeerState contains the latest state of a peer
|
||||
@@ -430,6 +411,25 @@ message SSHServerState {
|
||||
repeated SSHSessionInfo sessions = 2;
|
||||
}
|
||||
|
||||
// VNCSessionInfo contains information about an active VNC session
|
||||
message VNCSessionInfo {
|
||||
string remoteAddress = 1;
|
||||
string mode = 2;
|
||||
string username = 3;
|
||||
// userID is the Noise-verified session identity (hashed user ID from
|
||||
// the ACL session-key entry), empty when auth is disabled.
|
||||
string userID = 4;
|
||||
// initiator is the human-readable display name of the dashboard user
|
||||
// who minted the SessionPubKey, when known.
|
||||
string initiator = 5;
|
||||
}
|
||||
|
||||
// VNCServerState contains the latest state of the VNC server
|
||||
message VNCServerState {
|
||||
bool enabled = 1;
|
||||
repeated VNCSessionInfo sessions = 2;
|
||||
}
|
||||
|
||||
// FullStatus contains the full state held by the Status instance
|
||||
message FullStatus {
|
||||
ManagementState managementState = 1;
|
||||
@@ -444,12 +444,7 @@ message FullStatus {
|
||||
|
||||
bool lazyConnectionEnabled = 9;
|
||||
SSHServerState sshServerState = 10;
|
||||
|
||||
// networksRevision bumps whenever the set of routed networks (route and
|
||||
// exit-node candidates) or their selected state changes. The UI fingerprints
|
||||
// on it to know when to re-fetch ListNetworks via the push stream, instead
|
||||
// of polling on every status snapshot.
|
||||
uint64 networksRevision = 11;
|
||||
VNCServerState vncServerState = 11;
|
||||
}
|
||||
|
||||
// Networks
|
||||
@@ -638,6 +633,7 @@ message SystemEvent {
|
||||
AUTHENTICATION = 2;
|
||||
CONNECTIVITY = 3;
|
||||
SYSTEM = 4;
|
||||
APPROVAL = 5;
|
||||
}
|
||||
|
||||
string id = 1;
|
||||
@@ -721,6 +717,10 @@ message SetConfigRequest {
|
||||
optional bool disableSSHAuth = 33;
|
||||
optional int32 sshJWTCacheTTL = 34;
|
||||
optional bool disable_ipv6 = 35;
|
||||
|
||||
optional bool serverVNCAllowed = 36;
|
||||
|
||||
optional bool disableVNCApproval = 37;
|
||||
}
|
||||
|
||||
message SetConfigResponse{}
|
||||
@@ -841,55 +841,6 @@ message WaitJWTTokenResponse {
|
||||
int64 expiresIn = 3;
|
||||
}
|
||||
|
||||
// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow.
|
||||
message RequestExtendAuthSessionRequest {
|
||||
// Optional OIDC login_hint (typically the user's email) to pre-fill the
|
||||
// IdP login form.
|
||||
optional string hint = 1;
|
||||
}
|
||||
|
||||
// RequestExtendAuthSessionResponse carries the verification URI the UI
|
||||
// should open in a browser. The daemon retains the flow state and resolves
|
||||
// it via WaitExtendAuthSession.
|
||||
message RequestExtendAuthSessionResponse {
|
||||
// verification URI for the user to open in the browser
|
||||
string verificationURI = 1;
|
||||
// complete verification URI (with embedded user code)
|
||||
string verificationURIComplete = 2;
|
||||
// user code to enter on verification URI (for device-code flows)
|
||||
string userCode = 3;
|
||||
// device code for matching the WaitExtendAuthSession call to this flow
|
||||
string deviceCode = 4;
|
||||
// expiration time in seconds for the device code / PKCE flow
|
||||
int64 expiresIn = 5;
|
||||
}
|
||||
|
||||
// WaitExtendAuthSessionRequest is sent by the UI after it opens the
|
||||
// verification URI. The daemon blocks on this call until the user
|
||||
// completes (or aborts) the SSO step.
|
||||
message WaitExtendAuthSessionRequest {
|
||||
// device code returned by RequestExtendAuthSession
|
||||
string deviceCode = 1;
|
||||
// user code for verification
|
||||
string userCode = 2;
|
||||
}
|
||||
|
||||
// WaitExtendAuthSessionResponse carries the refreshed deadline returned
|
||||
// by the management server. Unset when the management server reports the
|
||||
// peer is not eligible for session extension.
|
||||
message WaitExtendAuthSessionResponse {
|
||||
google.protobuf.Timestamp sessionExpiresAt = 1;
|
||||
}
|
||||
|
||||
// DismissSessionWarningRequest is sent by the UI when the user clicks
|
||||
// "Dismiss" on the T-WarningLead notification.
|
||||
message DismissSessionWarningRequest {}
|
||||
|
||||
// DismissSessionWarningResponse acknowledges the dismissal. Carries no
|
||||
// payload — the daemon's only obligation is to silence the upcoming
|
||||
// T-FinalWarningLead fallback for the current deadline.
|
||||
message DismissSessionWarningResponse {}
|
||||
|
||||
// StartCPUProfileRequest for starting CPU profiling
|
||||
message StartCPUProfileRequest {}
|
||||
|
||||
@@ -964,3 +915,18 @@ message StartBundleCaptureRequest {
|
||||
message StartBundleCaptureResponse {}
|
||||
message StopBundleCaptureRequest {}
|
||||
message StopBundleCaptureResponse {}
|
||||
|
||||
message RespondApprovalRequest {
|
||||
// request_id matches the SystemEvent metadata key emitted by the daemon
|
||||
// when a subsystem awaits user approval for an inbound connection.
|
||||
string request_id = 1;
|
||||
// accept is true if the user approved the request, false if they
|
||||
// denied it. A missing or unknown request_id is treated as a no-op.
|
||||
bool accept = 2;
|
||||
// view_only signals that the user granted the connection but withheld
|
||||
// input control. Only meaningful when accept is true; ignored when
|
||||
// accept is false.
|
||||
bool view_only = 3;
|
||||
}
|
||||
|
||||
message RespondApprovalResponse {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,7 +111,7 @@ func (s *Server) StartCapture(req *proto.StartCaptureRequest, stream proto.Daemo
|
||||
return status.Errorf(codes.Internal, "create capture session: %v", err)
|
||||
}
|
||||
|
||||
engine, err := s.claimCapture(sess)
|
||||
engine, err := s.claimCapture(sess, func() { pw.Close() })
|
||||
if err != nil {
|
||||
sess.Stop()
|
||||
pw.Close()
|
||||
@@ -190,10 +190,7 @@ func (s *Server) StartBundleCapture(_ context.Context, req *proto.StartBundleCap
|
||||
|
||||
s.stopBundleCaptureLocked()
|
||||
s.cleanupBundleCapture()
|
||||
|
||||
if s.activeCapture != nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
|
||||
}
|
||||
s.evictActiveCaptureLocked()
|
||||
|
||||
engine, err := s.getCaptureEngineLocked()
|
||||
if err != nil {
|
||||
@@ -304,29 +301,58 @@ func (s *Server) cleanupBundleCapture() {
|
||||
s.bundleCapture = nil
|
||||
}
|
||||
|
||||
// claimCapture reserves the engine's capture slot for sess. Returns
|
||||
// FailedPrecondition if another capture is already active.
|
||||
func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) {
|
||||
// claimCapture reserves the engine's capture slot for sess. If another
|
||||
// capture is already running it is evicted: a previous streaming session
|
||||
// whose gRPC client died and never freed the slot stays stuck otherwise,
|
||||
// and a bundle capture is just informational state.
|
||||
func (s *Server) claimCapture(sess *capture.Session, cancel func()) (*internal.Engine, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.activeCapture != nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
|
||||
}
|
||||
s.evictActiveCaptureLocked()
|
||||
engine, err := s.getCaptureEngineLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.activeCapture = sess
|
||||
s.activeCaptureCancel = cancel
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// evictActiveCaptureLocked tears down whatever capture currently owns
|
||||
// the engine slot so a fresh claim can succeed. Caller must hold mutex.
|
||||
func (s *Server) evictActiveCaptureLocked() {
|
||||
if s.activeCapture == nil {
|
||||
return
|
||||
}
|
||||
if s.bundleCapture != nil && s.bundleCapture.sess == s.activeCapture {
|
||||
log.Infof("evicting running bundle capture to start a new capture")
|
||||
s.stopBundleCaptureLocked()
|
||||
return
|
||||
}
|
||||
log.Infof("evicting previous streaming capture to start a new one")
|
||||
prev := s.activeCapture
|
||||
cancel := s.activeCaptureCancel
|
||||
if engine, err := s.getCaptureEngineLocked(); err == nil {
|
||||
if err := engine.SetCapture(nil); err != nil {
|
||||
log.Debugf("clear previous capture: %v", err)
|
||||
}
|
||||
}
|
||||
s.activeCapture = nil
|
||||
s.activeCaptureCancel = nil
|
||||
prev.Stop()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// releaseCapture clears the active-capture owner if it still matches sess.
|
||||
func (s *Server) releaseCapture(sess *capture.Session) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
if s.activeCapture == sess {
|
||||
s.activeCapture = nil
|
||||
s.activeCaptureCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +367,7 @@ func (s *Server) clearCaptureIfOwner(sess *capture.Session, engine *internal.Eng
|
||||
log.Debugf("clear capture: %v", err)
|
||||
}
|
||||
s.activeCapture = nil
|
||||
s.activeCaptureCancel = nil
|
||||
}
|
||||
|
||||
func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) {
|
||||
|
||||
@@ -53,10 +53,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
||||
if engine != nil {
|
||||
refreshStatus = func() {
|
||||
log.Debug("refreshing system health status for debug bundle")
|
||||
// Background ctx: the bundle wants a full, fresh probe regardless
|
||||
// of the DebugBundle RPC client's lifetime. The engine's own ctx
|
||||
// still aborts it on shutdown.
|
||||
engine.RunHealthProbes(context.Background(), true)
|
||||
engine.RunHealthProbes(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,17 +172,6 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
|
||||
if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
|
||||
return nil, fmt.Errorf("select routes: %w", err)
|
||||
}
|
||||
|
||||
// Exit nodes are mutually exclusive: if this selection activates an
|
||||
// exit node, deselect every other available exit node so two can't be
|
||||
// selected at once. Non-exit route selections are left untouched.
|
||||
if requestActivatesExitNode(routes, routesMap) {
|
||||
if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 {
|
||||
if err := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil {
|
||||
return nil, fmt.Errorf("deselect sibling exit nodes: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
routeManager.TriggerSelection(routeManager.GetClientRoutes())
|
||||
|
||||
@@ -260,38 +249,3 @@ func toNetIDs(routes []string) []route.NetID {
|
||||
}
|
||||
return netIDs
|
||||
}
|
||||
|
||||
func isExitNodeRoutes(routes []*route.Route) bool {
|
||||
return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network))
|
||||
}
|
||||
|
||||
// requestActivatesExitNode reports whether any requested NetID maps to an exit
|
||||
// node (default route) in the current route table.
|
||||
func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool {
|
||||
for _, id := range requested {
|
||||
if isExitNodeRoutes(routesMap[id]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// otherExitNodeIDs returns every available exit-node NetID that is not in the
|
||||
// requested set — the siblings to deselect so a single exit node stays active.
|
||||
func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID {
|
||||
keep := make(map[route.NetID]struct{}, len(requested))
|
||||
for _, id := range requested {
|
||||
keep[id] = struct{}{}
|
||||
}
|
||||
var others []route.NetID
|
||||
for id, routes := range routesMap {
|
||||
if !isExitNodeRoutes(routes) {
|
||||
continue
|
||||
}
|
||||
if _, ok := keep[id]; ok {
|
||||
continue
|
||||
}
|
||||
others = append(others, id)
|
||||
}
|
||||
return others
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func TestExitNodeSelectionHelpers(t *testing.T) {
|
||||
routesMap := map[route.NetID][]*route.Route{
|
||||
"exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}},
|
||||
"exitB": {{Network: netip.MustParsePrefix("::/0")}},
|
||||
"lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}},
|
||||
}
|
||||
|
||||
assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node")
|
||||
assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node")
|
||||
assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node")
|
||||
assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node")
|
||||
|
||||
others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"})
|
||||
assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored")
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// healthProbeRunner runs the full, expensive probe (network round-trips to
|
||||
// management, signal and the relays) and reports whether every component was
|
||||
// healthy. ctx cancels the probe when the caller gives up. Satisfied by
|
||||
// *internal.Engine.
|
||||
type healthProbeRunner interface {
|
||||
RunHealthProbes(ctx context.Context, waitForResult bool) bool
|
||||
}
|
||||
|
||||
// statsRefresher does the cheap WireGuard-stats refresh callers fall back to
|
||||
// when a fresh probe isn't warranted. Satisfied by *peer.Status.
|
||||
type statsRefresher interface {
|
||||
RefreshWireGuardStats() error
|
||||
}
|
||||
|
||||
// probeThrottle rate-limits and single-flights the daemon's health probes.
|
||||
//
|
||||
// Health probes are expensive (network round-trips to management, signal and
|
||||
// the relays), while Status(GetFullPeerStatus=true) RPCs can arrive frequently
|
||||
// and concurrently — the desktop UI alone issues one per connect/disconnect.
|
||||
// probeThrottle keeps that load bounded with two rules:
|
||||
//
|
||||
// - Single-flight: only one probe runs at a time. Callers that pile up while
|
||||
// a probe is in flight share its result instead of each launching another,
|
||||
// even when that probe failed. A failed probe therefore does not make every
|
||||
// waiter re-probe in turn; the next, non-overlapping caller can try again.
|
||||
// - Throttle: after a fully successful probe the result is cached for
|
||||
// interval. While any component is unhealthy the cache is not advanced, so
|
||||
// later callers keep probing frequently and notice recovery quickly — the
|
||||
// intentional "probe often while unhealthy" behaviour from the original
|
||||
// design.
|
||||
type probeThrottle struct {
|
||||
interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
lastOK time.Time // last fully-successful probe; drives the throttle window
|
||||
completedAt time.Time // when the most recent probe finished; drives single-flight sharing
|
||||
}
|
||||
|
||||
func newProbeThrottle(interval time.Duration) *probeThrottle {
|
||||
return &probeThrottle{interval: interval}
|
||||
}
|
||||
|
||||
// Run decides whether to run a fresh health probe or serve the most recent
|
||||
// result. It serialises concurrent callers: at most one runner.RunHealthProbes
|
||||
// executes at a time and the rest call refresher.RefreshWireGuardStats and read
|
||||
// the snapshot it produced.
|
||||
//
|
||||
// Both calls run while the throttle's lock is held, so a slow probe blocks
|
||||
// other callers until it completes — that blocking is the single-flight
|
||||
// guarantee. ctx is forwarded to RunHealthProbes so a caller that gives up
|
||||
// cancels the in-flight probe (and any caller still queued on the lock falls
|
||||
// through quickly once it acquires it, since the probe ctx is already done).
|
||||
func (t *probeThrottle) Run(ctx context.Context, runner healthProbeRunner, refresher statsRefresher, waitForResult bool) {
|
||||
entered := time.Now()
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
// A probe that finished after we entered ran while we were waiting on the
|
||||
// lock — i.e. a peer in the same burst already probed for us, so share its
|
||||
// result rather than launch another. This holds even when that probe
|
||||
// failed, so a failed probe doesn't make every waiter re-probe in turn.
|
||||
sharedRecentProbe := t.completedAt.After(entered)
|
||||
throttled := time.Since(t.lastOK) <= t.interval
|
||||
|
||||
if sharedRecentProbe || throttled {
|
||||
if err := refresher.RefreshWireGuardStats(); err != nil {
|
||||
log.Debugf("failed to refresh WireGuard stats: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
healthy := runner.RunHealthProbes(ctx, waitForResult)
|
||||
t.completedAt = time.Now()
|
||||
if healthy {
|
||||
t.lastOK = t.completedAt
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeProber implements both healthProbeRunner and statsRefresher with
|
||||
// caller-supplied behaviour.
|
||||
type fakeProber struct {
|
||||
onProbe func() bool
|
||||
onRefresh func()
|
||||
}
|
||||
|
||||
func (f fakeProber) RunHealthProbes(context.Context, bool) bool {
|
||||
return f.onProbe()
|
||||
}
|
||||
|
||||
func (f fakeProber) RefreshWireGuardStats() error {
|
||||
if f.onRefresh != nil {
|
||||
f.onRefresh()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProbeThrottle_CachesAfterSuccess(t *testing.T) {
|
||||
pt := newProbeThrottle(time.Minute)
|
||||
|
||||
var probes, refreshes int
|
||||
prober := fakeProber{
|
||||
onProbe: func() bool { probes++; return true },
|
||||
onRefresh: func() { refreshes++ },
|
||||
}
|
||||
|
||||
pt.Run(context.Background(), prober, prober, false)
|
||||
pt.Run(context.Background(), prober, prober, false)
|
||||
|
||||
if probes != 1 {
|
||||
t.Fatalf("expected 1 probe within the throttle window, got %d", probes)
|
||||
}
|
||||
if refreshes != 1 {
|
||||
t.Fatalf("expected the throttled caller to refresh stats once, got %d", refreshes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeThrottle_StaysOpenWhileUnhealthy(t *testing.T) {
|
||||
pt := newProbeThrottle(time.Minute)
|
||||
|
||||
var probes int
|
||||
prober := fakeProber{onProbe: func() bool { probes++; return false }} // never healthy
|
||||
|
||||
// Sequential, non-overlapping callers must each re-probe while unhealthy:
|
||||
// a failed probe does not advance the throttle window.
|
||||
pt.Run(context.Background(), prober, prober, false)
|
||||
pt.Run(context.Background(), prober, prober, false)
|
||||
pt.Run(context.Background(), prober, prober, false)
|
||||
|
||||
if probes != 3 {
|
||||
t.Fatalf("expected every non-overlapping caller to probe while unhealthy, got %d", probes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeThrottle_SingleFlightSharesResult(t *testing.T) {
|
||||
pt := newProbeThrottle(time.Minute)
|
||||
|
||||
var probes int32
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{})
|
||||
|
||||
// First caller blocks inside the probe until released, holding the lock so
|
||||
// the others pile up behind it.
|
||||
prober := fakeProber{onProbe: func() bool {
|
||||
if atomic.AddInt32(&probes, 1) == 1 {
|
||||
close(started)
|
||||
<-release
|
||||
}
|
||||
return false // unhealthy — the share must happen regardless of result
|
||||
}}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pt.Run(context.Background(), prober, prober, false)
|
||||
}()
|
||||
|
||||
<-started // ensure the first probe is in flight before the burst arrives
|
||||
|
||||
const waiters = 9
|
||||
wg.Add(waiters)
|
||||
for i := 0; i < waiters; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pt.Run(context.Background(), prober, prober, false)
|
||||
}()
|
||||
}
|
||||
|
||||
// Give the waiters time to block on the lock, then let the first finish.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&probes); got != 1 {
|
||||
t.Fatalf("expected a concurrent burst to run exactly 1 probe, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
@@ -68,12 +67,6 @@ type Server struct {
|
||||
logFile string
|
||||
|
||||
oauthAuthFlow oauthAuthFlow
|
||||
// extendAuthSessionFlow holds the pending PKCE flow created by
|
||||
// RequestExtendAuthSession until WaitExtendAuthSession resolves it.
|
||||
// Kept separate from oauthAuthFlow (which is reserved for the SSH
|
||||
// JWT path) so a concurrent SSH auth doesn't clobber the session
|
||||
// extend flow or vice versa.
|
||||
extendAuthSessionFlow *auth.PendingFlow
|
||||
|
||||
mutex sync.Mutex
|
||||
config *profilemanager.Config
|
||||
@@ -87,7 +80,7 @@ type Server struct {
|
||||
statusRecorder *peer.Status
|
||||
sessionWatcher *internal.SessionWatcher
|
||||
|
||||
probeThrottle *probeThrottle
|
||||
lastProbe time.Time
|
||||
persistSyncResponse bool
|
||||
isSessionActive atomic.Bool
|
||||
|
||||
@@ -100,8 +93,12 @@ type Server struct {
|
||||
captureEnabled bool
|
||||
bundleCapture *bundleCapture
|
||||
// activeCapture is the session currently installed on the engine; guarded by s.mutex.
|
||||
activeCapture *capture.Session
|
||||
networksDisabled bool
|
||||
activeCapture *capture.Session
|
||||
// activeCaptureCancel tears down the streaming pipe/cancel for the
|
||||
// active streaming capture so eviction unblocks the StartCapture RPC
|
||||
// handler. Nil for bundle captures (they own their own context).
|
||||
activeCaptureCancel func()
|
||||
networksDisabled bool
|
||||
|
||||
sleepHandler *sleephandler.SleepHandler
|
||||
|
||||
@@ -130,8 +127,6 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
||||
captureEnabled: captureEnabled,
|
||||
networksDisabled: networksDisabled,
|
||||
jwtCache: newJWTCache(),
|
||||
extendAuthSessionFlow: auth.NewPendingFlow(),
|
||||
probeThrottle: newProbeThrottle(probeThreshold),
|
||||
}
|
||||
agent := &serverAgent{s}
|
||||
s.sleepHandler = sleephandler.New(agent)
|
||||
@@ -149,15 +144,6 @@ func (s *Server) Start() error {
|
||||
}
|
||||
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
// Every contextState.Set in the connect/login/server paths must push a
|
||||
// SubscribeStatus snapshot, otherwise transitions that don't happen to
|
||||
// be accompanied by a Mark{Management,Signal,...} call (e.g. plain
|
||||
// StatusNeedsLogin after a PermissionDenied login, StatusLoginFailed
|
||||
// after OAuth init failure, StatusIdle in the Login defer) leave the
|
||||
// UI stuck on the previous status until the next unrelated peer event.
|
||||
// Binding the recorder here means new state.Set callsites don't have
|
||||
// to opt in individually.
|
||||
state.SetOnChange(s.statusRecorder.NotifyStateChange)
|
||||
|
||||
if err := handlePanicLog(); err != nil {
|
||||
log.Warnf("failed to redirect stderr: %v", err)
|
||||
@@ -238,20 +224,10 @@ func (s *Server) Start() error {
|
||||
// mechanism to keep the client connected even when the connection is lost.
|
||||
// we cancel retry if the client receive a stop or down command, or if disable auto connect is configured.
|
||||
func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) {
|
||||
// close(giveUpChan) MUST run on every exit path (DisableAutoConnect
|
||||
// return, backoff.Retry return, panic) — Down() blocks for up to 5s
|
||||
// waiting on this signal before flipping the state to Idle, and a
|
||||
// missed close leaves Down() always hitting the timeout. The signal
|
||||
// fires AFTER clientRunning=false is committed under the mutex so a
|
||||
// Down/Up racing with the goroutine exit never observes a half-state
|
||||
// (chan closed but clientRunning still true).
|
||||
defer func() {
|
||||
s.mutex.Lock()
|
||||
s.clientRunning = false
|
||||
s.mutex.Unlock()
|
||||
if giveUpChan != nil {
|
||||
close(giveUpChan)
|
||||
}
|
||||
}()
|
||||
|
||||
if s.config.DisableAutoConnect {
|
||||
@@ -286,15 +262,6 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
|
||||
runOperation := func() error {
|
||||
err := s.connect(ctx, profileConfig, statusRecorder, runningChan)
|
||||
if err != nil {
|
||||
// PermissionDenied means the daemon transitioned to NeedsLogin
|
||||
// inside connect(). Without backoff.Permanent the outer retry
|
||||
// re-enters connect(), which resets the state to Connecting and
|
||||
// makes the tray flicker between NeedsLogin and Connecting until
|
||||
// the user logs in. Stop retrying and let the state stick.
|
||||
if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied {
|
||||
log.Debugf("run client connection exited with PermissionDenied, waiting for login")
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
log.Debugf("run client connection exited with error: %v. Will retry in the background", err)
|
||||
return err
|
||||
}
|
||||
@@ -306,6 +273,10 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
|
||||
if err := backoff.Retry(runOperation, backOff); err != nil {
|
||||
log.Errorf("operation failed: %v", err)
|
||||
}
|
||||
|
||||
if giveUpChan != nil {
|
||||
close(giveUpChan)
|
||||
}
|
||||
}
|
||||
|
||||
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
|
||||
@@ -374,7 +345,9 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
}
|
||||
|
||||
if msg.OptionalPreSharedKey != nil {
|
||||
config.PreSharedKey = msg.OptionalPreSharedKey
|
||||
if *msg.OptionalPreSharedKey != "" {
|
||||
config.PreSharedKey = msg.OptionalPreSharedKey
|
||||
}
|
||||
}
|
||||
|
||||
if msg.CleanDNSLabels {
|
||||
@@ -407,6 +380,8 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
config.RosenpassPermissive = msg.RosenpassPermissive
|
||||
config.DisableAutoConnect = msg.DisableAutoConnect
|
||||
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
||||
config.ServerVNCAllowed = msg.ServerVNCAllowed
|
||||
config.DisableVNCApproval = msg.DisableVNCApproval
|
||||
config.NetworkMonitor = msg.NetworkMonitor
|
||||
config.DisableClientRoutes = msg.DisableClientRoutes
|
||||
config.DisableServerRoutes = msg.DisableServerRoutes
|
||||
@@ -540,6 +515,8 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
return &proto.LoginResponse{}, nil
|
||||
}
|
||||
|
||||
state.Set(internal.StatusConnecting)
|
||||
|
||||
if msg.SetupKey == "" {
|
||||
hint := ""
|
||||
if msg.Hint != nil {
|
||||
@@ -554,7 +531,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) {
|
||||
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
|
||||
log.Debugf("using previous oauth flow info")
|
||||
state.Set(internal.StatusNeedsLogin)
|
||||
return &proto.LoginResponse{
|
||||
NeedsSSOLogin: true,
|
||||
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
|
||||
@@ -591,11 +567,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Setup-key path: we are about to dial Management with the key, so the
|
||||
// Connecting paint is meaningful here — unlike the SSO branch above,
|
||||
// which returns NeedsLogin and parks on the browser leg.
|
||||
state.Set(internal.StatusConnecting)
|
||||
|
||||
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
|
||||
state.Set(loginStatus)
|
||||
return nil, err
|
||||
@@ -604,43 +575,8 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
return &proto.LoginResponse{}, nil
|
||||
}
|
||||
|
||||
// WaitSSOLogin validates the supplied userCode against the in-flight OAuth
|
||||
// device/PKCE flow and blocks until the user finishes the browser leg.
|
||||
//
|
||||
// The daemon holds StatusNeedsLogin for the whole browser wait (set on
|
||||
// entry): the login is not done until the token returns, so a client that
|
||||
// (re)attaches mid-wait — a restarted UI, a second `netbird up` — reads
|
||||
// "login required" and offers the affordance, instead of a Connecting that
|
||||
// never resolves. The wait is also tied to the caller's context (see the
|
||||
// goroutine below), so a client that goes away cancels the wait instead of
|
||||
// orphaning it on rootCtx until the device-code window expires.
|
||||
//
|
||||
// State transitions on exit:
|
||||
//
|
||||
// ┌──────────────────────────────────────────┬──────────────────────────────────┐
|
||||
// │ Outcome │ contextState │
|
||||
// ├──────────────────────────────────────────┼──────────────────────────────────┤
|
||||
// │ Success → loginAttempt ok │ NeedsLogin held; the caller's Up │
|
||||
// │ │ drives Connecting → Connected │
|
||||
// │ Success → loginAttempt → still-NeedsLogin│ StatusNeedsLogin (loginAttempt) │
|
||||
// │ Success → loginAttempt error │ StatusLoginFailed (loginAttempt) │
|
||||
// │ UserCode mismatch │ StatusLoginFailed │
|
||||
// │ WaitToken: context.Canceled │ NeedsLogin held. Caller gone │
|
||||
// │ (caller went away — UI restart / │ (UI/CLI) → a fresh client │
|
||||
// │ Ctrl+C — or internal abort: profile │ shows the login affordance; │
|
||||
// │ switch / app quit / another │ internal aborts are │
|
||||
// │ WaitSSOLogin via actCancel/waitCancel) │ overwritten by the next Up. │
|
||||
// │ WaitToken: context.DeadlineExceeded │ StatusNeedsLogin │
|
||||
// │ (OAuth device-code window expired │ (retryable; the UI's "Connect" │
|
||||
// │ while waiting on the browser leg) │ re-enters the Login flow) │
|
||||
// │ WaitToken: any other error │ StatusLoginFailed │
|
||||
// │ (access_denied, expired_token, HTTP │ (genuine auth/IO failure; │
|
||||
// │ failure, token validation rejection) │ surfaced verbatim to caller) │
|
||||
// └──────────────────────────────────────────┴──────────────────────────────────┘
|
||||
//
|
||||
// The defer still applies a StatusIdle fallback for the early
|
||||
// oauth-flow-not-initialized return (before the entry Set), so a half state
|
||||
// doesn't leak when there is nothing to wait on.
|
||||
// WaitSSOLogin uses the userCode to validate the TokenInfo and
|
||||
// waits for the user to continue with the login on a browser
|
||||
func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLoginRequest) (*proto.WaitSSOLoginResponse, error) {
|
||||
s.mutex.Lock()
|
||||
if s.actCancel != nil {
|
||||
@@ -648,21 +584,6 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
||||
}
|
||||
ctx, cancel := context.WithCancel(s.rootCtx)
|
||||
|
||||
// Tie the in-flight browser wait to the caller. ctx stays rooted in
|
||||
// rootCtx so CtxGetState resolves the daemon's contextState, but if the
|
||||
// UI window or CLI that drove the login goes away mid-flow (restart,
|
||||
// Ctrl+C) the gRPC callerCtx cancels and we cancel the wait instead of
|
||||
// orphaning it on rootCtx until the OAuth device-code window expires.
|
||||
// The goroutine exits as soon as either context completes, so it can't
|
||||
// outlive the RPC.
|
||||
go func() {
|
||||
select {
|
||||
case <-callerCtx.Done():
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
md, ok := metadata.FromIncomingContext(callerCtx)
|
||||
if ok {
|
||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||
@@ -688,11 +609,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
||||
}
|
||||
}()
|
||||
|
||||
// Hold NeedsLogin for the whole browser wait — the login is not done
|
||||
// until the token returns, so a client that (re)attaches mid-wait
|
||||
// (restarted UI, second `netbird up`) reads "login required" and offers
|
||||
// the affordance instead of a Connecting that never resolves.
|
||||
state.Set(internal.StatusNeedsLogin)
|
||||
state.Set(internal.StatusConnecting)
|
||||
|
||||
s.mutex.Lock()
|
||||
flowInfo := s.oauthAuthFlow.info
|
||||
@@ -719,30 +636,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
||||
s.mutex.Lock()
|
||||
s.oauthAuthFlow.expiresAt = time.Now()
|
||||
s.mutex.Unlock()
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
// External abort. If our caller cancelled (the client closed
|
||||
// the browser-login popup, or the UI went away — callerCtx is
|
||||
// done), clear the abandoned OAuth flow so a fresh Login starts
|
||||
// a new device code instead of reusing this one. The entry
|
||||
// NeedsLogin stays in place, so a reattaching client shows the
|
||||
// login affordance. An internal abort (actCancel from a new
|
||||
// Login/WaitSSOLogin, callerCtx still live) leaves the flow for
|
||||
// the new owner — don't clobber it.
|
||||
if callerCtx.Err() != nil {
|
||||
s.mutex.Lock()
|
||||
s.oauthAuthFlow = oauthAuthFlow{}
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
// OAuth device-code window expired with no user action.
|
||||
// Retryable — leave the daemon in NeedsLogin so the UI
|
||||
// keeps the Login affordance instead of reading as a
|
||||
// hard failure.
|
||||
state.Set(internal.StatusNeedsLogin)
|
||||
default:
|
||||
state.Set(internal.StatusLoginFailed)
|
||||
}
|
||||
state.Set(internal.StatusLoginFailed)
|
||||
log.Errorf("waiting for browser login failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -793,22 +687,6 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// StatusNeedsLogin is a legitimate fresh-start entry state: a successful
|
||||
// WaitSSOLogin deliberately leaves the daemon in NeedsLogin (the login is
|
||||
// done, the token is in hand, but the engine hasn't been brought up yet —
|
||||
// see WaitSSOLogin's state-transition table). The same holds after a
|
||||
// mid-session expiry tore the engine down (clientRunning == false) and the
|
||||
// user re-authenticated. In both cases the caller's Up is expected to drive
|
||||
// the connection; treat NeedsLogin like Idle and reset to Idle so the
|
||||
// engine's own StatusConnecting → StatusConnected progression starts from a
|
||||
// clean slate. Without this, the first Up after an SSO login fails with
|
||||
// "up already in progress" and the user has to trigger Up a second time
|
||||
// (CLI: re-run `netbird up`; GUI: click Connect again).
|
||||
if status == internal.StatusNeedsLogin {
|
||||
status = internal.StatusIdle
|
||||
state.Set(internal.StatusIdle)
|
||||
}
|
||||
|
||||
if status != internal.StatusIdle {
|
||||
s.mutex.Unlock()
|
||||
return nil, fmt.Errorf("up already in progress: current status %s", status)
|
||||
@@ -873,9 +751,6 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||
|
||||
s.mutex.Unlock()
|
||||
if msg.GetAsync() {
|
||||
return &proto.UpResponse{}, nil
|
||||
}
|
||||
return s.waitForUp(callerCtx)
|
||||
}
|
||||
|
||||
@@ -959,10 +834,6 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
|
||||
|
||||
s.config = config
|
||||
|
||||
if msg != nil && msg.ProfileName != nil {
|
||||
s.publishProfileListChanged(*msg.ProfileName)
|
||||
}
|
||||
|
||||
return &proto.SwitchProfileResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -979,37 +850,23 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
state.Set(internal.StatusIdle)
|
||||
|
||||
s.mutex.Unlock()
|
||||
|
||||
// Wait for the connectWithRetryRuns goroutine to finish with a short timeout.
|
||||
// This prevents the goroutine from setting ErrResetConnection after Down() returns.
|
||||
// The giveUpChan is closed by the goroutine's deferred cleanup (see
|
||||
// connectWithRetryRuns) on every exit path. A timeout here typically
|
||||
// means the goroutine is still wedged inside a slow teardown step.
|
||||
// The giveUpChan is closed at the end of connectWithRetryRuns.
|
||||
if giveUpChan != nil {
|
||||
select {
|
||||
case <-giveUpChan:
|
||||
log.Debugf("client goroutine finished, giveUpChan closed")
|
||||
log.Debugf("client goroutine finished successfully")
|
||||
case <-time.After(5 * time.Second):
|
||||
log.Warnf("timeout waiting for client goroutine to finish, proceeding anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// Set Idle only after the retry goroutine has exited (or timed out).
|
||||
// Setting it earlier races with the goroutine's own Set(StatusConnecting)
|
||||
// at the top of each retry attempt, which would leave the snapshot
|
||||
// stuck at Connecting long after the user asked to disconnect.
|
||||
internal.CtxGetState(s.rootCtx).Set(internal.StatusIdle)
|
||||
|
||||
// Clear stale management/signal errors so the next Up() (typically for a
|
||||
// different profile) starts with a clean status snapshot. Without this,
|
||||
// a managementError left over from a LoginFailed cycle persists in the
|
||||
// statusRecorder and appears in the new profile's initial
|
||||
// SubscribeStatus snapshot, making the new profile look like it also
|
||||
// failed to log in.
|
||||
s.statusRecorder.MarkManagementDisconnected(nil)
|
||||
s.statusRecorder.MarkSignalDisconnected(nil)
|
||||
|
||||
return &proto.DownResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -1214,19 +1071,7 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil
|
||||
}
|
||||
}()
|
||||
|
||||
if err := mgmClient.Logout(); err != nil {
|
||||
// The peer is already gone from the management server (e.g. deleted
|
||||
// from the dashboard). The logout's goal — deregistering this peer —
|
||||
// is therefore already satisfied, so treat NotFound as success rather
|
||||
// than blocking the logout/profile-removal flow.
|
||||
if logoutPeerGone(err) {
|
||||
log.Infof("peer already removed from management server, treating logout as successful")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return mgmClient.Logout()
|
||||
}
|
||||
|
||||
// Status returns the daemon status
|
||||
@@ -1275,24 +1120,9 @@ func (s *Server) Status(
|
||||
}
|
||||
}
|
||||
|
||||
return s.buildStatusResponse(ctx, msg)
|
||||
}
|
||||
|
||||
// buildStatusResponse composes a StatusResponse from the current daemon
|
||||
// state. Shared between the unary Status RPC and the SubscribeStatus
|
||||
// stream so both paths return identical snapshots. ctx scopes the health
|
||||
// probe runProbes may trigger — a caller that disconnects cancels it.
|
||||
func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusRequest) (*proto.StatusResponse, error) {
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
status, err := state.Status()
|
||||
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||
if err != nil {
|
||||
// state.Status() blanks the status when err is set (e.g. management
|
||||
// retry loop wrapped a connection error). The underlying status is
|
||||
// still meaningful and the failure is already surfaced via
|
||||
// FullStatus.ManagementState.Error, so don't propagate err — that
|
||||
// would tear down the SubscribeStatus stream and cause the UI to
|
||||
// mark the daemon as unreachable on every retry.
|
||||
status = state.CurrentStatus()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if status == internal.StatusNeedsLogin && s.isSessionActive.Load() {
|
||||
@@ -1303,20 +1133,16 @@ func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusReque
|
||||
|
||||
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
|
||||
|
||||
if deadline := s.statusRecorder.GetSessionExpiresAt(); !deadline.IsZero() {
|
||||
statusResponse.SessionExpiresAt = timestamppb.New(deadline)
|
||||
}
|
||||
|
||||
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
|
||||
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
|
||||
|
||||
if msg.GetFullPeerStatus {
|
||||
s.runProbes(ctx, msg.ShouldRunProbes)
|
||||
s.runProbes(msg.ShouldRunProbes)
|
||||
fullStatus := s.statusRecorder.GetFullStatus()
|
||||
pbFullStatus := fullStatus.ToProto()
|
||||
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
||||
pbFullStatus.SshServerState = s.getSSHServerState()
|
||||
pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision()
|
||||
pbFullStatus.VncServerState = s.getVNCServerState()
|
||||
statusResponse.FullStatus = pbFullStatus
|
||||
}
|
||||
|
||||
@@ -1356,6 +1182,38 @@ func (s *Server) getSSHServerState() *proto.SSHServerState {
|
||||
return sshServerState
|
||||
}
|
||||
|
||||
// getVNCServerState retrieves the current VNC server state.
|
||||
func (s *Server) getVNCServerState() *proto.VNCServerState {
|
||||
s.mutex.Lock()
|
||||
connectClient := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
|
||||
if connectClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
engine := connectClient.Engine()
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabled, sessions := engine.GetVNCServerStatus()
|
||||
pbSessions := make([]*proto.VNCSessionInfo, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
pbSessions = append(pbSessions, &proto.VNCSessionInfo{
|
||||
RemoteAddress: sess.RemoteAddress,
|
||||
Mode: sess.Mode,
|
||||
Username: sess.Username,
|
||||
UserID: sess.UserID,
|
||||
Initiator: sess.Initiator,
|
||||
})
|
||||
}
|
||||
return &proto.VNCServerState{
|
||||
Enabled: enabled,
|
||||
Sessions: pbSessions,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
|
||||
func (s *Server) GetPeerSSHHostKey(
|
||||
ctx context.Context,
|
||||
@@ -1537,144 +1395,6 @@ func (s *Server) WaitJWTToken(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RequestExtendAuthSession initiates the SSO session-extension flow and
|
||||
// returns the verification URI the UI should open. The flow state is held
|
||||
// in s.extendAuthSessionFlow until WaitExtendAuthSession resolves it.
|
||||
func (s *Server) RequestExtendAuthSession(
|
||||
ctx context.Context,
|
||||
msg *proto.RequestExtendAuthSessionRequest,
|
||||
) (*proto.RequestExtendAuthSessionResponse, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
config := s.config
|
||||
connectClient := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
|
||||
if config == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not configured")
|
||||
}
|
||||
if connectClient == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
||||
}
|
||||
|
||||
hint := ""
|
||||
if msg.Hint != nil {
|
||||
hint = *msg.Hint
|
||||
}
|
||||
if hint == "" {
|
||||
hint = profilemanager.GetLoginHint()
|
||||
}
|
||||
|
||||
isDesktop := isUnixRunningDesktop()
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint)
|
||||
if err != nil {
|
||||
return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
|
||||
}
|
||||
|
||||
authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, gstatus.Errorf(codes.Internal, "failed to request auth info: %v", err)
|
||||
}
|
||||
|
||||
s.extendAuthSessionFlow.Set(oAuthFlow, authInfo)
|
||||
|
||||
return &proto.RequestExtendAuthSessionResponse{
|
||||
VerificationURI: authInfo.VerificationURI,
|
||||
VerificationURIComplete: authInfo.VerificationURIComplete,
|
||||
UserCode: authInfo.UserCode,
|
||||
DeviceCode: authInfo.DeviceCode,
|
||||
ExpiresIn: int64(authInfo.ExpiresIn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WaitExtendAuthSession blocks until the user completes the SSO step
|
||||
// initiated by RequestExtendAuthSession, then forwards the resulting JWT
|
||||
// to the management server's ExtendAuthSession RPC. The returned deadline
|
||||
// is also applied locally via the engine so SubscribeStatus consumers see
|
||||
// the refreshed state.
|
||||
func (s *Server) WaitExtendAuthSession(
|
||||
ctx context.Context,
|
||||
req *proto.WaitExtendAuthSessionRequest,
|
||||
) (*proto.WaitExtendAuthSessionResponse, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
oAuthFlow, authInfo, ok := s.extendAuthSessionFlow.Get()
|
||||
|
||||
s.mutex.Lock()
|
||||
connectClient := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
|
||||
if !ok || authInfo.DeviceCode != req.DeviceCode {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "invalid device code or no active extend-session flow")
|
||||
}
|
||||
|
||||
// Preempt a previous WaitExtendAuthSession (e.g. when the tray
|
||||
// notification and the about-to-expire dialog both start a flow on
|
||||
// the same deadline). The older waiter exits via context.Canceled;
|
||||
// the new one takes over the IdP poll.
|
||||
s.extendAuthSessionFlow.CancelWait()
|
||||
|
||||
waitCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
s.extendAuthSessionFlow.SetWaitCancel(cancel)
|
||||
|
||||
tokenInfo, err := oAuthFlow.WaitToken(waitCtx, authInfo)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil, gstatus.Errorf(codes.Canceled, "extend-session flow preempted")
|
||||
}
|
||||
return nil, gstatus.Errorf(codes.Internal, "failed to obtain JWT token: %v", err)
|
||||
}
|
||||
|
||||
// Clear pending flow before talking to mgm so a retry can re-initiate.
|
||||
s.extendAuthSessionFlow.Clear()
|
||||
|
||||
if connectClient == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
||||
}
|
||||
engine := connectClient.Engine()
|
||||
if engine == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "engine is not initialised")
|
||||
}
|
||||
|
||||
deadline, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse())
|
||||
if err != nil {
|
||||
return nil, gstatus.Errorf(codes.Internal, "management ExtendAuthSession failed: %v", err)
|
||||
}
|
||||
|
||||
resp := &proto.WaitExtendAuthSessionResponse{}
|
||||
if !deadline.IsZero() {
|
||||
resp.SessionExpiresAt = timestamppb.New(deadline)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// DismissSessionWarning forwards the user's "Dismiss" click on the
|
||||
// T-WarningLead notification down to the engine's sessionWatcher so the
|
||||
// T-FinalWarningLead fallback is suppressed for the current deadline.
|
||||
// Best-effort: when the client/engine is not yet running the call is a
|
||||
// successful no-op (the watcher has no deadline to dismiss anyway).
|
||||
func (s *Server) DismissSessionWarning(
|
||||
_ context.Context,
|
||||
_ *proto.DismissSessionWarningRequest,
|
||||
) (*proto.DismissSessionWarningResponse, error) {
|
||||
s.mutex.Lock()
|
||||
connectClient := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
if connectClient == nil {
|
||||
return &proto.DismissSessionWarningResponse{}, nil
|
||||
}
|
||||
if engine := connectClient.Engine(); engine != nil {
|
||||
engine.DismissSessionWarning()
|
||||
}
|
||||
return &proto.DismissSessionWarningResponse{}, nil
|
||||
}
|
||||
|
||||
// ExposeService exposes a local port via the NetBird reverse proxy.
|
||||
func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error {
|
||||
s.mutex.Lock()
|
||||
@@ -1734,6 +1454,27 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespondApproval relays the user's accept/deny decision for a pending
|
||||
// approval prompt to the engine's broker. Unknown or already-resolved
|
||||
// request_ids are silently no-op'd so a slow UI cannot deny a prompt the
|
||||
// user already handled (or that already timed out).
|
||||
func (s *Server) RespondApproval(_ context.Context, msg *proto.RespondApprovalRequest) (*proto.RespondApprovalResponse, error) {
|
||||
s.mutex.Lock()
|
||||
connectClient := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
if connectClient == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "client not initialized")
|
||||
}
|
||||
engine := connectClient.Engine()
|
||||
if engine == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "engine not running")
|
||||
}
|
||||
if !engine.RespondApproval(msg.GetRequestId(), msg.GetAccept(), msg.GetViewOnly()) {
|
||||
log.Debugf("approval response for unknown request_id %s", msg.GetRequestId())
|
||||
}
|
||||
return &proto.RespondApprovalResponse{}, nil
|
||||
}
|
||||
|
||||
func isUnixRunningDesktop() bool {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
||||
return false
|
||||
@@ -1741,7 +1482,7 @@ func isUnixRunningDesktop() bool {
|
||||
return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
|
||||
}
|
||||
|
||||
func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) {
|
||||
func (s *Server) runProbes(waitForProbeResult bool) {
|
||||
if s.connectClient == nil {
|
||||
return
|
||||
}
|
||||
@@ -1751,7 +1492,15 @@ func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) {
|
||||
return
|
||||
}
|
||||
|
||||
s.probeThrottle.Run(ctx, engine, s.statusRecorder, waitForProbeResult)
|
||||
if time.Since(s.lastProbe) > probeThreshold {
|
||||
if engine.RunHealthProbes(waitForProbeResult) {
|
||||
s.lastProbe = time.Now()
|
||||
}
|
||||
} else {
|
||||
if err := s.statusRecorder.RefreshWireGuardStats(); err != nil {
|
||||
log.Debugf("failed to refresh WireGuard stats: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig of the daemon.
|
||||
@@ -1842,6 +1591,8 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
||||
Mtu: int64(cfg.MTU),
|
||||
DisableAutoConnect: cfg.DisableAutoConnect,
|
||||
ServerSSHAllowed: *cfg.ServerSSHAllowed,
|
||||
ServerVNCAllowed: cfg.ServerVNCAllowed != nil && *cfg.ServerVNCAllowed,
|
||||
DisableVNCApproval: cfg.DisableVNCApproval != nil && *cfg.DisableVNCApproval,
|
||||
RosenpassEnabled: cfg.RosenpassEnabled,
|
||||
RosenpassPermissive: cfg.RosenpassPermissive,
|
||||
LazyConnectionEnabled: cfg.LazyConnectionEnabled,
|
||||
@@ -1880,8 +1631,6 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
|
||||
return nil, fmt.Errorf("failed to create profile: %w", err)
|
||||
}
|
||||
|
||||
s.publishProfileListChanged(msg.ProfileName)
|
||||
|
||||
return &proto.AddProfileResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -1903,32 +1652,9 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
|
||||
return nil, fmt.Errorf("failed to remove profile: %w", err)
|
||||
}
|
||||
|
||||
s.publishProfileListChanged(msg.ProfileName)
|
||||
|
||||
return &proto.RemoveProfileResponse{}, nil
|
||||
}
|
||||
|
||||
// publishProfileListChanged nudges the desktop UI to refresh its profile list
|
||||
// after a CLI-driven add/remove. The daemon exposes no dedicated
|
||||
// profile-changed RPC event, and a profile add/remove doesn't move the
|
||||
// connection status, so the UI's SubscribeStatus path never fires for it (and
|
||||
// the tray's status-string guard would swallow it anyway). Instead we publish
|
||||
// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent
|
||||
// recognises the metadata "kind" marker and translates it into its internal
|
||||
// profile-changed signal that both the tray menu and the React profile views
|
||||
// already subscribe to (see client/ui/services/daemon_feed.go,
|
||||
// MetadataKindProfileListChanged). userMessage is intentionally empty so this
|
||||
// stays a silent refresh signal rather than a user-facing notification.
|
||||
func (s *Server) publishProfileListChanged(profileName string) {
|
||||
s.statusRecorder.PublishEvent(
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
"Profile list changed",
|
||||
"",
|
||||
map[string]string{"kind": "profile-list-changed", "profile": profileName},
|
||||
)
|
||||
}
|
||||
|
||||
// ListProfiles lists all profiles in the daemon.
|
||||
func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) {
|
||||
s.mutex.Lock()
|
||||
@@ -2133,15 +1859,3 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logoutPeerGone reports whether a management Logout failed because the peer
|
||||
// no longer exists server-side (gRPC NotFound), walking the wrap chain since
|
||||
// the client wraps the gRPC status with fmt.Errorf.
|
||||
func logoutPeerGone(err error) bool {
|
||||
for e := err; e != nil; e = errors.Unwrap(e) {
|
||||
if s, ok := gstatus.FromError(e); ok && s.Code() == codes.NotFound {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
rosenpassEnabled := true
|
||||
rosenpassPermissive := true
|
||||
serverSSHAllowed := true
|
||||
serverVNCAllowed := true
|
||||
disableVNCApproval := true
|
||||
interfaceName := "utun100"
|
||||
wireguardPort := int64(51820)
|
||||
preSharedKey := "test-psk"
|
||||
@@ -83,6 +85,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
RosenpassEnabled: &rosenpassEnabled,
|
||||
RosenpassPermissive: &rosenpassPermissive,
|
||||
ServerSSHAllowed: &serverSSHAllowed,
|
||||
ServerVNCAllowed: &serverVNCAllowed,
|
||||
DisableVNCApproval: &disableVNCApproval,
|
||||
InterfaceName: &interfaceName,
|
||||
WireguardPort: &wireguardPort,
|
||||
OptionalPreSharedKey: &preSharedKey,
|
||||
@@ -127,6 +131,10 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
|
||||
require.NotNil(t, cfg.ServerSSHAllowed)
|
||||
require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed)
|
||||
require.NotNil(t, cfg.ServerVNCAllowed)
|
||||
require.Equal(t, serverVNCAllowed, *cfg.ServerVNCAllowed)
|
||||
require.NotNil(t, cfg.DisableVNCApproval)
|
||||
require.Equal(t, disableVNCApproval, *cfg.DisableVNCApproval)
|
||||
require.Equal(t, interfaceName, cfg.WgIface)
|
||||
require.Equal(t, int(wireguardPort), cfg.WgPort)
|
||||
require.Equal(t, preSharedKey, cfg.PreSharedKey)
|
||||
@@ -179,6 +187,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
|
||||
"RosenpassEnabled": true,
|
||||
"RosenpassPermissive": true,
|
||||
"ServerSSHAllowed": true,
|
||||
"ServerVNCAllowed": true,
|
||||
"DisableVNCApproval": true,
|
||||
"InterfaceName": true,
|
||||
"WireguardPort": true,
|
||||
"OptionalPreSharedKey": true,
|
||||
@@ -240,6 +250,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
|
||||
"enable-rosenpass": "RosenpassEnabled",
|
||||
"rosenpass-permissive": "RosenpassPermissive",
|
||||
"allow-server-ssh": "ServerSSHAllowed",
|
||||
"allow-server-vnc": "ServerVNCAllowed",
|
||||
"disable-vnc-approval": "DisableVNCApproval",
|
||||
"interface-name": "InterfaceName",
|
||||
"wireguard-port": "WireguardPort",
|
||||
"preshared-key": "OptionalPreSharedKey",
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// SubscribeStatus pushes a fresh StatusResponse on every connection state
|
||||
// change. The first message is the current snapshot, so a re-subscribing
|
||||
// client doesn't need to also call Status. Subsequent messages fire when
|
||||
// the peer recorder reports any of: connected/disconnected/connecting,
|
||||
// management or signal flip, address change, or peers list change.
|
||||
//
|
||||
// The change channel coalesces bursts to a single tick. If the consumer
|
||||
// is slow the daemon drops extras (not blocks), and the next snapshot
|
||||
// the consumer pulls already reflects everything.
|
||||
func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
|
||||
subID, ch := s.statusRecorder.SubscribeToStateChanges()
|
||||
defer func() {
|
||||
s.statusRecorder.UnsubscribeFromStateChanges(subID)
|
||||
log.Debug("client unsubscribed from status updates")
|
||||
}()
|
||||
|
||||
log.Debug("client subscribed to status updates")
|
||||
|
||||
if err := s.sendStatusSnapshot(req, stream); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := s.sendStatusSnapshot(req, stream); err != nil {
|
||||
return err
|
||||
}
|
||||
case <-stream.Context().Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
|
||||
resp, err := s.buildStatusResponse(stream.Context(), req)
|
||||
if err != nil {
|
||||
log.Warnf("build status snapshot for stream: %v", err)
|
||||
return err
|
||||
}
|
||||
if err := stream.Send(resp); err != nil {
|
||||
log.Warnf("send status snapshot to stream: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -28,10 +28,10 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
"github.com/netbirdio/netbird/client/ssh/server"
|
||||
"github.com/netbirdio/netbird/client/ssh/testutil"
|
||||
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
"github.com/netbirdio/netbird/client/ssh/client"
|
||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||
"github.com/netbirdio/netbird/client/ssh/testutil"
|
||||
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ import (
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
"github.com/netbirdio/netbird/util/netrelay"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
@@ -197,6 +197,14 @@ type Config struct {
|
||||
|
||||
// HostKey is the SSH server host key in PEM format
|
||||
HostKeyPEM []byte
|
||||
|
||||
// NetstackNet, when non-nil, makes the SSH server listen via the
|
||||
// supplied userspace network stack instead of an OS socket.
|
||||
NetstackNet *netstack.Net
|
||||
|
||||
// NetworkValidation, when non-zero, restricts inbound connections to
|
||||
// peers inside the NetBird overlay defined by this WireGuard address.
|
||||
NetworkValidation wgaddr.Address
|
||||
}
|
||||
|
||||
// SessionInfo contains information about an active SSH session
|
||||
@@ -208,12 +216,15 @@ type SessionInfo struct {
|
||||
PortForwards []string
|
||||
}
|
||||
|
||||
// New creates an SSH server instance with the provided host key and optional JWT configuration
|
||||
// If jwtConfig is nil, JWT authentication is disabled
|
||||
// New creates an SSH server instance from the supplied Config. Fields are
|
||||
// read once at construction; mutating Config afterwards has no effect.
|
||||
// JWT == nil disables JWT authentication.
|
||||
func New(config *Config) *Server {
|
||||
s := &Server{
|
||||
mu: sync.RWMutex{},
|
||||
hostKeyPEM: config.HostKeyPEM,
|
||||
netstackNet: config.NetstackNet,
|
||||
wgAddress: config.NetworkValidation,
|
||||
sessions: make(map[sessionKey]*sessionState),
|
||||
pendingAuthJWT: make(map[authKey]string),
|
||||
remoteForwardListeners: make(map[forwardKey]net.Listener),
|
||||
@@ -434,20 +445,6 @@ func (s *Server) buildSessionInfo(state *sessionState) SessionInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// SetNetstackNet sets the netstack network for userspace networking
|
||||
func (s *Server) SetNetstackNet(net *netstack.Net) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.netstackNet = net
|
||||
}
|
||||
|
||||
// SetNetworkValidation configures network-based connection filtering
|
||||
func (s *Server) SetNetworkValidation(addr wgaddr.Address) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.wgAddress = addr
|
||||
}
|
||||
|
||||
// UpdateSSHAuth updates the SSH fine-grained access control configuration
|
||||
// This should be called when network map updates include new SSH auth configuration
|
||||
func (s *Server) UpdateSSHAuth(config *sshauth.Config) {
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
// This file is intentionally named test.go (not test_test.go) so the exported
|
||||
// StartTestServer helper is visible to the ssh/proxy and ssh/client external
|
||||
// test packages, not just this package's own tests. The //go:build !js tag
|
||||
// keeps its "testing" import — and the whole testing/flag/regexp transitive
|
||||
// chain it drags in — out of the wasm client, which links ssh/server through
|
||||
// the engine but never runs Go tests under GOOS=js.
|
||||
//go:build !js
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
|
||||
@@ -55,10 +55,6 @@ type ConvertOptions struct {
|
||||
IPsFilter map[string]struct{}
|
||||
ConnectionTypeFilter string
|
||||
ProfileName string
|
||||
// SessionExpiresAt is the absolute UTC instant at which the peer's SSO
|
||||
// session expires. Zero when the peer is not SSO-tracked or login
|
||||
// expiration is disabled. Sourced from StatusResponse.SessionExpiresAt.
|
||||
SessionExpiresAt time.Time
|
||||
}
|
||||
|
||||
type PeerStateDetailOutput struct {
|
||||
@@ -135,6 +131,19 @@ type SSHServerStateOutput struct {
|
||||
Sessions []SSHSessionOutput `json:"sessions" yaml:"sessions"`
|
||||
}
|
||||
|
||||
type VNCSessionOutput struct {
|
||||
RemoteAddress string `json:"remoteAddress" yaml:"remoteAddress"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
Username string `json:"username,omitempty" yaml:"username,omitempty"`
|
||||
UserID string `json:"userID,omitempty" yaml:"userID,omitempty"`
|
||||
Initiator string `json:"initiator,omitempty" yaml:"initiator,omitempty"`
|
||||
}
|
||||
|
||||
type VNCServerStateOutput struct {
|
||||
Enabled bool `json:"enabled" yaml:"enabled"`
|
||||
Sessions []VNCSessionOutput `json:"sessions" yaml:"sessions"`
|
||||
}
|
||||
|
||||
type OutputOverview struct {
|
||||
Peers PeersStateOutput `json:"peers" yaml:"peers"`
|
||||
CliVersion string `json:"cliVersion" yaml:"cliVersion"`
|
||||
@@ -158,11 +167,7 @@ type OutputOverview struct {
|
||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"`
|
||||
ProfileName string `json:"profileName" yaml:"profileName"`
|
||||
SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"`
|
||||
// SessionExpiresAt is the absolute UTC instant at which the peer's SSO
|
||||
// session expires. nil when the peer is not SSO-tracked or login
|
||||
// expiration is disabled. Pointer (rather than zero-value time.Time) so
|
||||
// JSON / YAML omit the field entirely with `,omitempty`.
|
||||
SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty" yaml:"sessionExpiresAt,omitempty"`
|
||||
VNCServerState VNCServerStateOutput `json:"vncServer" yaml:"vncServer"`
|
||||
}
|
||||
|
||||
// ConvertToStatusOutputOverview converts protobuf status to the output overview.
|
||||
@@ -183,6 +188,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
|
||||
relayOverview := mapRelays(pbFullStatus.GetRelays())
|
||||
sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState())
|
||||
vncServerOverview := mapVNCServer(pbFullStatus.GetVncServerState())
|
||||
peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter)
|
||||
|
||||
overview := OutputOverview{
|
||||
@@ -208,10 +214,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(),
|
||||
ProfileName: opts.ProfileName,
|
||||
SSHServerState: sshServerOverview,
|
||||
}
|
||||
if !opts.SessionExpiresAt.IsZero() {
|
||||
t := opts.SessionExpiresAt
|
||||
overview.SessionExpiresAt = &t
|
||||
VNCServerState: vncServerOverview,
|
||||
}
|
||||
|
||||
if opts.Anonymize {
|
||||
@@ -286,6 +289,26 @@ func mapSSHServer(sshServerState *proto.SSHServerState) SSHServerStateOutput {
|
||||
}
|
||||
}
|
||||
|
||||
func mapVNCServer(state *proto.VNCServerState) VNCServerStateOutput {
|
||||
if state == nil {
|
||||
return VNCServerStateOutput{Sessions: []VNCSessionOutput{}}
|
||||
}
|
||||
sessions := make([]VNCSessionOutput, 0, len(state.GetSessions()))
|
||||
for _, sess := range state.GetSessions() {
|
||||
sessions = append(sessions, VNCSessionOutput{
|
||||
RemoteAddress: sess.GetRemoteAddress(),
|
||||
Mode: sess.GetMode(),
|
||||
Username: sess.GetUsername(),
|
||||
UserID: sess.GetUserID(),
|
||||
Initiator: sess.GetInitiator(),
|
||||
})
|
||||
}
|
||||
return VNCServerStateOutput{
|
||||
Enabled: state.GetEnabled(),
|
||||
Sessions: sessions,
|
||||
}
|
||||
}
|
||||
|
||||
func mapPeers(
|
||||
peers []*proto.PeerState,
|
||||
statusFilter string,
|
||||
@@ -548,17 +571,28 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
}
|
||||
}
|
||||
|
||||
peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
|
||||
vncServerStatus := "Disabled"
|
||||
if o.VNCServerState.Enabled {
|
||||
vncSessionCount := len(o.VNCServerState.Sessions)
|
||||
if vncSessionCount > 0 {
|
||||
sessionWord := "session"
|
||||
if vncSessionCount > 1 {
|
||||
sessionWord = "sessions"
|
||||
}
|
||||
vncServerStatus = fmt.Sprintf("Enabled (%d active %s)", vncSessionCount, sessionWord)
|
||||
} else {
|
||||
vncServerStatus = "Enabled"
|
||||
}
|
||||
|
||||
var sessionExpiryString string
|
||||
if o.SessionExpiresAt != nil && !o.SessionExpiresAt.IsZero() {
|
||||
sessionExpiryString = fmt.Sprintf(
|
||||
"Session expires: %s (in %s)\n",
|
||||
o.SessionExpiresAt.Format(time.RFC3339),
|
||||
FormatRemainingDuration(time.Until(*o.SessionExpiresAt)),
|
||||
)
|
||||
if showSSHSessions && vncSessionCount > 0 {
|
||||
for _, sess := range o.VNCServerState.Sessions {
|
||||
vncServerStatus += "\n " + formatVNCSessionLine(sess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
|
||||
|
||||
var forwardingRulesString string
|
||||
if o.NumberOfForwardingRules > 0 {
|
||||
forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules)
|
||||
@@ -603,9 +637,9 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
"Quantum resistance: %s\n"+
|
||||
"Lazy connection: %s\n"+
|
||||
"SSH Server: %s\n"+
|
||||
"VNC Server: %s\n"+
|
||||
"Networks: %s\n"+
|
||||
"%s"+
|
||||
"%s"+
|
||||
"Peers count: %s\n",
|
||||
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
|
||||
daemonVersion,
|
||||
@@ -623,9 +657,9 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
rosenpassEnabledStatus,
|
||||
lazyConnectionEnabledStatus,
|
||||
sshServerStatus,
|
||||
vncServerStatus,
|
||||
networks,
|
||||
forwardingRulesString,
|
||||
sessionExpiryString,
|
||||
peersCountString,
|
||||
)
|
||||
return summary
|
||||
@@ -983,6 +1017,26 @@ func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) {
|
||||
}
|
||||
}
|
||||
|
||||
// formatVNCSessionLine renders a single VNC session row for the detailed
|
||||
// status output. The leading slot identifies the initiator (display name
|
||||
// when known, hashed UserID otherwise); the post-arrow slot is the OS
|
||||
// user the session targets and is omitted in attach mode where the
|
||||
// destination is the current console user (unknown to the daemon).
|
||||
func formatVNCSessionLine(sess VNCSessionOutput) string {
|
||||
who := sess.Initiator
|
||||
if who == "" {
|
||||
who = sess.UserID
|
||||
}
|
||||
prefix := sess.RemoteAddress
|
||||
if who != "" {
|
||||
prefix = fmt.Sprintf("%s@%s", who, sess.RemoteAddress)
|
||||
}
|
||||
if sess.Username != "" {
|
||||
return fmt.Sprintf("[%s -> %s] mode=%s", prefix, sess.Username, sess.Mode)
|
||||
}
|
||||
return fmt.Sprintf("[%s] mode=%s", prefix, sess.Mode)
|
||||
}
|
||||
|
||||
func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, peer := range overview.Peers.Details {
|
||||
peer := peer
|
||||
@@ -1003,6 +1057,19 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
overview.Relays.Details[i] = detail
|
||||
}
|
||||
|
||||
anonymizeNSServerGroups(a, overview)
|
||||
|
||||
for i, route := range overview.Networks {
|
||||
overview.Networks[i] = a.AnonymizeRoute(route)
|
||||
}
|
||||
|
||||
overview.FQDN = a.AnonymizeDomain(overview.FQDN)
|
||||
|
||||
anonymizeEvents(a, overview)
|
||||
anonymizeServerSessions(a, overview)
|
||||
}
|
||||
|
||||
func anonymizeNSServerGroups(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, nsGroup := range overview.NSServerGroups {
|
||||
for j, domain := range nsGroup.Domains {
|
||||
overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomain(domain)
|
||||
@@ -1014,13 +1081,9 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, route := range overview.Networks {
|
||||
overview.Networks[i] = a.AnonymizeRoute(route)
|
||||
}
|
||||
|
||||
overview.FQDN = a.AnonymizeDomain(overview.FQDN)
|
||||
|
||||
func anonymizeEvents(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, event := range overview.Events {
|
||||
overview.Events[i].Message = a.AnonymizeString(event.Message)
|
||||
overview.Events[i].UserMessage = a.AnonymizeString(event.UserMessage)
|
||||
@@ -1029,67 +1092,24 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
event.Metadata[k] = a.AnonymizeString(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func anonymizeRemoteAddress(a *anonymize.Anonymizer, addr string) string {
|
||||
if host, port, err := net.SplitHostPort(addr); err == nil {
|
||||
return fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port)
|
||||
}
|
||||
return a.AnonymizeIPString(addr)
|
||||
}
|
||||
|
||||
func anonymizeServerSessions(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, session := range overview.SSHServerState.Sessions {
|
||||
if host, port, err := net.SplitHostPort(session.RemoteAddress); err == nil {
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port)
|
||||
} else {
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = a.AnonymizeIPString(session.RemoteAddress)
|
||||
}
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, session.RemoteAddress)
|
||||
overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatRemainingDuration renders a time.Duration for the "Session expires"
|
||||
// line. Examples: "2h 15m", "47m 12s", "8s", "expired 3m ago".
|
||||
//
|
||||
// Granularity drops to seconds only under a minute, otherwise minutes are
|
||||
// the smallest unit shown — sub-minute precision is noise for a deadline
|
||||
// that's hours or days out.
|
||||
func FormatRemainingDuration(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "expired " + HumaniseDuration(-d) + " ago"
|
||||
}
|
||||
return HumaniseDuration(d)
|
||||
}
|
||||
|
||||
// HumaniseDuration renders a positive duration in compact form (e.g.
|
||||
// "2h 15m", "47m", "8s"). Exposed alongside FormatRemainingDuration so
|
||||
// callers that don't need the "expired … ago" wording can format
|
||||
// positive durations directly.
|
||||
func HumaniseDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
s := int(d.Round(time.Second).Seconds())
|
||||
if s < 1 {
|
||||
s = 1
|
||||
}
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
|
||||
const (
|
||||
day = 24 * time.Hour
|
||||
hour = time.Hour
|
||||
minute = time.Minute
|
||||
)
|
||||
|
||||
days := int64(d / day)
|
||||
d -= time.Duration(days) * day
|
||||
hours := int64(d / hour)
|
||||
d -= time.Duration(hours) * hour
|
||||
minutes := int64(d / minute)
|
||||
|
||||
switch {
|
||||
case days > 0:
|
||||
if hours == 0 {
|
||||
return fmt.Sprintf("%dd", days)
|
||||
}
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
case hours > 0:
|
||||
if minutes == 0 {
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
default:
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
for i, sess := range overview.VNCServerState.Sessions {
|
||||
overview.VNCServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, sess.RemoteAddress)
|
||||
overview.VNCServerState.Sessions[i].Username = a.AnonymizeString(sess.Username)
|
||||
overview.VNCServerState.Sessions[i].UserID = a.AnonymizeString(sess.UserID)
|
||||
overview.VNCServerState.Sessions[i].Initiator = a.AnonymizeString(sess.Initiator)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,10 @@ var overview = OutputOverview{
|
||||
Enabled: false,
|
||||
Sessions: []SSHSessionOutput{},
|
||||
},
|
||||
VNCServerState: VNCServerStateOutput{
|
||||
Enabled: false,
|
||||
Sessions: []VNCSessionOutput{},
|
||||
},
|
||||
}
|
||||
|
||||
func TestConversionFromFullStatusToOutputOverview(t *testing.T) {
|
||||
@@ -407,6 +411,10 @@ func TestParsingToJSON(t *testing.T) {
|
||||
"sshServer":{
|
||||
"enabled":false,
|
||||
"sessions":[]
|
||||
},
|
||||
"vncServer":{
|
||||
"enabled":false,
|
||||
"sessions":[]
|
||||
}
|
||||
}`
|
||||
// @formatter:on
|
||||
@@ -517,6 +525,9 @@ profileName: ""
|
||||
sshServer:
|
||||
enabled: false
|
||||
sessions: []
|
||||
vncServer:
|
||||
enabled: false
|
||||
sessions: []
|
||||
`
|
||||
|
||||
assert.Equal(t, expectedYAML, yaml)
|
||||
@@ -587,6 +598,7 @@ Wireguard port: %d
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
VNC Server: Disabled
|
||||
Networks: 10.10.0.0/24
|
||||
Peers count: 2/2 Connected
|
||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
|
||||
@@ -613,6 +625,7 @@ Wireguard port: 51820
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
VNC Server: Disabled
|
||||
Networks: 10.10.0.0/24
|
||||
Peers count: 2/2 Connected
|
||||
`
|
||||
@@ -647,50 +660,3 @@ func TestTimeAgo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumaniseDuration(t *testing.T) {
|
||||
cases := []struct {
|
||||
in time.Duration
|
||||
want string
|
||||
}{
|
||||
{0, "1s"},
|
||||
{500 * time.Millisecond, "1s"},
|
||||
{8 * time.Second, "8s"},
|
||||
{59 * time.Second, "59s"},
|
||||
{time.Minute, "1m"},
|
||||
{47*time.Minute + 12*time.Second, "47m"},
|
||||
{time.Hour, "1h"},
|
||||
{2*time.Hour + 15*time.Minute, "2h 15m"},
|
||||
{2 * time.Hour, "2h"},
|
||||
{24 * time.Hour, "1d"},
|
||||
{2*24*time.Hour + 3*time.Hour, "2d 3h"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := HumaniseDuration(tc.in)
|
||||
assert.Equal(t, tc.want, got, "input %s", tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRemainingDuration_Expired(t *testing.T) {
|
||||
assert.Equal(t, "expired 3m ago", FormatRemainingDuration(-3*time.Minute))
|
||||
assert.Equal(t, "expired 1s ago", FormatRemainingDuration(-500*time.Millisecond))
|
||||
}
|
||||
|
||||
func TestSessionExpiresLineRendered(t *testing.T) {
|
||||
in := overview // copy of the package-level fixture
|
||||
deadline := time.Now().Add(2*time.Hour + 30*time.Minute).UTC()
|
||||
in.SessionExpiresAt = &deadline
|
||||
|
||||
out := in.GeneralSummary(false, false, false, false)
|
||||
assert.Contains(t, out, "Session expires: ")
|
||||
assert.Contains(t, out, deadline.Format(time.RFC3339))
|
||||
// 2h 30m drifts to "2h 29m" within 60s — match the family prefix.
|
||||
assert.Contains(t, out, "(in 2h ")
|
||||
}
|
||||
|
||||
func TestSessionExpiresLineOmittedWhenNil(t *testing.T) {
|
||||
in := overview
|
||||
in.SessionExpiresAt = nil
|
||||
out := in.GeneralSummary(false, false, false, false)
|
||||
assert.NotContains(t, out, "Session expires")
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ type Info struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed bool
|
||||
ServerVNCAllowed bool
|
||||
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -83,6 +84,7 @@ type Info struct {
|
||||
func (i *Info) SetFlags(
|
||||
rosenpassEnabled, rosenpassPermissive bool,
|
||||
serverSSHAllowed *bool,
|
||||
serverVNCAllowed *bool,
|
||||
disableClientRoutes, disableServerRoutes,
|
||||
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool,
|
||||
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
|
||||
@@ -93,6 +95,9 @@ func (i *Info) SetFlags(
|
||||
if serverSSHAllowed != nil {
|
||||
i.ServerSSHAllowed = *serverSSHAllowed
|
||||
}
|
||||
if serverVNCAllowed != nil {
|
||||
i.ServerVNCAllowed = *serverVNCAllowed
|
||||
}
|
||||
|
||||
i.DisableClientRoutes = disableClientRoutes
|
||||
i.DisableServerRoutes = disableServerRoutes
|
||||
|
||||
8
client/ui/.gitignore
vendored
8
client/ui/.gitignore
vendored
@@ -1,8 +0,0 @@
|
||||
.task
|
||||
bin
|
||||
frontend/dist
|
||||
frontend/node_modules
|
||||
frontend/bindings
|
||||
frontend/.vite
|
||||
build/linux/appimage/build
|
||||
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||
@@ -1,163 +0,0 @@
|
||||
# NetBird Wails UI — Working Notes
|
||||
|
||||
This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; the React/TS frontend lives in `frontend/`; bindings between them are generated under `frontend/bindings/`.
|
||||
|
||||
> **Keep these notes current.** When working in this directory with Claude, update this file (and `frontend/CLAUDE.md` for frontend-only changes) whenever you add a service, change an event name, shift a convention, rename a key directory, or land any other change that future-you would want to know about before reading the code. The goal is that a cold-start agent can orient itself from these notes without re-deriving the codebase.
|
||||
|
||||
## Layout
|
||||
|
||||
### Go (top-level package `main`)
|
||||
- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch` → `app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; first user-provided value drops the seeded `console` default), `--log-level` (`trace|debug|info|warn|error`, default `info`).
|
||||
- `tray.go` — `Tray` struct + menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast.
|
||||
- **Tray menu updates go through `relayoutMenu` (whole-tree rebuild), never in-place submenu mutation.** Any dynamic menu change — Profiles submenu (`tray_profiles.go loadProfiles` → caches rows under `profilesMu`, then `fillProfileSubmenu`), Exit Node submenu (`tray_exitnodes.go refreshExitNodes` → `fillExitNodeSubmenu`), daemon-version row (`tray_status.go`), and the About → Update row (`tray_update.go applyState` → `onMenuChange` callback) — rebuilds the entire menu via `Tray.relayoutMenu` (`buildMenu()` + repaint cached state + single `t.tray.SetMenu`). Serialised by `menuMu`. **Why:** on KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first time it's opened (`GetLayout` for that submenu id) and never re-fetches it on a `LayoutUpdated(parent=0)` signal — so the old `submenu.Clear()`+`Add()` left both the visible rows AND the click→id mapping frozen on the first snapshot. Because `Clear()`+`Add()` allocates fresh monotonic item ids each time (Wails `menuitem.go`), clicks then sent ids the rebuilt `itemMap` no longer knew, and silently no-op'd ("Manage Profiles" stopped responding after the first switch). `buildMenu()` allocates a brand-new submenu container id each relayout, which Plasma treats as unseen and re-queries on next open — fixing both the stale paint and the dead clicks. Confirmed via `dbus-monitor`: a re-opened submenu issued no `GetLayout` until its container id changed. The whole-tree `SetMenu` also subsumes the older darwin detached-NSMenu workaround. `fill*Submenu` helpers are pure UI (read caches, no daemon fetch, no `SetMenu`) so `relayoutMenu` never recurses back into the fetchers.
|
||||
- `tray_linux.go` — `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` (blank-white window on VMs / minimal WMs) and `WEBKIT_DISABLE_COMPOSITING_MODE=1` (Intel/Mesa SIGSEGV in `g_application_run` via unimplemented DRM-format-modifier paths — DMABUF-disable alone doesn't cover the GL compositor). Both are skipped if the user already set the var. Also `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1` when unprivileged userns are blocked.
|
||||
- `tray_watcher_linux.go`, `xembed_host_linux.go`, `xembed_tray_linux.{c,h}` — in-process SNI watcher + XEmbed bridge for minimal WMs. See `LINUX-TRAY.md`.
|
||||
- `signal_unix.go` / `signal_windows.go` — `listenForShowSignal`. Unix uses SIGUSR1; Windows uses a named event `Global\NetBirdQuickActionsTriggerEvent`. Mirrors the legacy Fyne UI's external-trigger contract so the installer / CLI keep working.
|
||||
- `grpc.go` — lazy, mutex-protected gRPC `Conn` shared by every service. `DaemonAddr()`: `unix:///var/run/netbird.sock` on Linux/macOS, `tcp://127.0.0.1:41731` on Windows.
|
||||
- `icons.go` — `//go:embed` tray/window PNGs. macOS uses template variants (`*-macos.png`); Linux uses a monochrome black/white pair (`*-mono.png` black for light panels, `*-mono-dark.png` white for dark panels); Windows reuses the colored light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`). The `*-mono*` set is generated from the macOS template silhouettes (states differ by shape, not color); `tray_icon.go iconForState` branches on `runtime.GOOS` (`linux` → mono, else colored).
|
||||
- **Linux mono icon theme selection** — Wails v3's Linux SNI backend ignores `SetDarkModeIcon` (its `setDarkModeIcon` just calls `setIcon`, last-write-wins — see `pkg/application/systemtray_linux.go`), and the SNI spec carries no panel light/dark hint. So `tray_theme_linux.go` detects the desktop colour scheme itself and `iconForState` picks black-vs-white, with `applyIcon` pushing a single `SetIcon` on Linux (no `SetDarkModeIcon`). Detection order: freedesktop **Settings portal** (`org.freedesktop.portal.Settings.Read` of `org.freedesktop.appearance`/`color-scheme`: 0=no-pref, 1=dark, 2=light) → on 0/unavailable, fall back to the **`GTK_THEME`** env var (`:dark` suffix ⇒ dark) → else default dark (suits the common dark panel). A private session-bus `SettingChanged` subscription repaints live on theme flips. `Tray.panelDark func() bool` is seeded by `startTrayTheme()` (Linux only; `tray_theme_other.go` is a no-op stub) before the first `applyIcon`; `panelIsDark()` returns true when `panelDark` is nil.
|
||||
|
||||
### Wails services (`services/*.go`)
|
||||
Each service is registered via `app.RegisterService(application.NewService(svc))`. Every method becomes a TS function in `frontend/bindings/.../services/`. Frontend-facing details (TS signatures, push events, models) are in `frontend/WAILS-API.md`. After editing any `services/*.go` or the proto, regenerate with `wails3 generate bindings -clean=true -ts` (or `pnpm bindings` from `frontend/`). `frontend/bindings/**` is gitignored.
|
||||
|
||||
For frontend-side conventions (routing, providers, contexts) see `frontend/CLAUDE.md`.
|
||||
|
||||
## Services rundown
|
||||
|
||||
All services live in `services/` and assume a build tag `!android && !ios && !freebsd && !js`. Each takes a shared `DaemonConn` (`conn.go`) and is registered in `main.go`.
|
||||
|
||||
| Service | File | Responsibility |
|
||||
|---|---|---|
|
||||
| `Connection` | `connection.go` | `Login` / `WaitSSOLogin` / `Up` / `Down` / `Logout` / `OpenURL`. `Up` is always async (`Async: true`); status flows back through `Peers`. `Login` Down-resets the daemon first to dislodge a stale WaitSSOLogin. `OpenURL` honors `$BROWSER`. |
|
||||
| `Settings` | `settings.go` | `GetConfig` / `SetConfig` (partial update — pointer fields are sent, nil fields preserved) / `GetFeatures` (operator-disabled UI surfaces). |
|
||||
| `Profiles` | `profile.go` | `Username` / `List` / `GetActive` / `Switch` / `Add` / `Remove`. `List` populates `Email` from the **user-side** state file (`profilemanager.NewProfileManager().GetProfileState`) — the daemon runs as root and can't read it. |
|
||||
| `ProfileSwitcher` | `profileswitcher.go` | `SwitchActive` — the single entry point both tray and frontend should use for profile flips. Applies the reconnect policy (see "Profile switching" below), mirrors the daemon switch into the user-side `profilemanager`, drives optimistic feedback via `Peers.BeginProfileSwitch`. |
|
||||
| `Peers` | `peers.go` | Daemon status snapshot + two long-running streams (`SubscribeStatus` → `EventStatus`, `SubscribeEvents` → `EventSystem`). Emits synthetic `StatusDaemonUnavailable` when the socket is unreachable. Owns the profile-switch suppression filter (`BeginProfileSwitch` / `CancelProfileSwitch` / `shouldSuppress`). Fan-outs update metadata into dedicated `EventUpdateAvailable` / `EventUpdateProgress` events. |
|
||||
| `Networks` | `network.go` | `List` / `Select` / `Deselect` of routed networks. |
|
||||
| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. |
|
||||
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). |
|
||||
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
|
||||
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpiration(seconds)` / `CloseSessionExpiration` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenError(title, message)` / `CloseError` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
|
||||
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |
|
||||
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode, onboardingCompleted}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists; `SetOnboardingCompleted(bool)` persists the welcome-window dismissal. All broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
|
||||
| `Autostart` | `autostart.go` | Thin facade over Wails' `app.Autostart` (`*application.AutostartManager`). `Supported()` / `IsEnabled()` / `SetEnabled(bool)` — launch-the-UI-at-login toggle. The OS login-item registration (launchd/SMAppService on macOS, `HKCU\…\Run` on Windows, XDG `.desktop` on Linux) is the **single source of truth** — nothing is mirrored to the preferences file. `Enable` registers the running executable with no extra args (the app comes up hidden into the tray). Affects the **graphical UI only**, not the daemon/background service. `Supported()` is false on server/mobile builds (`ErrAutostartNotSupported`); the React toggle in `SettingsGeneral.tsx` hides itself when false. |
|
||||
|
||||
`DaemonConn` is defined in `services/conn.go`; `ptrStr` (string-to-*string helper for proto pointer fields) lives there too.
|
||||
|
||||
## Daemon proto
|
||||
- Proto source: `../proto/daemon.proto`. Generated Go in `../proto/*.pb.go`.
|
||||
- Regen: `cd ../proto && protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative daemon.proto`
|
||||
- Pinned versions (see `daemon.pb.go` header): `protoc v7.34.1`, `protoc-gen-go v1.36.6`. CI's `proto-version-check` workflow fails on mismatch.
|
||||
- After proto regen, also regen Wails bindings so the TS layer picks up new fields.
|
||||
|
||||
## Events bus
|
||||
|
||||
`main.go` registers five typed events for the frontend: `netbird:status` (`Status`), `netbird:event` (`SystemEvent`), `netbird:profile:changed` (`ProfileRef`), `netbird:update:available` (`UpdateAvailable`), `netbird:update:progress` (`UpdateProgress`). `netbird:profile:changed` fires from `ProfileSwitcher.SwitchActive` after a successful daemon-side switch — both the React `ProfileContext` and the tray subscribe so a flip driven from one surface paints in the others (the daemon itself does not emit a profile event). Plus three plain-string events:
|
||||
|
||||
- `EventTriggerLogin = "trigger-login"` — tray asking the frontend's `startLogin()` to begin an SSO flow. The tray does **not** show the main window when emitting — the hidden webview is alive and subscribed, so `startLogin` runs and the only visible surface is the BrowserLogin popup it opens.
|
||||
- `EventBrowserLoginCancel = "browser-login:cancel"` — the `BrowserLogin` window's Cancel button or red-X close. `startLogin()` listens and tears down the daemon's pending `WaitSSOLogin`.
|
||||
- `preferences.EventPreferencesChanged = "netbird:preferences:changed"` — emitted after every successful `SetLanguage` (payload `{language}`). Both the tray menu rebuild and the React `i18next.changeLanguage` subscribe so a flip from any window paints everywhere.
|
||||
- `EventSettingsOpen = "netbird:settings:open"` (payload: tab string, e.g. `"general"` / `"profiles"`) — emitted by `WindowManager.OpenSettings(tab)` to set the active tab before Go calls `Show`/`Focus`. The matching reset-to-General on close lives in the React side via `document.visibilitychange` (Wails events from the Go close hook race `Hide` and flash the previous tab for one frame).
|
||||
|
||||
Daemon connection status strings (`services/peers.go`) mirror `internal.Status*` in `client/internal/state.go`: `Connected`, `Connecting`, `Idle`, `NeedsLogin`, `LoginFailed`, `SessionExpired`, plus the synthetic `DaemonUnavailable` emitted by `Peers` when the socket is unreachable.
|
||||
|
||||
## Profile switching
|
||||
|
||||
`services/profileswitcher.go` is the single source of truth for the reconnect policy. Both the tray (`tray.go switchProfile`) and the frontend (via `modules/profiles/ProfileContext.tsx`'s `switchProfile`, which `modules/profiles/ProfilesTab.tsx` and the header `ProfileDropdown` go through) call `ProfileSwitcher.SwitchActive`; identical inputs give identical state transitions.
|
||||
|
||||
Reconnect policy (driven by `prevStatus` from `Peers.Get`):
|
||||
|
||||
| Previous status | Action | Optimistic UI | Suppressed events until new flow begins |
|
||||
|---|---|---|---|
|
||||
| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle |
|
||||
| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle |
|
||||
| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — |
|
||||
| Idle | Switch only | (no change) | — |
|
||||
|
||||
Only Connected/Connecting trigger `Peers.BeginProfileSwitch`. That:
|
||||
1. Sets a 30s `switchInProgress` guard.
|
||||
2. Emits a synthetic `Status{Status: StatusConnecting}` so both tray and React paint immediately.
|
||||
3. Tells `statusStreamLoop` to drop the daemon's stale Connected updates (peer count drops as the engine tears down) and the transient Idle in between Down and the new Up.
|
||||
|
||||
`shouldSuppress` releases the guard as soon as a status that signals the new flow began arrives:
|
||||
- **Suppressed**: Connected, Idle
|
||||
- **Pass through and clear**: Connecting / NeedsLogin / LoginFailed / SessionExpired / DaemonUnavailable
|
||||
- **Timeout fallback**: 30s elapsed → clear flag, emit normally.
|
||||
|
||||
`Peers.CancelProfileSwitch` aborts the suppression — called by `tray.go handleDisconnect` so the user's "Disconnect while Connecting" click paints through immediately.
|
||||
|
||||
Also: `ProfileSwitcher.SwitchActive` mirrors the daemon switch into the user-side `profilemanager` (`~/Library/Application Support/netbird/active_profile`). The CLI's `netbird up` reads this file and sends the resolved profile name back; if it diverges from the daemon's `/var/lib/netbird/active_profile.json`, the daemon silently flips back. Mirror failures don't abort the switch — surfaced as a warning.
|
||||
|
||||
## Auxiliary windows (`WindowManager`)
|
||||
|
||||
The main window is created up front in `main.go`. Auxiliary windows are created on demand by `services.WindowManager`:
|
||||
|
||||
- **Settings** (`/#/settings`) — opened from the header gear icon (`pages/main/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`ProfilesTab.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. **Unlike the other auxiliary windows**, Settings is created eagerly (hidden) inside `NewWindowManager` and hides on close instead of being destroyed — first open is instant. The window stays at a single URL (`/#/settings`) forever; `OpenSettings(tab)` does **not** call `SetURL`. Instead it emits `netbird:settings:open` with the target tab (empty → `"general"`), then calls `Show`/`Focus`. `SettingsPage` keeps the active tab in React local state and listens for the event to switch. **Reset-on-close lives in the React side**, not the Go close hook: `SettingsPage` listens for `document.visibilitychange` and resets the tab to General when the page goes hidden. Doing it via `Event.Emit` from the close hook didn't work — the dispatch goroutine races `Hide`, the JS listener often runs only after the *next* `Show`, and the user sees a one-frame flash of the previous tab. The Page Visibility API fires before WebKit throttles the page, so the state update lands while we're still in foreground JS. (The earlier `SetURL` path re-loaded the WKWebView entirely, re-mounting the `AppLayout` provider stack and visibly flashing the `SettingsSkeleton` while `SettingsContext` re-fetched config.)
|
||||
- **BrowserLogin** (`/#/dialog/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`pages/main/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes.
|
||||
- **SessionExpiration** (`/#/dialog/session-expiration?seconds=<n>`) — opened by `WindowManager.OpenSessionExpiration(seconds)`. 460×380, fixed size, `AlwaysOnTop: true`. The React-side buttons close the window via `WindowManager.CloseSessionExpiration` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Triggered by the tray today: `tray_session.go openSessionExpiration` fires it at T-FinalWarningLead when the earlier T-10 notification wasn't dismissed, and `openSessionExtendFlow` opens it on tray-row click seeded with the live remaining time. **Multi-monitor aware** — targets the display the OS cursor is currently on via `WindowManager.getScreenBasedOnCursorPosition`, which queries the native cursor location per-OS through `getCursorPosition` (`services/cursor_{darwin,windows,linux,other}.go`): `NSEvent.mouseLocation` flipped against the primary's frame height on macOS, `w32.GetCursorPos` + ScreenManager `PhysicalToDipPoint` on Windows, X11 `XQueryPointer` on Linux. The X11 query covers Wayland sessions too via XWayland, which ships by default on every supported Linux target. **Verified distro coverage**: Windows, macOS, Ubuntu 22.04 + 24.04 (GNOME-Wayland default + XWayland), Fedora 40 (GNOME-Wayland + XWayland), Debian 12 (GNOME default + XWayland), Arch Linux (any DE/compositor + XWayland), Linux Mint (Cinnamon-Xorg → Xorg direct), GNOME (Xorg + Wayland), Fluxbox (Xorg, exercised by the xembed-tray test path). Falls back gracefully (no panic, no error) to the main window's screen, then the OS default, when the cursor can't be resolved (headless / no DISPLAY / pure-Wayland-without-XWayland). Both first-create and re-show go through a single helper, `WindowManager.centerOnCursorScreen`: synchronous SetPosition first (covers full desktops and re-show with a still-alive GTK surface), then on minimal WMs (`recenterOnShow` — the Fluxbox/XEmbed-tray path) the same ~1s realize-detection retry loop `centerWhenReady` uses, because Wails' Linux SetPosition silently no-ops against a nil GdkSurface and Fluxbox would otherwise leave the window on the primary monitor.
|
||||
- **InstallProgress** (`/#/dialog/install-progress?version=<v>`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close).
|
||||
- **Welcome** (`/#/dialog/welcome`) — first-launch onboarding window opened by `WindowManager.OpenWelcome()` from `main.go`'s `ApplicationStarted` hook, gated by `prefStore.Get().OnboardingCompleted` so it only fires on a fresh install. Auto-sized via `useAutoSizeWindow`, centered (`InitialPosition: WindowCentered`), inherits `AlwaysOnTop` from `DialogWindowOptions`. Two-step state machine: **(1)** tray-screenshot pitch with the per-OS tray icon; **(2)** Cloud-vs-self-hosted segmented control with optional URL input — only rendered when `shouldShowManagementStep` returns true (default profile + no recorded email + management URL is empty/cloud-default). The Continue button on either terminal step flips `Preferences.SetOnboardingCompleted(true)`, calls `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`.
|
||||
|
||||
- **Error** (`/#/dialog/error?message=<m>`) — the app's single error surface, opened by `WindowManager.OpenError(title, message)`. **This replaced the native OS MessageBox outright**: the frontend `errorDialog({Title, Message})` wrapper in `lib/dialogs.ts` now drives this window (same name/signature as before, so call sites were untouched), and the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers plus the Windows `Detached` workaround were deleted (nothing called warning/info/question). Frameless NetBird chrome, `AlwaysOnTop` (inherited from `DialogWindowOptions`), auto-sized to the variable-length message via `useAutoSizeWindow`. **`title` is the window's chrome title** — set Go-side as `"NetBird - <title>"` (empty falls back to the localised "Error"), *not* shown in the body — so it's excluded from `retitleAll` (a language flip must not clobber the live error title). **`message` is the body text**, carried as a query param (`errorDialogURL` query-escapes it so newlines/`&` in formatted daemon errors survive into `useSearchParams`). The left-aligned body is just the danger `SquareIcon` + message + a bottom-right Close button. A second error while one is open updates the live window (`SetTitle` + `SetURL`) instead of stacking another. Singleton, destroyed on close. The Close button (and the Escape key — keyboard cancellation) calls `WindowManager.CloseError()`. Note the behaviour change vs the old native box: `errorDialog()` resolves as soon as the window opens (it no longer blocks until dismissed). **macOS caveat:** the window uses `MacTitleBarHiddenInset`, so the chrome title isn't visibly rendered there — on macOS the error name would not be shown anywhere since it's no longer in the body.
|
||||
|
||||
The four lazy auxiliary windows (BrowserLogin, SessionExpiration, InstallProgress, Error) are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm.
|
||||
|
||||
On macOS, `main.go` overrides Wails' default `applicationShouldHandleReopen` listener (which shows *every* hidden window — see `pkg/application/events_common_darwin.go`) by registering an application event hook that cancels the event and shows only the main window. Without this, clicking the dock icon would resurrect the hide-on-close Settings window alongside the main one.
|
||||
|
||||
The main window is **hidden** on close (the `WindowClosing` hook calls `e.Cancel(); window.Hide()`). The user reaches "really quit" through the tray → Quit menu entry.
|
||||
|
||||
## Localisation (i18n)
|
||||
|
||||
The locale tree under `client/ui/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). It sits next to the Go `i18n` package (the tray's consumer) so a single JSON tree drives both surfaces. Layout: `_index.json` lists shipped languages (`code` / `displayName` / `englishName`); `<code>/common.json` per language. `en/common.json` must exist (the `Bundle` loader hard-fails without it); languages listed in `_index.json` without a bundle are skipped with a warning. Placeholders are single-braced (`"Install version {version}"`) — Go substitutes via `Bundle.Translate(lang, key, "name", value, ...)`; React uses i18next with `interpolation: { prefix: "{", suffix: "}" }`.
|
||||
|
||||
Adding a language: drop a `<code>/common.json` under `client/ui/i18n/locales/`, append a row to `_index.json`, rebuild. Go reads the tree via `//go:embed all:i18n/locales` in `client/ui/main.go`; Vite reads it via the `../../../i18n/locales/*/common.json` glob in `frontend/src/lib/i18n.ts`, with `server.fs.allow` in `vite.config.ts` whitelisting the parent dir so the dev server can serve files outside `frontend/`.
|
||||
|
||||
Package layout:
|
||||
- `client/ui/i18n/` — pure `LanguageCode` / `Language` / `Bundle` loader. No Wails / no daemon. Reads the tree from an `fs.FS` passed in by `main.go`.
|
||||
- `client/ui/preferences/` — `Store` persists `UIPreferences{language}` to `os.UserConfigDir()/netbird/ui-preferences.json` (per-OS-user, shared across daemon profiles). Validates against an injected `LanguageValidator` (`*i18n.Bundle`). No file → in-memory default `en`, persisted on first `SetLanguage`. Broadcasts via in-process pub/sub + optional Wails event emitter.
|
||||
- `services/i18n.go` + `services/preferences.go` — Wails facades. Preferences emits `netbird:preferences:changed` (payload `{language}`) on every `SetLanguage`.
|
||||
|
||||
Key conventions: `tray.*` / `notify.*` (Go-side), `common.* / connect.* / nav.* / profile.* / settings.* / update.* / browserLogin.* / sessionExpiration.* / peers.*` (frontend). Keep keys stable — renames cascade everywhere.
|
||||
|
||||
## Linux tray support
|
||||
|
||||
The in-process `StatusNotifierWatcher` + XEmbed host that lets the tray work on minimal WMs is detailed in `LINUX-TRAY.md` (sibling). Touch that doc when modifying `tray_watcher_linux.go` / `xembed_host_linux.go` / `xembed_tray_linux.{c,h}`.
|
||||
|
||||
**Legacy `-tags gtk3` build:** Wails v3 defaults to GTK4/WebKitGTK 6.0; the legacy GTK3/WebKit2GTK 4.1 path (`-tags gtk3`, for Ubuntu 22.04 / Debian 12 / RHEL 9 / Fedora ≤39, removed upstream in Wails v3.1) is shipped as a second `netbird-ui` package built via `EXTRA_TAGS=gtk3` / a separate goreleaser lane. `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` are GTK4-only (`//go:build … && !gtk3`); on gtk3 builds `xembed_host_gtk3_linux.go` stubs them out (`xembedTrayAvailable()` → false), so the minimal-WM XEmbed fallback is **absent on gtk3** (tray still works on SNI-capable desktops). Keep the C files' `//go:build` constraints in sync with the Go file.
|
||||
|
||||
## Wails Dialogs (frontend, `@wailsio/runtime`)
|
||||
|
||||
The app no longer uses native `@wailsio/runtime` `Dialogs.*` message boxes — errors go through the custom Error window (see below), confirmations through the in-app `useConfirm()` modal. `WAILS-DIALOGS.md` (sibling) is retained only as reference for the native API surface and the Go-side frameless-window pattern, should a native file picker (`OpenFile`/`SaveFile`) ever be needed.
|
||||
|
||||
## Conventions in this codebase
|
||||
|
||||
### Errors → custom Error window
|
||||
|
||||
User-actionable operation failures (config save, profile switch, debug bundle, update, login, etc.) surface via the frontend `errorDialog({Title, Message})` helper in `frontend/src/lib/dialogs.ts`, which opens the custom always-on-top **Error** auxiliary window (`WindowManager.OpenError`, `/#/dialog/error` — see the Auxiliary windows section). Use an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong" (the window already shows a red error icon). The name `errorDialog` and its `{Title, Message}` shape are unchanged from when it wrapped the native `Dialogs.Error`, so call sites were untouched; the native `Dialogs.Error`/`Warning`/`Info`/`Question` wrappers and the Windows `Detached` workaround were removed (the native MessageBox could wedge the main window's close button — see the Error-window note). Confirmations use the in-app `useConfirm()` modal (`contexts/DialogContext.tsx`), which resolves to a boolean.
|
||||
|
||||
**Skip dialogs entirely** for: inline form validation (`Input.tsx`, URL-format checks — too heavy for keystroke feedback); transient link errors on the dashboard (flap in/out with daemon — use an inline indicator); "partial success" notes inside an otherwise-OK flow (e.g. "bundle saved but upload failed" stays inline). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no error dialog needed there.
|
||||
|
||||
### OS notifications
|
||||
|
||||
The tray uses Wails' built-in `notifications` service. One `notifications.NotificationService` is created in `main.go` and passed into `TrayServices.Notifier`. Notification IDs are prefixed for coalescing: `netbird-update-<version>`, `netbird-event-<id>`, `netbird-tray-error`, `netbird-session-expired`. Notifications are gated by the user's "Notifications" toggle (cached in `Tray.notificationsEnabled`, seeded from `Settings.GetConfig` at boot). `Severity == "critical"` events bypass the gate, mirroring the legacy Fyne `event.Manager`.
|
||||
|
||||
### Profile switching invariants
|
||||
|
||||
`ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` is the single TS wrapper, and `modules/profiles/ProfilesTab.tsx` + the header `ProfileDropdown` both go through it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above.
|
||||
|
||||
**Never call `Connection.Up` on an Idle/NeedsLogin daemon** — the daemon's internal 50s `waitForUp` blocks until `DeadlineExceeded`. `Connection.Up` from the frontend is reserved for the explicit Connect button (`ConnectionStatusSwitch.connect`) and the post-SSO resume inside `startLogin`; the gating for profile-switch reconnects lives Go-side in `ProfileSwitcher.SwitchActive`.
|
||||
|
||||
## Build / dev tasks
|
||||
|
||||
`task dev` (Wails dev, live reload), `task build` (prod build for the current OS, dispatches to `build/{darwin,linux,windows}/Taskfile.yml`), `task build:server` / `run:server` / `build:docker` / `run:docker` (server-mode variants in `build/Taskfile.yml`). **No** `task generate:bindings` alias — run `wails3 generate bindings -clean=true -ts` directly from this directory. CLI flags + log-target semantics are documented in the `main.go` bullet under "Layout".
|
||||
|
||||
Both `windows:build` and `windows:build:console` (the latter outputs `bin/netbird-ui-console.exe` linked against the console subsystem, so Go stdout/stderr/logrus print to the launching terminal) honour `DEV=true`, which drops the `-tags production` flag. The `production` tag is what disables the WebKit/WebView2 DevTools inspector — so `DEV=true` is the only way to get a Windows binary where the frontend JS console is reachable (right-click → Inspect / F12). Cross-compile from Linux with `CGO_ENABLED=1 task windows:build:console DEV=true`.
|
||||
|
||||
## Useful references
|
||||
- `WAILS-DIALOGS.md` (sibling) — full `@wailsio/runtime` `Dialogs` API + per-OS behaviour + frameless-window pattern.
|
||||
- `LINUX-TRAY.md` (sibling) — StatusNotifierWatcher + XEmbed host details.
|
||||
- `frontend/WAILS-API.md` — frontend-facing binding signatures and model shapes.
|
||||
- Wails v3 dialog docs: https://v3.wails.io/features/dialogs/message/ and https://v3.wails.io/features/dialogs/custom/ (may 403 from some clients).
|
||||
- Wails v3 multiple-windows guidance: https://v3.wails.io/learn/multiple-windows/
|
||||
- Authoritative TS signatures: `frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
||||
- Wails examples: https://github.com/wailsapp/wails/tree/master/v3/examples/dialogs
|
||||
@@ -1,12 +0,0 @@
|
||||
# Linux tray support (StatusNotifierWatcher + XEmbed)
|
||||
|
||||
Minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the AppIndicator extension) don't ship a `StatusNotifierWatcher`, so tray icons using libayatana-appindicator / freedesktop StatusNotifier silently fail. `main.go` calls `startStatusNotifierWatcher()` *before* `NewTray` so the Wails systray's `RegisterStatusNotifierItem` call hits the in-process watcher we control.
|
||||
|
||||
- `tray_watcher_linux.go` — owns `org.kde.StatusNotifierWatcher` on the session bus if no other process has it. Safe to call unconditionally.
|
||||
- `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click.
|
||||
|
||||
Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op.
|
||||
|
||||
## Legacy GTK3 build (`-tags gtk3`)
|
||||
|
||||
The XEmbed host (`xembed_host_linux.go` + `xembed_tray_linux.{c,h}`) hard-links GTK4 and uses GTK4-only popup-menu APIs (`GdkSurface`, `GtkEventControllerFocus`, `gtk_window_set_child`, `gdk_display_get_monitors`→`GListModel`, …), so it cannot compile against GTK3. On the legacy `-tags gtk3` build those files are excluded (`//go:build … && !gtk3`) and `xembed_host_gtk3_linux.go` provides a pure-Go stub where `xembedTrayAvailable()` returns false. The watcher probe then exits immediately, so the in-process XEmbed fallback is **absent on GTK3 builds** — the tray works only where the desktop ships its own `StatusNotifierWatcher` (KDE, GNOME+AppIndicator, Cinnamon/xapp, XFCE), not on minimal WMs. Rather than port the ~150-line C menu layer to GTK3 we accept this gap; `-tags gtk3` is removed upstream in Wails v3.1.
|
||||
BIN
client/ui/Netbird.icns
Normal file
BIN
client/ui/Netbird.icns
Normal file
Binary file not shown.
@@ -1,114 +0,0 @@
|
||||
# NetBird desktop UI (Wails3 + React)
|
||||
|
||||
Replaces `client/ui` (Fyne). One binary on Windows / macOS / Linux,
|
||||
talks to the NetBird daemon over gRPC, renders a React frontend in a
|
||||
WebView.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go ≥ 1.25, Node ≥ 20, **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`)
|
||||
- `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
|
||||
- `task`: `go install github.com/go-task/task/v3/cmd/task@latest`
|
||||
- A running NetBird daemon (default: `unix:///var/run/netbird.sock`,
|
||||
Windows `tcp://127.0.0.1:41731`)
|
||||
- Linux only: `libwebkitgtk-6.0-dev`, `libgtk-4-dev`, `libsoup-3.0-dev`
|
||||
|
||||
### Legacy GTK3 build
|
||||
|
||||
Wails v3 builds on GTK4 / WebKitGTK 6.0 by default. Distros that don't ship
|
||||
WebKitGTK 6.0 yet (Ubuntu 22.04, Debian 12, RHEL 9, Fedora ≤ 39) need the
|
||||
legacy GTK3 / WebKit2GTK 4.1 build, produced with `-tags gtk3` (e.g.
|
||||
`task build EXTRA_TAGS=gtk3`). It needs `libgtk-3-dev` + `libwebkit2gtk-4.1-dev`
|
||||
instead of the GTK4 libs above. `-tags gtk3` is removed upstream in Wails v3.1.
|
||||
|
||||
> **Tray limitation:** the GTK3 build drops the in-process XEmbed
|
||||
> `StatusNotifierWatcher` (its menu layer is GTK4-only — see
|
||||
> [`LINUX-TRAY.md`](LINUX-TRAY.md) and `xembed_host_gtk3_linux.go`). The tray
|
||||
> still works on desktops that ship their own watcher (KDE, GNOME+AppIndicator,
|
||||
> Cinnamon/xapp, XFCE, …); only the minimal-WM fallback (Fluxbox/OpenBox/i3/dwm)
|
||||
> is unavailable on GTK3 packages.
|
||||
|
||||
## Develop without rebuilding
|
||||
|
||||
```bash
|
||||
cd client/ui
|
||||
task dev
|
||||
```
|
||||
|
||||
`task dev` runs Vite (port 9245) + the Go binary + a `*.go` watcher.
|
||||
Frontend edits hot-reload instantly. Go edits trigger a rebuild and
|
||||
relaunch. Pass daemon flags after `--`:
|
||||
|
||||
```bash
|
||||
task dev -- --daemon-addr=tcp://127.0.0.1:41731
|
||||
```
|
||||
|
||||
For pure UI work (no native window, fastest loop):
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm dev
|
||||
```
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
task build
|
||||
```
|
||||
|
||||
Output in `bin/`. Frontend assets are embedded into the binary.
|
||||
|
||||
### Cross-compile Windows from Linux
|
||||
|
||||
Install the mingw-w64 toolchain once:
|
||||
|
||||
```bash
|
||||
sudo apt install gcc-mingw-w64-x86-64 # Debian/Ubuntu
|
||||
sudo dnf install mingw64-gcc # Fedora
|
||||
sudo pacman -S mingw-w64-gcc # Arch
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=1 task windows:build
|
||||
```
|
||||
|
||||
Produces `bin/netbird-ui.exe`. macOS cross-compile from Linux is not
|
||||
supported (signing and notarization need a real Mac).
|
||||
|
||||
### Windows console build (logs in the terminal)
|
||||
|
||||
Default `windows:build` links the binary as a Windows GUI app, which
|
||||
detaches from the launching console — `logrus` output, `fmt.Println`,
|
||||
and panics go nowhere visible. To debug tray/event/daemon issues:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=1 task windows:build:console
|
||||
```
|
||||
|
||||
Produces `bin/netbird-ui-console.exe`. Run it from `cmd.exe` /
|
||||
PowerShell / Windows Terminal and stdout/stderr land in that
|
||||
terminal. Same flag works on a native Windows build (drop the
|
||||
`CGO_ENABLED=1` if your toolchain already has it set).
|
||||
|
||||
## Regenerating bindings
|
||||
|
||||
When a Go service signature changes:
|
||||
|
||||
```bash
|
||||
wails3 generate bindings
|
||||
```
|
||||
|
||||
`task dev` does this automatically on `*.go` save.
|
||||
|
||||
## Tray icons
|
||||
|
||||
Source SVGs live in `assets/svg/` (state.svg + state-macos.svg). After editing
|
||||
any SVG, rasterize to the PNGs the Go side embeds:
|
||||
|
||||
```bash
|
||||
task common:generate:tray:icons
|
||||
```
|
||||
|
||||
Requires Inkscape. Commit the resulting `assets/*.png` files alongside the
|
||||
SVG change so CI doesn't need Inkscape installed.
|
||||
@@ -1,58 +0,0 @@
|
||||
version: '3'
|
||||
|
||||
includes:
|
||||
common: ./build/Taskfile.yml
|
||||
windows: ./build/windows/Taskfile.yml
|
||||
darwin: ./build/darwin/Taskfile.yml
|
||||
linux: ./build/linux/Taskfile.yml
|
||||
|
||||
vars:
|
||||
APP_NAME: "netbird-ui"
|
||||
BIN_DIR: "bin"
|
||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: Builds the application
|
||||
cmds:
|
||||
- task: "{{OS}}:build"
|
||||
|
||||
package:
|
||||
summary: Packages a production build of the application
|
||||
cmds:
|
||||
- task: "{{OS}}:package"
|
||||
|
||||
run:
|
||||
summary: Runs the application
|
||||
cmds:
|
||||
- task: "{{OS}}:run"
|
||||
|
||||
dev:
|
||||
summary: Runs the application in development mode
|
||||
cmds:
|
||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||
|
||||
setup:docker:
|
||||
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||
cmds:
|
||||
- task: common:setup:docker
|
||||
|
||||
build:server:
|
||||
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||
cmds:
|
||||
- task: common:build:server
|
||||
|
||||
run:server:
|
||||
summary: Runs the application in server mode
|
||||
cmds:
|
||||
- task: common:run:server
|
||||
|
||||
build:docker:
|
||||
summary: Builds a Docker image for server mode deployment
|
||||
cmds:
|
||||
- task: common:build:docker
|
||||
|
||||
run:docker:
|
||||
summary: Builds and runs the Docker image
|
||||
cmds:
|
||||
- task: common:run:docker
|
||||
@@ -1,56 +0,0 @@
|
||||
# Wails Dialogs (frontend, `@wailsio/runtime`)
|
||||
|
||||
The frontend dialog API lives in `@wailsio/runtime` as `Dialogs`. Authoritative signatures are in
|
||||
`frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
||||
|
||||
See `CLAUDE.md` for project conventions on *when* to use these (errors vs. inline validation, confirmation flow, etc.).
|
||||
|
||||
## Message dialogs
|
||||
|
||||
```ts
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
|
||||
await Dialogs.Info({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Warning({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Error({ Title, Message, Buttons?, Detached? });
|
||||
await Dialogs.Question({ Title, Message, Buttons?, Detached? });
|
||||
```
|
||||
|
||||
All four return `Promise<string>` resolving to the **Label** of the button the user clicked. With no `Buttons` provided you get a single OK button — the promise just resolves when the user dismisses.
|
||||
|
||||
`MessageDialogOptions` fields:
|
||||
- `Title?: string` — window title (short).
|
||||
- `Message?: string` — the body text.
|
||||
- `Buttons?: Button[]` — custom buttons. Each `Button` is `{ Label?, IsCancel?, IsDefault? }`. `IsCancel` is what Esc/⌘. triggers; `IsDefault` is what Enter triggers.
|
||||
- `Detached?: boolean` — when `true`, the dialog isn't tied to the parent window (no sheet behavior on macOS).
|
||||
|
||||
## File dialogs
|
||||
|
||||
`Dialogs.OpenFile(options)` and `Dialogs.SaveFile(options)` — see `dialogs.d.ts` for the full `OpenFileDialogOptions` / `SaveFileDialogOptions` field set (filters, ButtonText, multi-select, hidden files, alias resolution, directory mode, etc).
|
||||
|
||||
## Per-OS behavior
|
||||
|
||||
| Platform | Behavior |
|
||||
|---|---|
|
||||
| **macOS** | Sheet-style when attached to a parent window. Up to ~4 custom buttons render naturally. Keyboard: Enter = default, ⌘. or Esc = cancel. Follows system theme. Accessibility is built-in. |
|
||||
| **Windows** | Modal `TaskDialog`-style. Standard button labels are nudged toward OS conventions. Keyboard: Enter = default, Esc = cancel. Follows system theme. |
|
||||
| **Linux** | GTK dialogs — appearance varies by desktop environment (GNOME/KDE). Follows desktop theme. Standard keyboard nav. |
|
||||
|
||||
Behavioural notes that affect us:
|
||||
- The promise resolves with the **button label string**, not an index. Compare against the literal `Label` you passed (e.g. `if (result !== "Delete") return;`).
|
||||
- `Buttons[]` on Linux/Windows uses the labels you supply, but the OS layout/styling is fixed.
|
||||
- `Dialogs.Error` plays the platform error sound and uses the platform error icon. Don't use it for confirmations — use `Dialogs.Warning` or `Dialogs.Question`.
|
||||
- Don't fire dialogs in a tight loop or from every keystroke — they interrupt focus and (on macOS) animate in/out. Debounce or guard with a `busy` flag.
|
||||
|
||||
## Frameless / custom-window dialogs (Go side)
|
||||
|
||||
When the native dialog API isn't enough — rich content, embedded webview, multi-screen flow — open a regular Wails window. This is done on the **Go side** via `app.Window.NewWithOptions(application.WebviewWindowOptions{...})`. Useful options:
|
||||
- `Parent` — attach to a parent so OS treats it as a child.
|
||||
- `AlwaysOnTop: true` — float above the parent.
|
||||
- `Frameless: true` — no titlebar/chrome.
|
||||
- `Resizable: false` (also `DisableResize: true` in v3) — fixed-size dialog feel.
|
||||
- `Hidden: true` initially, then `dialog.Show()` + `dialog.SetFocus()`.
|
||||
|
||||
We **do** use this pattern, but pragmatically: `WindowManager.OpenSettings` and `OpenBrowserLogin` are regular small webview windows (not modal sheets) with no resize, hidden minimise/maximise buttons, and a translucent macOS title bar. They're not classic "OS modal dialogs"; they're just lightweight ancillary windows that look the part. Modal behaviour (`parent.SetEnabled(false)`) is intentionally not used — the user can still click back to the main window.
|
||||
|
||||
In-app modals (`NewProfileDialog`, delete-profile confirmation, etc.) are Radix `Dialog` primitives inside the main webview. Reach for a custom OS window only when content must escape the main window (BrowserLogin is the canonical example — its lifecycle is tied to the SSO wait) or when the window needs its own taskbar entry / dock icon.
|
||||
259
client/ui/approval.go
Normal file
259
client/ui/approval.go
Normal file
@@ -0,0 +1,259 @@
|
||||
//go:build !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/approval"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// Approval metadata that is remote-peer or dashboard controlled is passed to
|
||||
// the forked netbird-ui via environment variables rather than argv, so it is
|
||||
// not exposed to other local users through ps.
|
||||
const (
|
||||
envApprovalInitiator = "NB_APPROVAL_INITIATOR"
|
||||
envApprovalPeerName = "NB_APPROVAL_PEER_NAME"
|
||||
envApprovalSourceIP = "NB_APPROVAL_SOURCE_IP"
|
||||
envApprovalUsername = "NB_APPROVAL_USERNAME"
|
||||
envApprovalKeyFingerprint = "NB_APPROVAL_KEY_FINGERPRINT"
|
||||
envApprovalSubject = "NB_APPROVAL_SUBJECT"
|
||||
)
|
||||
|
||||
// handleApprovalEvent forks a netbird-ui child process to render the
|
||||
// dialog on its own fyne main loop. Top-level windows opened from a
|
||||
// background goroutine of the tray process don't render reliably on
|
||||
// Linux/GTK, so the rest of the UI (settings, login URL, update) uses
|
||||
// the same fork pattern.
|
||||
func (s *serviceClient) handleApprovalEvent(ev *proto.SystemEvent) {
|
||||
if ev == nil || ev.Category != proto.SystemEvent_APPROVAL {
|
||||
return
|
||||
}
|
||||
requestID := ev.Metadata["request_id"]
|
||||
if requestID == "" {
|
||||
log.Warnf("approval event missing request_id: %v", ev.Metadata)
|
||||
return
|
||||
}
|
||||
|
||||
// Only the request id, kind, and deadline stay on argv: they are
|
||||
// daemon-issued and non-sensitive. The remote-influenced fields go
|
||||
// through the child's environment.
|
||||
args := []string{
|
||||
"--approval-request-id=" + requestID,
|
||||
"--approval-kind=" + ev.Metadata["kind"],
|
||||
"--approval-expires-at=" + ev.Metadata["expires_at"],
|
||||
}
|
||||
env := append(os.Environ(),
|
||||
envApprovalInitiator+"="+ev.Metadata["initiator"],
|
||||
envApprovalPeerName+"="+ev.Metadata["peer_name"],
|
||||
envApprovalSourceIP+"="+ev.Metadata["source_ip"],
|
||||
envApprovalUsername+"="+ev.Metadata["username"],
|
||||
envApprovalKeyFingerprint+"="+ev.Metadata["peer_pubkey"],
|
||||
envApprovalSubject+"="+ev.UserMessage,
|
||||
)
|
||||
go s.runApprovalCommand(s.ctx, env, args)
|
||||
}
|
||||
|
||||
// runApprovalCommand forks netbird-ui to render the approval dialog,
|
||||
// inheriting the parent environment plus the approval-specific variables. It
|
||||
// mirrors runSelfCommand but sets cmd.Env so the sensitive metadata never
|
||||
// appears on the child's argv.
|
||||
func (s *serviceClient) runApprovalCommand(ctx context.Context, env, args []string) {
|
||||
proc, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Errorf("get executable path: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
cmdArgs := append([]string{"--approval=true", "--daemon-addr=" + s.addr}, args...)
|
||||
cmd := exec.CommandContext(ctx, proc, cmdArgs...)
|
||||
cmd.Env = env
|
||||
|
||||
if out := s.attachOutput(cmd); out != nil {
|
||||
defer func() {
|
||||
if err := out.Close(); err != nil {
|
||||
log.Errorf("close log file %s: %v", s.logFile, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
log.Printf("running approval command: %s", cmd.String())
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
log.Printf("approval command failed with exit code %d", exitErr.ExitCode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// showApprovalUI runs the dialog on the forked process's fyne main loop
|
||||
// and forwards the user's decision to the daemon via RespondApproval.
|
||||
func (s *serviceClient) showApprovalUI(req approvalRequest) {
|
||||
w := s.app.NewWindow(approvalTitle(req.kind))
|
||||
w.Resize(fyne.NewSize(480, 260))
|
||||
w.CenterOnScreen()
|
||||
w.RequestFocus()
|
||||
|
||||
var rows []string
|
||||
if req.initiator != "" {
|
||||
// The display name comes from the management dashboard and is
|
||||
// not cryptographically asserted by the connecting client. The
|
||||
// key fingerprint that follows IS: it's the Noise_IK static
|
||||
// public key the client just proved possession of. Show both
|
||||
// so the user can sanity-check that "Alice" is really the
|
||||
// Alice they trust.
|
||||
rows = append(rows, "From user: "+req.initiator)
|
||||
}
|
||||
if fp := approval.ShortKeyFingerprint(req.keyFingerprint); fp != "" {
|
||||
rows = append(rows, "Key fp: "+fp)
|
||||
}
|
||||
if req.peerName != "" {
|
||||
rows = append(rows, "Via peer: "+req.peerName)
|
||||
}
|
||||
if req.sourceIP != "" && req.sourceIP != req.peerName {
|
||||
rows = append(rows, "Source IP: "+req.sourceIP)
|
||||
}
|
||||
if req.username != "" {
|
||||
rows = append(rows, "OS user: "+req.username)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
rows = []string{"Remote: " + req.displayPeer()}
|
||||
}
|
||||
body := strings.Join(rows, "\n")
|
||||
bodyLabel := widget.NewLabel(body)
|
||||
bodyLabel.Wrapping = fyne.TextWrapWord
|
||||
|
||||
countdown := widget.NewLabel("")
|
||||
deadline := req.deadline()
|
||||
updateCountdown := func() {
|
||||
remaining := time.Until(deadline).Round(time.Second)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
countdown.SetText(fmt.Sprintf("Auto-deny in %s", remaining))
|
||||
}
|
||||
updateCountdown()
|
||||
|
||||
type outcome struct {
|
||||
accept bool
|
||||
viewOnly bool
|
||||
}
|
||||
decided := make(chan outcome, 1)
|
||||
decide := func(o outcome) {
|
||||
select {
|
||||
case decided <- o:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
allow := widget.NewButton("Allow", func() { decide(outcome{accept: true}) })
|
||||
allow.Importance = widget.HighImportance
|
||||
allowView := widget.NewButton("Allow (view only)", func() { decide(outcome{accept: true, viewOnly: true}) })
|
||||
deny := widget.NewButton("Deny", func() { decide(outcome{accept: false}) })
|
||||
|
||||
header := widget.NewLabelWithStyle(req.subject, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
buttonRow := container.NewGridWithColumns(3, allow, allowView, deny)
|
||||
info := container.NewVBox(header, widget.NewSeparator(), bodyLabel, widget.NewSeparator(), countdown)
|
||||
w.SetContent(container.NewPadded(container.NewBorder(nil, buttonRow, nil, nil, info)))
|
||||
w.SetCloseIntercept(func() { decide(outcome{}) })
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if time.Until(deadline) <= 0 {
|
||||
decide(outcome{})
|
||||
return
|
||||
}
|
||||
fyne.Do(updateCountdown)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
o := <-decided
|
||||
s.sendApprovalResponse(req.requestID, o.accept, o.viewOnly)
|
||||
fyne.Do(func() {
|
||||
w.Close()
|
||||
s.app.Quit()
|
||||
})
|
||||
}()
|
||||
|
||||
w.Show()
|
||||
}
|
||||
|
||||
func (s *serviceClient) sendApprovalResponse(requestID string, accept, viewOnly bool) {
|
||||
conn, err := s.getSrvClient(defaultFailTimeout)
|
||||
if err != nil {
|
||||
log.Warnf("approval response: get daemon client: %v", err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout)
|
||||
defer cancel()
|
||||
if _, err := conn.RespondApproval(ctx, &proto.RespondApprovalRequest{
|
||||
RequestId: requestID,
|
||||
Accept: accept,
|
||||
ViewOnly: viewOnly,
|
||||
}); err != nil {
|
||||
log.Warnf("approval response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// approvalRequest is the parsed --approval-* CLI args that the forked
|
||||
// dialog process consumes.
|
||||
type approvalRequest struct {
|
||||
requestID string
|
||||
kind string
|
||||
initiator string
|
||||
peerName string
|
||||
sourceIP string
|
||||
username string
|
||||
subject string
|
||||
expiresAt string
|
||||
keyFingerprint string
|
||||
}
|
||||
|
||||
func (r approvalRequest) displayPeer() string {
|
||||
switch {
|
||||
case r.initiator != "":
|
||||
return r.initiator
|
||||
case r.peerName != "":
|
||||
return r.peerName
|
||||
case r.sourceIP != "":
|
||||
return r.sourceIP
|
||||
default:
|
||||
return "unknown peer"
|
||||
}
|
||||
}
|
||||
|
||||
// deadline returns the wall-clock auto-deny moment. Falls back to a short
|
||||
// local window when the daemon's expires_at is missing/unparsable, so a
|
||||
// stale value never leaves the dialog open indefinitely.
|
||||
func (r approvalRequest) deadline() time.Time {
|
||||
if t, err := time.Parse(time.RFC3339, r.expiresAt); err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Now().Add(13 * time.Second)
|
||||
}
|
||||
|
||||
func approvalTitle(kind string) string {
|
||||
switch kind {
|
||||
case "vnc":
|
||||
return "Allow VNC Connection?"
|
||||
case "ssh":
|
||||
return "Allow SSH Connection?"
|
||||
default:
|
||||
return "Allow Incoming Connection?"
|
||||
}
|
||||
}
|
||||
BIN
client/ui/assets/connected.png
Normal file
BIN
client/ui/assets/connected.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
client/ui/assets/disconnected.png
Normal file
BIN
client/ui/assets/disconnected.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
client/ui/assets/netbird-disconnected.ico
Normal file
BIN
client/ui/assets/netbird-disconnected.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user