Compare commits
143 Commits
embedded-v
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef6b4f7538 | ||
|
|
2aea1f7bb5 | ||
|
|
620233a7ac | ||
|
|
1c15e9976b | ||
|
|
f04e2bada8 | ||
|
|
1d88faf66f | ||
|
|
84093af1f0 | ||
|
|
34a4744565 | ||
|
|
b79b62bee4 | ||
|
|
bec4eb326a | ||
|
|
8748f3810d | ||
|
|
1c5254cb31 | ||
|
|
3f8cd29006 | ||
|
|
ca48de549e | ||
|
|
5b71a4f2ad | ||
|
|
741ce8581d | ||
|
|
6b44d65cac | ||
|
|
f84b1df857 | ||
|
|
c24349e4f1 | ||
|
|
7f7bee630f | ||
|
|
4e0eb9f2d4 | ||
|
|
38a367e0cd | ||
|
|
78fb15e327 | ||
|
|
35e58a2796 | ||
|
|
a6278936af | ||
|
|
32f62f3ed8 | ||
|
|
7fae703a27 | ||
|
|
f468f15a30 | ||
|
|
5bdccfe8f4 | ||
|
|
cccb0e9230 | ||
|
|
9d8eb76746 | ||
|
|
1ebb507cbb | ||
|
|
5411fa4350 | ||
|
|
17cae1a75c | ||
|
|
c0b0eeb6ab | ||
|
|
d32721d7fc | ||
|
|
288f8dec08 | ||
|
|
db8c9a0e30 | ||
|
|
505fcc7f7a | ||
|
|
0fe8764707 | ||
|
|
c0e7c61c4b | ||
|
|
e4eedbe18f | ||
|
|
fc1db63fc3 | ||
|
|
d841a6aa07 | ||
|
|
258e7ec038 | ||
|
|
1932b76f5b | ||
|
|
d33b841a33 | ||
|
|
df1935da6d | ||
|
|
eb6be5a2f3 | ||
|
|
209f14fc2f | ||
|
|
2bd56ecf67 | ||
|
|
67988c2407 | ||
|
|
53b2fb8dc1 | ||
|
|
803144e569 | ||
|
|
c0cd88a3d0 | ||
|
|
6c9b821bf0 | ||
|
|
83030dbbd6 | ||
|
|
1c8a6e3798 | ||
|
|
74ea03da9b | ||
|
|
77fdf23a50 | ||
|
|
1f4ed5c8ef | ||
|
|
e1bf362675 | ||
|
|
af40ee52f8 | ||
|
|
4988f2aa68 | ||
|
|
e3efaa5e59 | ||
|
|
100d25a062 | ||
|
|
04b4330393 | ||
|
|
c8e18585c6 | ||
|
|
1931a2c8a8 | ||
|
|
108d43e702 | ||
|
|
842ef0d657 | ||
|
|
439f44c6b4 | ||
|
|
b5a970155b | ||
|
|
686e0d97f2 | ||
|
|
0c287b6f4d | ||
|
|
f7f5946910 | ||
|
|
7a9f5a734f | ||
|
|
1aae067aaa | ||
|
|
28a7eba756 | ||
|
|
8841b950a2 | ||
|
|
0c2702c0d7 | ||
|
|
b43a09a1c7 | ||
|
|
595dfbb6f1 | ||
|
|
7f560df9be | ||
|
|
09052949a2 | ||
|
|
9aef31ff53 | ||
|
|
08f52f4517 | ||
|
|
18e3b5dd32 | ||
|
|
f3f9704c6f | ||
|
|
4c3d4effbd | ||
|
|
3953fee5a4 | ||
|
|
adeaa49cda | ||
|
|
2c5d52a1bf | ||
|
|
70a755fbae | ||
|
|
559da5d5b9 | ||
|
|
614ee11ac7 | ||
|
|
85080afa59 | ||
|
|
a5cc8da054 | ||
|
|
a4fd5a78b4 | ||
|
|
062a183e4e | ||
|
|
a2be41caf8 | ||
|
|
5b70989e3e | ||
|
|
d324a5ff48 | ||
|
|
debb558aa3 | ||
|
|
cce80f8276 | ||
|
|
05ee4e52b8 | ||
|
|
bb2bf673a0 | ||
|
|
91c745e5e8 | ||
|
|
68c38247f1 | ||
|
|
8b8f38de1b | ||
|
|
2b272e74c8 | ||
|
|
e6cbf30415 | ||
|
|
490b60ad0e | ||
|
|
553be144b4 | ||
|
|
c3f9514182 | ||
|
|
a8812d5fb1 | ||
|
|
6f93cf6ac3 | ||
|
|
18909390c2 | ||
|
|
b3eb5f2453 | ||
|
|
dc02542a9e | ||
|
|
0c136fffb9 | ||
|
|
fffb9dd219 | ||
|
|
93275f9052 | ||
|
|
dd9c15072f | ||
|
|
4c743bc03d | ||
|
|
2e61b42e92 | ||
|
|
3f8de2a149 | ||
|
|
bc609c3ae7 | ||
|
|
e3994d0c99 | ||
|
|
ba6e10cef3 | ||
|
|
ce53981b55 | ||
|
|
a69037630b | ||
|
|
df58935cc0 | ||
|
|
a1743dbf9b | ||
|
|
f9771de3f5 | ||
|
|
bfe19fa542 | ||
|
|
d07f25fc49 | ||
|
|
670b0f66ac | ||
|
|
15d73a2edd | ||
|
|
88a2bf582d | ||
|
|
0148d926d5 | ||
|
|
8f16a19b8f | ||
|
|
504dceedf3 |
1
.github/pull_request_template.md
vendored
@@ -12,7 +12,6 @@
|
|||||||
- [ ] Is a feature enhancement
|
- [ ] Is a feature enhancement
|
||||||
- [ ] It is a refactor
|
- [ ] It is a refactor
|
||||||
- [ ] Created tests that fail without the change (if possible)
|
- [ ] Created tests that fail without the change (if possible)
|
||||||
- [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
|
|
||||||
|
|
||||||
> By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
|
> By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
|
||||||
|
|
||||||
|
|||||||
10
.github/workflows/golang-test-darwin.yml
vendored
@@ -43,5 +43,13 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -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)
|
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||||
|
# which fails to compile until the frontend has been built. The Wails UI
|
||||||
|
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||||
|
# before goreleaser.
|
||||||
|
# `go list -e` lets the listing succeed even though the embed fails to
|
||||||
|
# resolve; the grep then drops the broken package by path. Without -e,
|
||||||
|
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||||
|
# root, which has no Go files.
|
||||||
|
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
|
||||||
|
|
||||||
|
|||||||
16
.github/workflows/golang-test-linux.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||||
|
|
||||||
- name: Install 32-bit libpcap
|
- name: Install 32-bit libpcap
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
@@ -141,7 +141,7 @@ jobs:
|
|||||||
${{ runner.os }}-gotest-cache-
|
${{ runner.os }}-gotest-cache-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||||
|
|
||||||
- name: Install 32-bit libpcap
|
- name: Install 32-bit libpcap
|
||||||
if: matrix.arch == '386'
|
if: matrix.arch == '386'
|
||||||
@@ -154,7 +154,15 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||||
|
# which fails to compile until the frontend has been built. The Wails UI
|
||||||
|
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||||
|
# before goreleaser.
|
||||||
|
# `go list -e` lets the listing succeed even though the embed fails to
|
||||||
|
# resolve; the grep then drops the broken package by path. Without -e,
|
||||||
|
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||||
|
# root, which has no Go files.
|
||||||
|
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -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)
|
||||||
|
|
||||||
test_client_on_docker:
|
test_client_on_docker:
|
||||||
name: "Client (Docker) / Unit"
|
name: "Client (Docker) / Unit"
|
||||||
@@ -214,7 +222,7 @@ jobs:
|
|||||||
sh -c ' \
|
sh -c ' \
|
||||||
apk update; apk add --no-cache \
|
apk update; apk add --no-cache \
|
||||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
||||||
go test -buildvcs=false -tags devcert -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)
|
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)
|
||||||
'
|
'
|
||||||
|
|
||||||
test_relay:
|
test_relay:
|
||||||
|
|||||||
9
.github/workflows/golang-test-windows.yml
vendored
@@ -64,8 +64,15 @@ jobs:
|
|||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
||||||
- name: Generate test script
|
- name: Generate test script
|
||||||
|
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||||
|
# which fails to compile until the frontend has been built. The Wails UI
|
||||||
|
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||||
|
# before goreleaser.
|
||||||
|
# `go list -e` lets the listing succeed even though the embed fails to
|
||||||
|
# resolve; the Where-Object pipeline then drops the broken package by
|
||||||
|
# path. Without -e, go list aborts with empty stdout.
|
||||||
run: |
|
run: |
|
||||||
$packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
|
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' }
|
||||||
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
||||||
$cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
$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
|
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
||||||
|
|||||||
13
.github/workflows/golangci-lint.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
uses: codespell-project/actions-codespell@v2
|
uses: codespell-project/actions-codespell@v2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
||||||
skip: go.mod,go.sum,**/proxy/web/**
|
skip: go.mod,go.sum,**/proxy/web/**,**/pnpm-lock.yaml,**/package-lock.json
|
||||||
golangci:
|
golangci:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -50,7 +50,16 @@ jobs:
|
|||||||
cache: false
|
cache: false
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
|
- name: Stub Wails frontend bundle
|
||||||
|
# client/ui/main.go has //go:embed all:frontend/dist. The
|
||||||
|
# directory is produced by `pnpm run build` and is gitignored, so
|
||||||
|
# lint-only runs (no frontend toolchain) need a placeholder file
|
||||||
|
# for the embed pattern to match.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p client/ui/frontend/dist
|
||||||
|
touch client/ui/frontend/dist/.embed-placeholder
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
80
.github/workflows/release.yml
vendored
@@ -186,9 +186,9 @@ jobs:
|
|||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||||
- name: Generate windows syso amd64
|
- name: Generate windows syso amd64
|
||||||
run: goversioninfo -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v4
|
uses: goreleaser/goreleaser-action@v4
|
||||||
@@ -349,8 +349,18 @@ jobs:
|
|||||||
- name: check git status
|
- name: check git status
|
||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
- name: Decode GPG signing key
|
- name: Decode GPG signing key
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
@@ -369,10 +379,16 @@ jobs:
|
|||||||
echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
|
echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
|
||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||||
|
- name: Install wails3 CLI
|
||||||
|
# Version derived from go.mod so the binding generator always matches
|
||||||
|
# the wails runtime the binary links against.
|
||||||
|
run: |
|
||||||
|
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||||
|
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||||
- name: Generate windows syso amd64
|
- name: Generate windows syso amd64
|
||||||
run: goversioninfo -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso
|
run: goversioninfo -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v4
|
uses: goreleaser/goreleaser-action@v4
|
||||||
@@ -439,6 +455,20 @@ jobs:
|
|||||||
run: go mod tidy
|
run: go mod tidy
|
||||||
- name: check git status
|
- name: check git status
|
||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
- name: Install wails3 CLI
|
||||||
|
# Version derived from go.mod so the binding generator always matches
|
||||||
|
# the wails runtime the binary links against.
|
||||||
|
run: |
|
||||||
|
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||||
|
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v4
|
uses: goreleaser/goreleaser-action@v4
|
||||||
@@ -528,24 +558,6 @@ jobs:
|
|||||||
- name: Move wintun.dll into dist
|
- name: Move wintun.dll into dist
|
||||||
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||||
|
|
||||||
- name: Download Mesa3D (amd64 only)
|
|
||||||
uses: carlosperate/download-file-action@v2
|
|
||||||
id: download-mesa3d
|
|
||||||
if: matrix.arch == 'amd64'
|
|
||||||
with:
|
|
||||||
file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z
|
|
||||||
file-name: mesa3d.7z
|
|
||||||
location: ${{ env.downloadPath }}
|
|
||||||
sha256: '71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9'
|
|
||||||
|
|
||||||
- name: Extract Mesa3D driver (amd64 only)
|
|
||||||
if: matrix.arch == 'amd64'
|
|
||||||
run: 7z x -o"${{ env.downloadPath }}" "${{ env.downloadPath }}/mesa3d.7z"
|
|
||||||
|
|
||||||
- name: Move opengl32.dll into dist (amd64 only)
|
|
||||||
if: matrix.arch == 'amd64'
|
|
||||||
run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
|
||||||
|
|
||||||
- name: Download EnVar plugin for NSIS
|
- name: Download EnVar plugin for NSIS
|
||||||
uses: carlosperate/download-file-action@v2
|
uses: carlosperate/download-file-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -568,6 +580,28 @@ jobs:
|
|||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
||||||
|
|
||||||
|
- name: Set up Go for wails3 CLI
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: "go.mod"
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Install wails3 CLI
|
||||||
|
# Version derived from go.mod so the bootstrapper payload always
|
||||||
|
# matches the wails runtime the binary links against.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||||
|
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||||
|
|
||||||
|
- name: Stage WebView2 bootstrapper for installers
|
||||||
|
# Both client/installer.nsis and client/netbird.wxs reference
|
||||||
|
# client/MicrosoftEdgeWebview2Setup.exe. wails3 writes it there.
|
||||||
|
# The signing pipeline (netbirdio/sign-pipelines) does the same
|
||||||
|
# step for release builds; this mirrors it for PR sanity testing.
|
||||||
|
shell: bash
|
||||||
|
run: wails3 generate webview2bootstrapper -dir client
|
||||||
|
|
||||||
- name: Build NSIS installer
|
- name: Build NSIS installer
|
||||||
uses: joncloud/makensis-action@v3.3
|
uses: joncloud/makensis-action@v3.3
|
||||||
with:
|
with:
|
||||||
|
|||||||
6
.github/workflows/wasm-build-validation.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
- name: Install golangci-lint
|
- name: Install golangci-lint
|
||||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||||
with:
|
with:
|
||||||
@@ -61,8 +61,8 @@ jobs:
|
|||||||
|
|
||||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||||
|
|
||||||
if [ ${SIZE} -gt 62914560 ]; then
|
if [ ${SIZE} -gt 58720256 ]; then
|
||||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
|
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ linters:
|
|||||||
- linters:
|
- linters:
|
||||||
- staticcheck
|
- staticcheck
|
||||||
text: "QF1012"
|
text: "QF1012"
|
||||||
|
# client/ui/main.go uses //go:embed all:frontend/dist; the
|
||||||
|
# directory is populated by `pnpm build` in the release pipeline
|
||||||
|
# and missing at lint time, so the embed parses to "no matching
|
||||||
|
# files found" — surfaced by golangci-lint's typecheck pre-pass.
|
||||||
|
# Suppress just that one diagnostic; the rest of the package
|
||||||
|
# (services/, tray.go, grpc.go, ...) still gets linted normally.
|
||||||
|
- linters:
|
||||||
|
- typecheck
|
||||||
|
path: client/ui/main\.go
|
||||||
|
text: "pattern all:frontend/dist"
|
||||||
paths:
|
paths:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
project_name: netbird-ui
|
project_name: netbird-ui
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
# Bindings are gitignored; regenerate before the frontend build so
|
||||||
|
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
||||||
|
# build without them).
|
||||||
|
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts'
|
||||||
|
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: netbird-ui
|
- id: netbird-ui
|
||||||
dir: client/ui
|
dir: client/ui
|
||||||
@@ -70,12 +79,15 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/netbird.desktop
|
- src: client/ui/build/linux/netbird.desktop
|
||||||
dst: /usr/share/applications/netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/assets/netbird.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
|
- libgtk-3-0
|
||||||
|
- libwebkit2gtk-4.1-0
|
||||||
|
- libayatana-appindicator3-1
|
||||||
|
|
||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
@@ -89,12 +101,15 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/netbird.desktop
|
- src: client/ui/build/linux/netbird.desktop
|
||||||
dst: /usr/share/applications/netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/assets/netbird.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
|
- gtk3
|
||||||
|
- webkit2gtk4.1
|
||||||
|
- libayatana-appindicator-gtk3
|
||||||
rpm:
|
rpm:
|
||||||
signature:
|
signature:
|
||||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
project_name: netbird-ui
|
project_name: netbird-ui
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
# Bindings are gitignored; regenerate before the frontend build so
|
||||||
|
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
||||||
|
# build without them).
|
||||||
|
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts'
|
||||||
|
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: netbird-ui-darwin
|
- id: netbird-ui-darwin
|
||||||
dir: client/ui
|
dir: client/ui
|
||||||
@@ -20,8 +29,6 @@ builds:
|
|||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
tags:
|
|
||||||
- load_wgnt_from_rsrc
|
|
||||||
|
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
- id: netbird-ui-darwin
|
- id: netbird-ui-darwin
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ If you haven't already, join our slack workspace [here](https://docs.netbird.io/
|
|||||||
- [Contributing to NetBird](#contributing-to-netbird)
|
- [Contributing to NetBird](#contributing-to-netbird)
|
||||||
- [Contents](#contents)
|
- [Contents](#contents)
|
||||||
- [Code of conduct](#code-of-conduct)
|
- [Code of conduct](#code-of-conduct)
|
||||||
- [Discuss changes with the NetBird team first](#discuss-changes-with-the-netbird-team-first)
|
|
||||||
- [Directory structure](#directory-structure)
|
- [Directory structure](#directory-structure)
|
||||||
- [Development setup](#development-setup)
|
- [Development setup](#development-setup)
|
||||||
- [Requirements](#requirements)
|
- [Requirements](#requirements)
|
||||||
@@ -34,14 +33,6 @@ Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
|
|||||||
By participating, you are expected to uphold this code. Please report
|
By participating, you are expected to uphold this code. Please report
|
||||||
unacceptable behavior to community@netbird.io.
|
unacceptable behavior to community@netbird.io.
|
||||||
|
|
||||||
## Discuss changes with the NetBird team first
|
|
||||||
|
|
||||||
Changes to the **public API**, **gRPC protocols**, **functionality behavior**, **CLI / service flags**, or **new features** should be discussed with the NetBird team before you start the work. These surfaces are part of NetBird's contract with operators, self-hosters, and downstream integrators, and changes to them have compatibility, security, and release-planning implications that benefit from an early conversation.
|
|
||||||
|
|
||||||
Open an issue or reach out on [Slack](https://docs.netbird.io/slack-url) to talk through what you have in mind. We'll help shape the change, flag any constraints we know about, and confirm the direction so the PR review can focus on implementation rather than design.
|
|
||||||
|
|
||||||
Typical bug fixes, internal refactors, documentation updates, and tests do not need pre-discussion — open the PR directly.
|
|
||||||
|
|
||||||
## Directory structure
|
## Directory structure
|
||||||
|
|
||||||
The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages.
|
The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages.
|
||||||
|
|||||||
@@ -22,11 +22,19 @@ import (
|
|||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// extendSessionFlag drives the `netbird login --extend` flow: refresh the
|
||||||
|
// SSO session expiry on the management server without tearing down the
|
||||||
|
// tunnel. Mutually exclusive with setup-key login (a setup-key cannot
|
||||||
|
// refresh an SSO-tracked peer — see auth.errSetupKeyOnSSOExpiredPeer).
|
||||||
|
var extendSessionFlag bool
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
|
loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc)
|
||||||
loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc)
|
loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc)
|
||||||
loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
|
loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc)
|
||||||
loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location")
|
loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location")
|
||||||
|
loginCmd.PersistentFlags().BoolVar(&extendSessionFlag, "extend", false,
|
||||||
|
"refresh the SSO session expiry without tearing down the tunnel (requires an active connection)")
|
||||||
}
|
}
|
||||||
|
|
||||||
var loginCmd = &cobra.Command{
|
var loginCmd = &cobra.Command{
|
||||||
@@ -61,6 +69,16 @@ var loginCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if extendSessionFlag {
|
||||||
|
if providedSetupKey != "" {
|
||||||
|
return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers")
|
||||||
|
}
|
||||||
|
if err := doExtendSession(ctx, cmd); err != nil {
|
||||||
|
return fmt.Errorf("extend session failed: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// workaround to run without service
|
// workaround to run without service
|
||||||
if util.FindFirstLogPath(logFiles) == "" {
|
if util.FindFirstLogPath(logFiles) == "" {
|
||||||
if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil {
|
if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil {
|
||||||
@@ -150,6 +168,65 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doExtendSession drives the daemon's RequestExtendAuthSession /
|
||||||
|
// WaitExtendAuthSession pair. The user is sent through a regular SSO flow
|
||||||
|
// (browser + verification URL) and the resulting JWT is forwarded to the
|
||||||
|
// management server's ExtendAuthSession RPC. The tunnel stays up
|
||||||
|
// throughout — no Down/Up, no network-map resync.
|
||||||
|
func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
|
||||||
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
|
if err != nil {
|
||||||
|
//nolint
|
||||||
|
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
||||||
|
"If the daemon is not running please run: "+
|
||||||
|
"\nnetbird service install \nnetbird service start\n", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client := proto.NewDaemonServiceClient(conn)
|
||||||
|
|
||||||
|
req := &proto.RequestExtendAuthSessionRequest{}
|
||||||
|
// Pre-fill the IdP login hint from the active profile so the user
|
||||||
|
// doesn't have to retype their email. Best-effort: we still proceed
|
||||||
|
// without a hint if the lookup fails.
|
||||||
|
pm := profilemanager.NewProfileManager()
|
||||||
|
if active, perr := pm.GetActiveProfile(); perr == nil {
|
||||||
|
if profState, sperr := pm.GetProfileState(active.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) {
|
func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) (*profilemanager.Profile, error) {
|
||||||
// switch profile if provided
|
// switch profile if provided
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -117,6 +118,11 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
profName = activeProf.Name
|
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{
|
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||||
Anonymize: anonymizeFlag,
|
Anonymize: anonymizeFlag,
|
||||||
DaemonVersion: resp.GetDaemonVersion(),
|
DaemonVersion: resp.GetDaemonVersion(),
|
||||||
@@ -127,6 +133,7 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
IPsFilter: ipsFilterMap,
|
IPsFilter: ipsFilterMap,
|
||||||
ConnectionTypeFilter: connectionTypeFilter,
|
ConnectionTypeFilter: connectionTypeFilter,
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
|
SessionExpiresAt: sessionExpiresAt,
|
||||||
})
|
})
|
||||||
var statusOutputString string
|
var statusOutputString string
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
106
client/cmd/up.go
@@ -361,12 +361,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
|||||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||||
req.ServerSSHAllowed = &serverSSHAllowed
|
req.ServerSSHAllowed = &serverSSHAllowed
|
||||||
}
|
}
|
||||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
|
||||||
req.ServerVNCAllowed = &serverVNCAllowed
|
|
||||||
}
|
|
||||||
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
|
||||||
req.DisableVNCApproval = &disableVNCApproval
|
|
||||||
}
|
|
||||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||||
req.EnableSSHRoot = &enableSSHRoot
|
req.EnableSSHRoot = &enableSSHRoot
|
||||||
}
|
}
|
||||||
@@ -473,14 +467,30 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
|||||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||||
ic.ServerSSHAllowed = &serverSSHAllowed
|
ic.ServerSSHAllowed = &serverSSHAllowed
|
||||||
}
|
}
|
||||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
|
||||||
ic.ServerVNCAllowed = &serverVNCAllowed
|
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||||
}
|
ic.EnableSSHRoot = &enableSSHRoot
|
||||||
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
|
||||||
ic.DisableVNCApproval = &disableVNCApproval
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applySSHFlagsToConfig(cmd, &ic)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(interfaceNameFlag).Changed {
|
if cmd.Flag(interfaceNameFlag).Changed {
|
||||||
if err := parseInterfaceName(interfaceName); err != nil {
|
if err := parseInterfaceName(interfaceName); err != nil {
|
||||||
@@ -556,49 +566,6 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
|||||||
return &ic, nil
|
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) {
|
func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) {
|
||||||
loginRequest := proto.LoginRequest{
|
loginRequest := proto.LoginRequest{
|
||||||
SetupKey: providedSetupKey,
|
SetupKey: providedSetupKey,
|
||||||
@@ -628,14 +595,31 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
|||||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||||
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
||||||
}
|
}
|
||||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
|
||||||
loginRequest.ServerVNCAllowed = &serverVNCAllowed
|
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||||
}
|
loginRequest.EnableSSHRoot = &enableSSHRoot
|
||||||
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
|
||||||
loginRequest.DisableVNCApproval = &disableVNCApproval
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applySSHFlagsToLogin(cmd, &loginRequest)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(disableAutoConnectFlag).Changed {
|
if cmd.Flag(disableAutoConnectFlag).Changed {
|
||||||
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
//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,
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
//go:build darwin && !ios
|
|
||||||
|
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
// 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(int(targetUID)); err != nil {
|
|
||||||
return fmt.Errorf("setgid(%d): %w", targetUID, 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
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
//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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
//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())
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
//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 C:\Windows\Temp socket
|
|
||||||
// location (admin/SYSTEM-write-only) and the per-spawn token for
|
|
||||||
// integrity instead.
|
|
||||||
func dropAgentPrivileges(_ uint32) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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,30 +6,19 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var StateDir string
|
||||||
// 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() {
|
func init() {
|
||||||
|
StateDir = os.Getenv("NB_STATE_DIR")
|
||||||
|
if StateDir != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "windows":
|
case "windows":
|
||||||
StateDir = filepath.Join(os.Getenv("PROGRAMDATA"), "Netbird")
|
StateDir = filepath.Join(os.Getenv("PROGRAMDATA"), "Netbird")
|
||||||
case "darwin", "linux":
|
case "darwin", "linux":
|
||||||
StateDir = "/var/lib/netbird"
|
StateDir = "/var/lib/netbird"
|
||||||
RuntimeDir = "/var/run/netbird"
|
|
||||||
case "freebsd", "openbsd", "netbsd", "dragonfly":
|
case "freebsd", "openbsd", "netbsd", "dragonfly":
|
||||||
StateDir = "/var/db/netbird"
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
!define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls."
|
!define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls."
|
||||||
!define INSTALLER_NAME "netbird-installer.exe"
|
!define INSTALLER_NAME "netbird-installer.exe"
|
||||||
!define MAIN_APP_EXE "Netbird"
|
!define MAIN_APP_EXE "Netbird"
|
||||||
!define ICON "ui\\assets\\netbird.ico"
|
!define ICON "ui\\build\\windows\\icon.ico"
|
||||||
!define BANNER "ui\\build\\banner.bmp"
|
!define BANNER "ui\\build\\banner.bmp"
|
||||||
!define LICENSE_DATA "..\\LICENSE"
|
!define LICENSE_DATA "..\\LICENSE"
|
||||||
|
|
||||||
@@ -288,6 +288,43 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
|||||||
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
|
# Install the Microsoft Edge WebView2 runtime if it isn't already present.
|
||||||
|
# Macro adapted from Wails3's NSIS template (wails_tools.nsh): a registry
|
||||||
|
# probe followed by a silent install of the embedded evergreen bootstrapper.
|
||||||
|
# The MicrosoftEdgeWebview2Setup.exe payload is staged next to this script
|
||||||
|
# by the sign-pipelines build step (`wails3 generate webview2bootstrapper`).
|
||||||
|
!macro nb.webview2runtime
|
||||||
|
SetRegView 64
|
||||||
|
# Per-machine install marker — populated when the runtime ships with
|
||||||
|
# Edge or has been installed by an admin previously.
|
||||||
|
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||||
|
${If} $0 != ""
|
||||||
|
Goto webview2_ok
|
||||||
|
${EndIf}
|
||||||
|
# Per-user fallback for HKCU installs.
|
||||||
|
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||||
|
${If} $0 != ""
|
||||||
|
Goto webview2_ok
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
SetDetailsPrint both
|
||||||
|
DetailPrint "Installing: WebView2 Runtime"
|
||||||
|
SetDetailsPrint listonly
|
||||||
|
|
||||||
|
InitPluginsDir
|
||||||
|
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||||
|
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||||
|
File "MicrosoftEdgeWebview2Setup.exe"
|
||||||
|
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||||
|
|
||||||
|
SetDetailsPrint both
|
||||||
|
webview2_ok:
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
Section -WebView2
|
||||||
|
!insertmacro nb.webview2runtime
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
Section -Post
|
Section -Post
|
||||||
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
|
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
|
||||||
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
|
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
|
||||||
@@ -339,9 +376,9 @@ DetailPrint "Deleting application files..."
|
|||||||
Delete "$INSTDIR\${UI_APP_EXE}"
|
Delete "$INSTDIR\${UI_APP_EXE}"
|
||||||
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
||||||
Delete "$INSTDIR\wintun.dll"
|
Delete "$INSTDIR\wintun.dll"
|
||||||
!if ${ARCH} == "amd64"
|
# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove
|
||||||
|
# any leftover copy on uninstall so old upgrades don't leave it behind.
|
||||||
Delete "$INSTDIR\opengl32.dll"
|
Delete "$INSTDIR\opengl32.dll"
|
||||||
!endif
|
|
||||||
DetailPrint "Removing application directory..."
|
DetailPrint "Removing application directory..."
|
||||||
RmDir /r "$INSTDIR"
|
RmDir /r "$INSTDIR"
|
||||||
|
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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,6 +3,7 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,6 +22,25 @@ import (
|
|||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// peerLoginExpiredMsg is the exact phrase the management server returns
|
||||||
|
// when a previously SSO-enrolled peer's login has expired. Sourced from
|
||||||
|
// shared/management/status/error.go (NewPeerLoginExpiredError). Matched
|
||||||
|
// by substring so a future server-side rewording that keeps the phrase
|
||||||
|
// still triggers the friendly fallback in Login().
|
||||||
|
const peerLoginExpiredMsg = "peer login has expired"
|
||||||
|
|
||||||
|
// errSetupKeyOnSSOExpiredPeer replaces the raw management error when the
|
||||||
|
// user runs `netbird login -k <setup-key>` against a peer that was
|
||||||
|
// originally enrolled via SSO. Wrapped in a PermissionDenied gRPC status
|
||||||
|
// so callers' existing isPermissionDenied / isAuthError checks still
|
||||||
|
// classify it correctly (early-exit from retry backoff, StatusNeedsLogin
|
||||||
|
// in the server state machine).
|
||||||
|
var errSetupKeyOnSSOExpiredPeer = status.Error(
|
||||||
|
codes.PermissionDenied,
|
||||||
|
"this peer was originally enrolled via SSO and its session has expired. "+
|
||||||
|
"Setup keys can only enrol new peers — run `netbird up` (interactive SSO) to re-login.",
|
||||||
|
)
|
||||||
|
|
||||||
// Auth manages authentication operations with the management server
|
// Auth manages authentication operations with the management server
|
||||||
// It maintains a long-lived connection and automatically handles reconnection with backoff
|
// It maintains a long-lived connection and automatically handles reconnection with backoff
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
@@ -184,6 +204,15 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
|||||||
log.Debugf("peer registration required")
|
log.Debugf("peer registration required")
|
||||||
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
|
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// The peer pub-key is already on file with the management
|
||||||
|
// server (originally enrolled via SSO) and the session has
|
||||||
|
// expired. The setup-key path can only enrol new peers, so
|
||||||
|
// retrying with -k will keep failing. Replace the raw mgm
|
||||||
|
// message with an actionable hint that tells the user to
|
||||||
|
// re-authenticate via SSO instead.
|
||||||
|
if setupKey != "" && jwtToken == "" && isPeerLoginExpired(err) {
|
||||||
|
err = errSetupKeyOnSSOExpiredPeer
|
||||||
|
}
|
||||||
isAuthError = isPermissionDenied(err)
|
isAuthError = isPermissionDenied(err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -315,7 +344,6 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
|||||||
a.config.RosenpassEnabled,
|
a.config.RosenpassEnabled,
|
||||||
a.config.RosenpassPermissive,
|
a.config.RosenpassPermissive,
|
||||||
a.config.ServerSSHAllowed,
|
a.config.ServerSSHAllowed,
|
||||||
a.config.ServerVNCAllowed,
|
|
||||||
a.config.DisableClientRoutes,
|
a.config.DisableClientRoutes,
|
||||||
a.config.DisableServerRoutes,
|
a.config.DisableServerRoutes,
|
||||||
a.config.DisableDNS,
|
a.config.DisableDNS,
|
||||||
@@ -475,3 +503,16 @@ func isLoginNeeded(err error) bool {
|
|||||||
func isRegistrationNeeded(err error) bool {
|
func isRegistrationNeeded(err error) bool {
|
||||||
return isPermissionDenied(err)
|
return isPermissionDenied(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isPeerLoginExpired reports whether err is the management server's
|
||||||
|
// "peer login has expired" PermissionDenied response. Used by Login to
|
||||||
|
// detect the case where the caller passed a setup-key but the peer is
|
||||||
|
// actually an SSO-enrolled record whose session needs refreshing — the
|
||||||
|
// setup-key path cannot help there.
|
||||||
|
func isPeerLoginExpired(err error) bool {
|
||||||
|
if !isPermissionDenied(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s, _ := status.FromError(err)
|
||||||
|
return strings.Contains(s.Message(), peerLoginExpiredMsg)
|
||||||
|
}
|
||||||
|
|||||||
80
client/internal/auth/auth_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsPeerLoginExpired(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil",
|
||||||
|
err: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain error (not a gRPC status)",
|
||||||
|
err: errors.New("network read: connection reset"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PermissionDenied with different message",
|
||||||
|
err: status.Error(codes.PermissionDenied, "user is blocked"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unauthenticated with the expected phrase",
|
||||||
|
// Wrong status code — must still return false.
|
||||||
|
err: status.Error(codes.Unauthenticated, "peer login has expired, please log in once more"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact server message",
|
||||||
|
err: status.Error(codes.PermissionDenied, "peer login has expired, please log in once more"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "phrase as substring",
|
||||||
|
// Future-proofing: if mgm reworords but keeps the phrase,
|
||||||
|
// the friendly fallback must still kick in.
|
||||||
|
err: status.Error(codes.PermissionDenied, "session refused: peer login has expired (account=foo)"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := isPeerLoginExpired(tc.err); got != tc.want {
|
||||||
|
t.Fatalf("isPeerLoginExpired(%v) = %v, want %v", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrSetupKeyOnSSOExpiredPeer(t *testing.T) {
|
||||||
|
// Sentinel must surface as PermissionDenied so the upstream
|
||||||
|
// isPermissionDenied / isAuthError checks classify it correctly
|
||||||
|
// (short-circuit retry backoff, set StatusNeedsLogin).
|
||||||
|
if !isPermissionDenied(errSetupKeyOnSSOExpiredPeer) {
|
||||||
|
t.Fatalf("errSetupKeyOnSSOExpiredPeer must be a PermissionDenied gRPC error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message must actually mention SSO and `netbird up` so it is
|
||||||
|
// actionable for the end user. Loose substring checks keep the
|
||||||
|
// test resilient to copy edits.
|
||||||
|
s, _ := status.FromError(errSetupKeyOnSSOExpiredPeer)
|
||||||
|
msg := strings.ToLower(s.Message())
|
||||||
|
for _, want := range []string{"sso", "netbird up"} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("sentinel message should contain %q, got %q", want, s.Message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
client/internal/auth/pending_flow.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PendingFlow stores an in-progress OAuth flow between the RPC that
|
||||||
|
// initiates it (returns the verification URI to the UI) and the RPC
|
||||||
|
// that waits for the user to complete it. The flow handle, the
|
||||||
|
// device-code info, and the absolute expiry are kept together so the
|
||||||
|
// waiting RPC can validate the device code and reuse the same flow.
|
||||||
|
//
|
||||||
|
// PendingFlow is safe for concurrent use; callers must not access the
|
||||||
|
// stored fields directly.
|
||||||
|
type PendingFlow struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
flow OAuthFlow
|
||||||
|
info AuthFlowInfo
|
||||||
|
expiresAt time.Time
|
||||||
|
waitCancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPendingFlow returns an empty PendingFlow ready to be populated by Set.
|
||||||
|
func NewPendingFlow() *PendingFlow {
|
||||||
|
return &PendingFlow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores the flow and its authorization info, computing the absolute
|
||||||
|
// expiry from info.ExpiresIn (seconds, as returned by the IdP).
|
||||||
|
func (p *PendingFlow) Set(flow OAuthFlow, info AuthFlowInfo) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.flow = flow
|
||||||
|
p.info = info
|
||||||
|
p.expiresAt = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the stored flow, info, and whether a flow is currently
|
||||||
|
// pending. Returns (nil, zero, false) after Clear or before Set.
|
||||||
|
func (p *PendingFlow) Get() (OAuthFlow, AuthFlowInfo, bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.flow == nil {
|
||||||
|
return nil, AuthFlowInfo{}, false
|
||||||
|
}
|
||||||
|
return p.flow, p.info, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiresAt returns the absolute expiry of the pending flow. Returns
|
||||||
|
// the zero time when no flow is pending.
|
||||||
|
func (p *PendingFlow) ExpiresAt() time.Time {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWaitCancel records the cancel function for the goroutine currently
|
||||||
|
// blocked in WaitToken so a new RequestAuth can preempt it.
|
||||||
|
func (p *PendingFlow) SetWaitCancel(cancel context.CancelFunc) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.waitCancel = cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelWait invokes and clears the stored wait-cancel, if any. Safe to
|
||||||
|
// call when no wait is in progress.
|
||||||
|
func (p *PendingFlow) CancelWait() {
|
||||||
|
p.mu.Lock()
|
||||||
|
cancel := p.waitCancel
|
||||||
|
p.waitCancel = nil
|
||||||
|
p.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear resets the pending flow to empty. Any stored wait-cancel is
|
||||||
|
// dropped without being invoked — call CancelWait first if the waiting
|
||||||
|
// goroutine must be stopped.
|
||||||
|
func (p *PendingFlow) Clear() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.flow = nil
|
||||||
|
p.info = AuthFlowInfo{}
|
||||||
|
p.expiresAt = time.Time{}
|
||||||
|
p.waitCancel = nil
|
||||||
|
}
|
||||||
74
client/internal/auth/sessionwatch/event.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package sessionwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// internal event kinds are no longer exposed: the watcher drives the Sink
|
||||||
|
// directly (NotifyStateChange on deadline change/clear, PublishEvent at
|
||||||
|
// each warning lead). Tests use a mock Sink to observe what the watcher
|
||||||
|
// emits.
|
||||||
|
|
||||||
|
// Metadata keys attached by the daemon to session-warning SystemEvents.
|
||||||
|
// The UI tray reads these to build a locale-aware notification without
|
||||||
|
// relying on the daemon's locale-less UserMessage string, and to
|
||||||
|
// disambiguate the T-WarningLead notification from the T-FinalWarningLead
|
||||||
|
// fallback that auto-opens the SessionAboutToExpire dialog.
|
||||||
|
const (
|
||||||
|
// MetaSessionWarning is set to "true" on both warning events (T-10 and
|
||||||
|
// T-2) so the UI can detect a session-warning SystemEvent without
|
||||||
|
// matching on the message text. Use MetaSessionFinal to distinguish
|
||||||
|
// the two.
|
||||||
|
MetaSessionWarning = "session_warning"
|
||||||
|
// MetaSessionFinal is set to "true" on the T-FinalWarningLead event
|
||||||
|
// only. Consumers that need to auto-open the SessionAboutToExpire
|
||||||
|
// dialog gate on this; T-WarningLead events leave the field unset.
|
||||||
|
MetaSessionFinal = "session_final_warning"
|
||||||
|
// MetaSessionExpiresAt carries the absolute UTC deadline encoded with
|
||||||
|
// FormatExpiresAt; consumers must decode with ParseExpiresAt so a
|
||||||
|
// future format change stays a single edit.
|
||||||
|
MetaSessionExpiresAt = "session_expires_at"
|
||||||
|
// MetaSessionLeadMinutes carries the lead in whole minutes (WarningLead
|
||||||
|
// for the T-10 event, FinalWarningLead for the T-2 event) so the UI
|
||||||
|
// can show "expires in ~N minutes" without hardcoding either constant.
|
||||||
|
MetaSessionLeadMinutes = "lead_minutes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// expiresAtLayout is the wire format used for MetaSessionExpiresAt.
|
||||||
|
// Producer and consumers both go through FormatExpiresAt/ParseExpiresAt
|
||||||
|
// so this layout stays a single source of truth.
|
||||||
|
const expiresAtLayout = time.RFC3339
|
||||||
|
|
||||||
|
// FormatExpiresAt encodes a deadline for MetaSessionExpiresAt. Always
|
||||||
|
// emits UTC so a consumer in another timezone reads the same wall-clock
|
||||||
|
// deadline.
|
||||||
|
func FormatExpiresAt(t time.Time) string {
|
||||||
|
return t.UTC().Format(expiresAtLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseExpiresAt decodes the MetaSessionExpiresAt value back to a UTC
|
||||||
|
// time. Returns an error when the field is empty or malformed; the
|
||||||
|
// caller decides whether to fall back (zero value) or propagate.
|
||||||
|
func ParseExpiresAt(s string) (time.Time, error) {
|
||||||
|
t, err := time.Parse(expiresAtLayout, s)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return t.UTC(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatLeadMinutes encodes a lead duration for MetaSessionLeadMinutes
|
||||||
|
// as the integer count of whole minutes. Sub-minute residuals are
|
||||||
|
// truncated — the field is informational ("expires in ~N minutes") and
|
||||||
|
// fractional minutes don't change what the UI displays.
|
||||||
|
func FormatLeadMinutes(d time.Duration) string {
|
||||||
|
return strconv.Itoa(int(d / time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLeadMinutes decodes a MetaSessionLeadMinutes value. Returns 0
|
||||||
|
// and the parse error for malformed input; consumers that prefer a
|
||||||
|
// silent fallback can simply ignore the error.
|
||||||
|
func ParseLeadMinutes(s string) (int, error) {
|
||||||
|
return strconv.Atoi(s)
|
||||||
|
}
|
||||||
362
client/internal/auth/sessionwatch/watcher.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
// 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 (NotifyStateChange
|
||||||
|
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
||||||
|
// a fake recorder so the same surface is observable without an engine.
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
NotifyStateChange()
|
||||||
|
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.NotifyStateChange()
|
||||||
|
}
|
||||||
|
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline returns the most recently observed deadline. Zero when no
|
||||||
|
// deadline is currently tracked.
|
||||||
|
func (w *Watcher) Deadline() time.Time {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.current
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss records the user's "Dismiss" action against the current deadline
|
||||||
|
// and suppresses the upcoming final-warning callback for that deadline.
|
||||||
|
// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
|
||||||
|
// deadline resets the dismissal so the final-warning cycle re-arms.
|
||||||
|
//
|
||||||
|
// No-op when the watcher holds no deadline or has been closed.
|
||||||
|
func (w *Watcher) Dismiss() {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed || w.current.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.dismissedAt.Equal(w.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.dismissedAt = w.current
|
||||||
|
// Cancel the armed final-warning timer eagerly. fireFinal would also
|
||||||
|
// gate on dismissedAt, but stopping the timer avoids a wakeup with
|
||||||
|
// nothing to do and makes the intent visible.
|
||||||
|
if w.finalTimer != nil {
|
||||||
|
w.finalTimer.Stop()
|
||||||
|
w.finalTimer = nil
|
||||||
|
}
|
||||||
|
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops any pending timer. Update calls after Close are ignored.
|
||||||
|
func (w *Watcher) Close() {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.closed = true
|
||||||
|
w.stopTimerLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.NotifyStateChange()
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
463
client/internal/auth/sessionwatch/watcher_test.go
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
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. NotifyStateChange and PublishEvent
|
||||||
|
// land in the same ordered events slice (with the Kind distinguishing
|
||||||
|
// them) so tests that care about ordering still work.
|
||||||
|
type fakeRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []event
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRecorder) NotifyStateChange() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.events = append(r.events, event{kind: stateChange})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -256,6 +256,15 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
||||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// On daemon shutdown / Down() the parent context is cancelled
|
||||||
|
// and the dial fails with "context canceled". Wrapping that
|
||||||
|
// into state would leave the snapshot stuck at Connecting+err
|
||||||
|
// until the backoff loop wakes up — instead let the operation
|
||||||
|
// return cleanly so the deferred state.Set(StatusIdle) takes
|
||||||
|
// effect on the next iteration.
|
||||||
|
if c.ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err))
|
return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err))
|
||||||
}
|
}
|
||||||
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
|
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
|
||||||
@@ -384,6 +393,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed the session-expiry deadline from the LoginResponse. Subsequent
|
||||||
|
// changes flow in through SyncResponse and are applied in handleSync.
|
||||||
|
engine.ApplySessionDeadline(loginResp.GetSessionExpiresAt())
|
||||||
|
|
||||||
log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress())
|
log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress())
|
||||||
state.Set(StatusConnected)
|
state.Set(StatusConnected)
|
||||||
|
|
||||||
@@ -424,7 +437,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.statusRecorder.ClientStart()
|
c.statusRecorder.ClientStart()
|
||||||
err = backoff.Retry(operation, backOff)
|
// 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))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
||||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
||||||
@@ -562,8 +579,6 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
|||||||
RosenpassEnabled: config.RosenpassEnabled,
|
RosenpassEnabled: config.RosenpassEnabled,
|
||||||
RosenpassPermissive: config.RosenpassPermissive,
|
RosenpassPermissive: config.RosenpassPermissive,
|
||||||
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
||||||
ServerVNCAllowed: config.ServerVNCAllowed != nil && *config.ServerVNCAllowed,
|
|
||||||
DisableVNCApproval: config.DisableVNCApproval,
|
|
||||||
EnableSSHRoot: config.EnableSSHRoot,
|
EnableSSHRoot: config.EnableSSHRoot,
|
||||||
EnableSSHSFTP: config.EnableSSHSFTP,
|
EnableSSHSFTP: config.EnableSSHSFTP,
|
||||||
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
||||||
@@ -646,7 +661,6 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
|||||||
config.RosenpassEnabled,
|
config.RosenpassEnabled,
|
||||||
config.RosenpassPermissive,
|
config.RosenpassPermissive,
|
||||||
config.ServerSSHAllowed,
|
config.ServerSSHAllowed,
|
||||||
config.ServerVNCAllowed,
|
|
||||||
config.DisableClientRoutes,
|
config.DisableClientRoutes,
|
||||||
config.DisableServerRoutes,
|
config.DisableServerRoutes,
|
||||||
config.DisableDNS,
|
config.DisableDNS,
|
||||||
|
|||||||
@@ -636,12 +636,6 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
|||||||
if g.internalConfig.SSHJWTCacheTTL != nil {
|
if g.internalConfig.SSHJWTCacheTTL != nil {
|
||||||
configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL))
|
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("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes))
|
||||||
configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes))
|
configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes))
|
||||||
|
|||||||
@@ -862,8 +862,6 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
|||||||
RosenpassEnabled: true,
|
RosenpassEnabled: true,
|
||||||
RosenpassPermissive: true,
|
RosenpassPermissive: true,
|
||||||
ServerSSHAllowed: &bTrue,
|
ServerSSHAllowed: &bTrue,
|
||||||
ServerVNCAllowed: &bTrue,
|
|
||||||
DisableVNCApproval: &bTrue,
|
|
||||||
EnableSSHRoot: &bTrue,
|
EnableSSHRoot: &bTrue,
|
||||||
EnableSSHSFTP: &bTrue,
|
EnableSSHSFTP: &bTrue,
|
||||||
EnableSSHLocalPortForwarding: &bTrue,
|
EnableSSHLocalPortForwarding: &bTrue,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
"github.com/netbirdio/netbird/client/internal/acl"
|
"github.com/netbirdio/netbird/client/internal/acl"
|
||||||
"github.com/netbirdio/netbird/client/internal/approval"
|
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||||
"github.com/netbirdio/netbird/client/internal/debug"
|
"github.com/netbirdio/netbird/client/internal/debug"
|
||||||
"github.com/netbirdio/netbird/client/internal/dns"
|
"github.com/netbirdio/netbird/client/internal/dns"
|
||||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||||
@@ -124,8 +124,6 @@ type EngineConfig struct {
|
|||||||
RosenpassPermissive bool
|
RosenpassPermissive bool
|
||||||
|
|
||||||
ServerSSHAllowed bool
|
ServerSSHAllowed bool
|
||||||
ServerVNCAllowed bool
|
|
||||||
DisableVNCApproval *bool
|
|
||||||
EnableSSHRoot *bool
|
EnableSSHRoot *bool
|
||||||
EnableSSHSFTP *bool
|
EnableSSHSFTP *bool
|
||||||
EnableSSHLocalPortForwarding *bool
|
EnableSSHLocalPortForwarding *bool
|
||||||
@@ -207,9 +205,7 @@ type Engine struct {
|
|||||||
|
|
||||||
networkMonitor *networkmonitor.NetworkMonitor
|
networkMonitor *networkmonitor.NetworkMonitor
|
||||||
|
|
||||||
sshServer sshServer
|
sshServer sshServer
|
||||||
vncSrv vncServer
|
|
||||||
approvalBroker *approval.Broker
|
|
||||||
|
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
|
|
||||||
@@ -255,6 +251,8 @@ type Engine struct {
|
|||||||
jobExecutorWG sync.WaitGroup
|
jobExecutorWG sync.WaitGroup
|
||||||
|
|
||||||
exposeManager *expose.Manager
|
exposeManager *expose.Manager
|
||||||
|
|
||||||
|
sessionWatcher *sessionwatch.Watcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer is an instance of the Connection Peer
|
// Peer is an instance of the Connection Peer
|
||||||
@@ -290,7 +288,6 @@ func NewEngine(
|
|||||||
TURNs: []*stun.URI{},
|
TURNs: []*stun.URI{},
|
||||||
networkSerial: 0,
|
networkSerial: 0,
|
||||||
statusRecorder: services.StatusRecorder,
|
statusRecorder: services.StatusRecorder,
|
||||||
approvalBroker: approval.New(services.StatusRecorder),
|
|
||||||
stateManager: services.StateManager,
|
stateManager: services.StateManager,
|
||||||
portForwardManager: portforward.NewManager(),
|
portForwardManager: portforward.NewManager(),
|
||||||
checks: services.Checks,
|
checks: services.Checks,
|
||||||
@@ -299,6 +296,17 @@ func NewEngine(
|
|||||||
clientMetrics: services.ClientMetrics,
|
clientMetrics: services.ClientMetrics,
|
||||||
updateManager: services.UpdateManager,
|
updateManager: services.UpdateManager,
|
||||||
}
|
}
|
||||||
|
// 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 = sessionwatch.New(engine.statusRecorder)
|
||||||
|
|
||||||
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
||||||
return engine
|
return engine
|
||||||
@@ -326,10 +334,6 @@ func (e *Engine) Stop() error {
|
|||||||
log.Warnf("failed to stop SSH server: %v", err)
|
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()
|
e.cleanupSSHConfig()
|
||||||
|
|
||||||
if e.ingressGatewayMgr != nil {
|
if e.ingressGatewayMgr != nil {
|
||||||
@@ -343,6 +347,10 @@ func (e *Engine) Stop() error {
|
|||||||
e.srWatcher.Close()
|
e.srWatcher.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if e.sessionWatcher != nil {
|
||||||
|
e.sessionWatcher.Close()
|
||||||
|
}
|
||||||
|
|
||||||
if e.updateManager != nil {
|
if e.updateManager != nil {
|
||||||
e.updateManager.SetDownloadOnly()
|
e.updateManager.SetDownloadOnly()
|
||||||
}
|
}
|
||||||
@@ -875,6 +883,8 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
return e.ctx.Err()
|
return e.ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(update.GetSessionExpiresAt())
|
||||||
|
|
||||||
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
||||||
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
||||||
}
|
}
|
||||||
@@ -1020,7 +1030,6 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
|||||||
e.config.RosenpassEnabled,
|
e.config.RosenpassEnabled,
|
||||||
e.config.RosenpassPermissive,
|
e.config.RosenpassPermissive,
|
||||||
&e.config.ServerSSHAllowed,
|
&e.config.ServerSSHAllowed,
|
||||||
&e.config.ServerVNCAllowed,
|
|
||||||
e.config.DisableClientRoutes,
|
e.config.DisableClientRoutes,
|
||||||
e.config.DisableServerRoutes,
|
e.config.DisableServerRoutes,
|
||||||
e.config.DisableDNS,
|
e.config.DisableDNS,
|
||||||
@@ -1068,10 +1077,6 @@ 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 := e.statusRecorder.GetLocalPeerState()
|
||||||
state.IP = e.wgInterface.Address().String()
|
state.IP = e.wgInterface.Address().String()
|
||||||
state.IPv6 = e.wgInterface.Address().IPv6String()
|
state.IPv6 = e.wgInterface.Address().IPv6String()
|
||||||
@@ -1197,7 +1202,6 @@ func (e *Engine) receiveManagementEvents() {
|
|||||||
e.config.RosenpassEnabled,
|
e.config.RosenpassEnabled,
|
||||||
e.config.RosenpassPermissive,
|
e.config.RosenpassPermissive,
|
||||||
&e.config.ServerSSHAllowed,
|
&e.config.ServerSSHAllowed,
|
||||||
&e.config.ServerVNCAllowed,
|
|
||||||
e.config.DisableClientRoutes,
|
e.config.DisableClientRoutes,
|
||||||
e.config.DisableServerRoutes,
|
e.config.DisableServerRoutes,
|
||||||
e.config.DisableDNS,
|
e.config.DisableDNS,
|
||||||
@@ -1387,11 +1391,6 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
|||||||
e.updateSSHServerAuth(networkMap.GetSshAuth())
|
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
|
// 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)
|
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
|
||||||
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
||||||
@@ -1847,7 +1846,6 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
|||||||
e.config.RosenpassEnabled,
|
e.config.RosenpassEnabled,
|
||||||
e.config.RosenpassPermissive,
|
e.config.RosenpassPermissive,
|
||||||
&e.config.ServerSSHAllowed,
|
&e.config.ServerSSHAllowed,
|
||||||
&e.config.ServerVNCAllowed,
|
|
||||||
e.config.DisableClientRoutes,
|
e.config.DisableClientRoutes,
|
||||||
e.config.DisableServerRoutes,
|
e.config.DisableServerRoutes,
|
||||||
e.config.DisableDNS,
|
e.config.DisableDNS,
|
||||||
@@ -2612,16 +2610,3 @@ func decodeRelayIP(b []byte) netip.Addr {
|
|||||||
}
|
}
|
||||||
return ip.Unmap()
|
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
97
client/internal/engine_authsession.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplySessionDeadline propagates the absolute SSO session deadline carried on
|
||||||
|
// LoginResponse / SyncResponse to both the watcher (for the edge-triggered
|
||||||
|
// warning) and the status recorder (for the SubscribeStatus / Status RPC
|
||||||
|
// snapshot the UI consumes).
|
||||||
|
//
|
||||||
|
// The wire field is 3-state:
|
||||||
|
// - nil → snapshot carries no info; keep the
|
||||||
|
// previously-anchored deadline (no-op)
|
||||||
|
// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is
|
||||||
|
// disabled; clear both sinks
|
||||||
|
// - valid timestamp → new deadline; arm watcher, expose on
|
||||||
|
// status recorder
|
||||||
|
//
|
||||||
|
// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected
|
||||||
|
// value is treated as a clear on both sinks: the alternative — leaving the
|
||||||
|
// previously-known deadline in place — risks the UI confidently displaying
|
||||||
|
// a stale "expires in X" while the server has actually invalidated it.
|
||||||
|
func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
|
||||||
|
if ts == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var deadline time.Time
|
||||||
|
// Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled".
|
||||||
|
// Everything else flows through Watcher.Update, whose sanity-checks
|
||||||
|
// reject out-of-range / pre-epoch / far-future / too-stale values; the
|
||||||
|
// catch-block below converts any rejection into a clear.
|
||||||
|
if ts.GetSeconds() != 0 || ts.GetNanos() != 0 {
|
||||||
|
deadline = ts.AsTime().UTC()
|
||||||
|
}
|
||||||
|
if e.sessionWatcher != nil {
|
||||||
|
if err := e.sessionWatcher.Update(deadline); err != nil {
|
||||||
|
log.Errorf("auth session deadline rejected: %v, clearing", err)
|
||||||
|
deadline = time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.statusRecorder != nil {
|
||||||
|
e.statusRecorder.SetSessionExpiresAt(deadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissSessionWarning records the user's "Dismiss" click on the
|
||||||
|
// T-WarningLead interactive notification and suppresses the upcoming
|
||||||
|
// T-FinalWarningLead fallback for the current deadline. No-op when the
|
||||||
|
// watcher is not running or holds no deadline.
|
||||||
|
func (e *Engine) DismissSessionWarning() {
|
||||||
|
if e.sessionWatcher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.sessionWatcher.Dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtendAuthSession asks the management server to refresh the SSO session
|
||||||
|
// expiry deadline using the supplied JWT, then mirrors the new deadline into
|
||||||
|
// the daemon's state. The tunnel is untouched; no resync, no reconnect.
|
||||||
|
//
|
||||||
|
// Returns the new absolute UTC deadline (or zero time when the server
|
||||||
|
// reports the peer is not eligible for extension).
|
||||||
|
func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) {
|
||||||
|
if jwtToken == "" {
|
||||||
|
return time.Time{}, errors.New("jwt token is required")
|
||||||
|
}
|
||||||
|
if e.mgmClient == nil {
|
||||||
|
return time.Time{}, errors.New("management client is not initialised")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := system.GetInfoWithChecks(ctx, e.checks)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to collect system info for session extend: %v", err)
|
||||||
|
info = system.GetInfo(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("extend auth session on management: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(resp.GetSessionExpiresAt())
|
||||||
|
|
||||||
|
if resp.GetSessionExpiresAt().IsValid() {
|
||||||
|
return resp.GetSessionExpiresAt().AsTime().UTC(), nil
|
||||||
|
}
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
78
client/internal/engine_session_deadline_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the
|
||||||
|
// wire field carried on LoginResponse / SyncResponse:
|
||||||
|
//
|
||||||
|
// - nil pointer → no info; previously-anchored deadline survives
|
||||||
|
// - explicit zero value → "expiry disabled" sentinel; both sinks cleared
|
||||||
|
// - valid future timestamp → new deadline propagated to both sinks
|
||||||
|
func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
||||||
|
newEngine := func() *Engine {
|
||||||
|
recorder := peer.NewRecorder("")
|
||||||
|
return &Engine{
|
||||||
|
statusRecorder: recorder,
|
||||||
|
sessionWatcher: sessionwatch.New(recorder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(deadline))
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline),
|
||||||
|
"status recorder should hold the new deadline")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(nil)
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded),
|
||||||
|
"nil snapshot must not disturb the existing deadline")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||||
|
|
||||||
|
// Explicit zero Timestamp{} (seconds=0, nanos=0) is the
|
||||||
|
// "expiry disabled / not SSO" sentinel.
|
||||||
|
e.ApplySessionDeadline(×tamppb.Timestamp{})
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||||
|
"explicit zero sentinel must clear the deadline")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid timestamp clears the deadline", func(t *testing.T) {
|
||||||
|
e := newEngine()
|
||||||
|
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||||
|
e.ApplySessionDeadline(timestamppb.New(seeded))
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
||||||
|
|
||||||
|
// Out-of-range nanos → IsValid()==false; same-meaning as the
|
||||||
|
// disabled sentinel for downstream sinks.
|
||||||
|
e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1})
|
||||||
|
|
||||||
|
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||||
|
"invalid timestamp must clear the deadline")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||||
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
||||||
sshserver "github.com/netbirdio/netbird/client/ssh/server"
|
sshserver "github.com/netbirdio/netbird/client/ssh/server"
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
@@ -237,18 +237,22 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
|
|||||||
return errors.New("wg interface not initialized")
|
return errors.New("wg interface not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
wgAddr := e.wgInterface.Address()
|
|
||||||
serverConfig := &sshserver.Config{
|
serverConfig := &sshserver.Config{
|
||||||
HostKeyPEM: e.config.SSHKey,
|
HostKeyPEM: e.config.SSHKey,
|
||||||
JWT: jwtConfig,
|
JWT: jwtConfig,
|
||||||
NetstackNet: e.wgInterface.GetNet(),
|
|
||||||
NetworkValidation: wgAddr,
|
|
||||||
}
|
}
|
||||||
server := sshserver.New(serverConfig)
|
server := sshserver.New(serverConfig)
|
||||||
|
|
||||||
|
wgAddr := e.wgInterface.Address()
|
||||||
|
server.SetNetworkValidation(wgAddr)
|
||||||
|
|
||||||
netbirdIP := wgAddr.IP
|
netbirdIP := wgAddr.IP
|
||||||
listenAddr := netip.AddrPortFrom(netbirdIP, sshserver.InternalSSHPort)
|
listenAddr := netip.AddrPortFrom(netbirdIP, sshserver.InternalSSHPort)
|
||||||
|
|
||||||
|
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||||
|
server.SetNetstackNet(netstackNet)
|
||||||
|
}
|
||||||
|
|
||||||
e.configureSSHServer(server)
|
e.configureSSHServer(server)
|
||||||
|
|
||||||
if err := server.Start(e.ctx, listenAddr); err != nil {
|
if err := server.Start(e.ctx, listenAddr); err != nil {
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
//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"
|
|
||||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
|
||||||
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 _, e := range vncAuth.GetSessionPubKeys() {
|
|
||||||
pub := e.GetPubKey()
|
|
||||||
if len(pub) != 32 {
|
|
||||||
log.Warnf("VNC session pubkey wrong length %d", len(pub))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hash := e.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: e.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 netstackNet := e.wgInterface.GetNet(); netstackNet != 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"
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
//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 }
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
//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,36 +120,6 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
|
|||||||
m.trimLocked()
|
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) {
|
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
|
||||||
result := "success"
|
result := "success"
|
||||||
if !success {
|
if !success {
|
||||||
|
|||||||
@@ -59,11 +59,6 @@ type metricsImplementation interface {
|
|||||||
// RecordLoginDuration records how long the login to management took
|
// RecordLoginDuration records how long the login to management took
|
||||||
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
|
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 exports metrics in InfluxDB line protocol format
|
||||||
Export(w io.Writer) error
|
Export(w io.Writer) error
|
||||||
|
|
||||||
@@ -83,21 +78,6 @@ type ClientMetrics struct {
|
|||||||
pushCancel context.CancelFunc
|
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
|
// ConnectionStageTimestamps holds timestamps for each connection stage
|
||||||
type ConnectionStageTimestamps struct {
|
type ConnectionStageTimestamps struct {
|
||||||
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
|
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
|
||||||
@@ -147,17 +127,6 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
|
|||||||
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
|
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
|
// RecordLoginDuration records how long the login to management server took
|
||||||
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
|
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
|
|||||||
@@ -73,9 +73,6 @@ func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.
|
|||||||
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
|
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 {
|
func (m *mockMetrics) Export(w io.Writer) error {
|
||||||
if m.exportData != "" {
|
if m.exportData != "" {
|
||||||
_, err := w.Write([]byte(m.exportData))
|
_, err := w.Write([]byte(m.exportData))
|
||||||
|
|||||||
@@ -202,6 +202,12 @@ type Status struct {
|
|||||||
notifier *notifier
|
notifier *notifier
|
||||||
rosenpassEnabled bool
|
rosenpassEnabled bool
|
||||||
rosenpassPermissive bool
|
rosenpassPermissive bool
|
||||||
|
// sessionExpiresAt is the absolute UTC instant at which the peer's SSO
|
||||||
|
// session expires. Zero when the peer is not SSO-tracked or login
|
||||||
|
// expiration is disabled. Populated from management LoginResponse /
|
||||||
|
// SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC
|
||||||
|
// so the UI can show remaining time without itself talking to mgm.
|
||||||
|
sessionExpiresAt time.Time
|
||||||
nsGroupStates []NSGroupState
|
nsGroupStates []NSGroupState
|
||||||
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
||||||
lazyConnectionEnabled bool
|
lazyConnectionEnabled bool
|
||||||
@@ -217,6 +223,14 @@ type Status struct {
|
|||||||
eventStreams map[string]chan *proto.SystemEvent
|
eventStreams map[string]chan *proto.SystemEvent
|
||||||
eventQueue *EventQueue
|
eventQueue *EventQueue
|
||||||
|
|
||||||
|
// stateChangeStreams fan-out connection-state changes (connected /
|
||||||
|
// disconnected / connecting / address change / peers list change) to
|
||||||
|
// every active SubscribeStatus gRPC stream. Each subscriber gets a
|
||||||
|
// buffered chan; the notifier non-blockingly pings them so a slow
|
||||||
|
// consumer can never stall the daemon.
|
||||||
|
stateChangeMux sync.Mutex
|
||||||
|
stateChangeStreams map[string]chan struct{}
|
||||||
|
|
||||||
ingressGwMgr *ingressgw.Manager
|
ingressGwMgr *ingressgw.Manager
|
||||||
|
|
||||||
routeIDLookup routeIDLookup
|
routeIDLookup routeIDLookup
|
||||||
@@ -230,6 +244,7 @@ func NewRecorder(mgmAddress string) *Status {
|
|||||||
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
||||||
eventStreams: make(map[string]chan *proto.SystemEvent),
|
eventStreams: make(map[string]chan *proto.SystemEvent),
|
||||||
eventQueue: NewEventQueue(eventQueueSize),
|
eventQueue: NewEventQueue(eventQueueSize),
|
||||||
|
stateChangeStreams: make(map[string]chan struct{}),
|
||||||
offlinePeers: make([]State, 0),
|
offlinePeers: make([]State, 0),
|
||||||
notifier: newNotifier(),
|
notifier: newNotifier(),
|
||||||
mgmAddress: mgmAddress,
|
mgmAddress: mgmAddress,
|
||||||
@@ -360,6 +375,7 @@ func (d *Status) UpdatePeerState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +401,7 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R
|
|||||||
|
|
||||||
// todo: consider to make sense of this notification or not
|
// todo: consider to make sense of this notification or not
|
||||||
d.notifier.peerListChanged(numPeers)
|
d.notifier.peerListChanged(numPeers)
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +427,7 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error {
|
|||||||
|
|
||||||
// todo: consider to make sense of this notification or not
|
// todo: consider to make sense of this notification or not
|
||||||
d.notifier.peerListChanged(numPeers)
|
d.notifier.peerListChanged(numPeers)
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +477,7 @@ func (d *Status) UpdatePeerICEState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +514,7 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +550,7 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +589,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,6 +683,7 @@ func (d *Status) FinishPeerListModifications() {
|
|||||||
for _, rd := range dispatches {
|
for _, rd := range dispatches {
|
||||||
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
|
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
|
func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
|
||||||
@@ -719,6 +742,32 @@ func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.localAddressChanged(fqdn, ip)
|
d.notifier.localAddressChanged(fqdn, ip)
|
||||||
|
d.notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionExpiresAt records the absolute UTC instant at which the peer's
|
||||||
|
// SSO session is set to expire. Pass the zero value to clear (e.g. when the
|
||||||
|
// management server stops publishing a deadline because login expiration was
|
||||||
|
// disabled or the peer is not SSO-tracked). Same-value updates are no-ops;
|
||||||
|
// real changes fan out via notifyStateChange so SubscribeStatus consumers
|
||||||
|
// pick up the new deadline on their next read.
|
||||||
|
func (d *Status) SetSessionExpiresAt(deadline time.Time) {
|
||||||
|
d.mux.Lock()
|
||||||
|
if d.sessionExpiresAt.Equal(deadline) {
|
||||||
|
d.mux.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.sessionExpiresAt = deadline
|
||||||
|
d.mux.Unlock()
|
||||||
|
d.notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
|
||||||
|
// or the zero value when no deadline is tracked.
|
||||||
|
func (d *Status) GetSessionExpiresAt() time.Time {
|
||||||
|
d.mux.Lock()
|
||||||
|
defer d.mux.Unlock()
|
||||||
|
return d.sessionExpiresAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLocalPeerStateRoute adds a route to the local peer state
|
// AddLocalPeerStateRoute adds a route to the local peer state
|
||||||
@@ -787,6 +836,7 @@ func (d *Status) CleanLocalPeerState() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.localAddressChanged(fqdn, ip)
|
d.notifier.localAddressChanged(fqdn, ip)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkManagementDisconnected sets ManagementState to disconnected
|
// MarkManagementDisconnected sets ManagementState to disconnected
|
||||||
@@ -799,6 +849,7 @@ func (d *Status) MarkManagementDisconnected(err error) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkManagementConnected sets ManagementState to connected
|
// MarkManagementConnected sets ManagementState to connected
|
||||||
@@ -811,6 +862,7 @@ func (d *Status) MarkManagementConnected() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSignalAddress update the address of the signal server
|
// UpdateSignalAddress update the address of the signal server
|
||||||
@@ -851,6 +903,7 @@ func (d *Status) MarkSignalDisconnected(err error) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkSignalConnected sets SignalState to connected
|
// MarkSignalConnected sets SignalState to connected
|
||||||
@@ -863,6 +916,7 @@ func (d *Status) MarkSignalConnected() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
|
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
|
||||||
@@ -1060,16 +1114,19 @@ func (d *Status) GetFullStatus() FullStatus {
|
|||||||
// ClientStart will notify all listeners about the new service state
|
// ClientStart will notify all listeners about the new service state
|
||||||
func (d *Status) ClientStart() {
|
func (d *Status) ClientStart() {
|
||||||
d.notifier.clientStart()
|
d.notifier.clientStart()
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientStop will notify all listeners about the new service state
|
// ClientStop will notify all listeners about the new service state
|
||||||
func (d *Status) ClientStop() {
|
func (d *Status) ClientStop() {
|
||||||
d.notifier.clientStop()
|
d.notifier.clientStop()
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientTeardown will notify all listeners about the service is under teardown
|
// ClientTeardown will notify all listeners about the service is under teardown
|
||||||
func (d *Status) ClientTeardown() {
|
func (d *Status) ClientTeardown() {
|
||||||
d.notifier.clientTearDown()
|
d.notifier.clientTearDown()
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConnectionListener set a listener to the notifier
|
// SetConnectionListener set a listener to the notifier
|
||||||
@@ -1191,15 +1248,6 @@ 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
|
// UnsubscribeFromEvents removes an event subscription
|
||||||
func (d *Status) UnsubscribeFromEvents(sub *EventSubscription) {
|
func (d *Status) UnsubscribeFromEvents(sub *EventSubscription) {
|
||||||
if sub == nil {
|
if sub == nil {
|
||||||
@@ -1220,6 +1268,62 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent {
|
|||||||
return d.eventQueue.GetAll()
|
return d.eventQueue.GetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubscribeToStateChanges hands back a channel that receives a tick on
|
||||||
|
// every connection-state change (connected / disconnected / connecting /
|
||||||
|
// address change / peers-list change). The channel is buffered to one
|
||||||
|
// pending tick so a coalesced burst still wakes the consumer exactly
|
||||||
|
// once. Pass the returned id to UnsubscribeFromStateChanges to detach.
|
||||||
|
func (d *Status) SubscribeToStateChanges() (string, <-chan struct{}) {
|
||||||
|
d.stateChangeMux.Lock()
|
||||||
|
defer d.stateChangeMux.Unlock()
|
||||||
|
|
||||||
|
id := uuid.New().String()
|
||||||
|
ch := make(chan struct{}, 1)
|
||||||
|
d.stateChangeStreams[id] = ch
|
||||||
|
return id, ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel
|
||||||
|
// and closes it so any consumer goroutine selecting on the channel
|
||||||
|
// unblocks cleanly.
|
||||||
|
func (d *Status) UnsubscribeFromStateChanges(id string) {
|
||||||
|
d.stateChangeMux.Lock()
|
||||||
|
defer d.stateChangeMux.Unlock()
|
||||||
|
|
||||||
|
if ch, ok := d.stateChangeStreams[id]; ok {
|
||||||
|
close(ch)
|
||||||
|
delete(d.stateChangeStreams, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops
|
||||||
|
// the tick if a subscriber's buffer is full — by definition the consumer
|
||||||
|
// is already going to fetch the latest snapshot, so multiple pending ticks
|
||||||
|
// would be redundant.
|
||||||
|
func (d *Status) notifyStateChange() {
|
||||||
|
d.stateChangeMux.Lock()
|
||||||
|
defer d.stateChangeMux.Unlock()
|
||||||
|
|
||||||
|
for _, ch := range d.stateChangeStreams {
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyStateChange is the public wake-the-subscribers entry point used by
|
||||||
|
// callers that mutate state outside the peer recorder — most importantly
|
||||||
|
// the connect-state machine, which writes StatusNeedsLogin into the
|
||||||
|
// shared contextState (client/internal/state.go) without touching any
|
||||||
|
// recorder field. Without this push the SubscribeStatus stream stays on
|
||||||
|
// the previous snapshot until an unrelated peer/management/signal
|
||||||
|
// change happens to fire notifyStateChange, leaving the UI's status
|
||||||
|
// out of sync with the daemon.
|
||||||
|
func (d *Status) NotifyStateChange() {
|
||||||
|
d.notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.Unlock()
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ type ConfigInput struct {
|
|||||||
StateFilePath string
|
StateFilePath string
|
||||||
PreSharedKey *string
|
PreSharedKey *string
|
||||||
ServerSSHAllowed *bool
|
ServerSSHAllowed *bool
|
||||||
ServerVNCAllowed *bool
|
|
||||||
DisableVNCApproval *bool
|
|
||||||
EnableSSHRoot *bool
|
EnableSSHRoot *bool
|
||||||
EnableSSHSFTP *bool
|
EnableSSHSFTP *bool
|
||||||
EnableSSHLocalPortForwarding *bool
|
EnableSSHLocalPortForwarding *bool
|
||||||
@@ -118,8 +116,6 @@ type Config struct {
|
|||||||
RosenpassEnabled bool
|
RosenpassEnabled bool
|
||||||
RosenpassPermissive bool
|
RosenpassPermissive bool
|
||||||
ServerSSHAllowed *bool
|
ServerSSHAllowed *bool
|
||||||
ServerVNCAllowed *bool
|
|
||||||
DisableVNCApproval *bool
|
|
||||||
EnableSSHRoot *bool
|
EnableSSHRoot *bool
|
||||||
EnableSSHSFTP *bool
|
EnableSSHSFTP *bool
|
||||||
EnableSSHLocalPortForwarding *bool
|
EnableSSHLocalPortForwarding *bool
|
||||||
@@ -422,33 +418,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
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 != nil && input.EnableSSHRoot != config.EnableSSHRoot {
|
||||||
if *input.EnableSSHRoot {
|
if *input.EnableSSHRoot {
|
||||||
log.Infof("enabling SSH root login")
|
log.Infof("enabling SSH root login")
|
||||||
|
|||||||
@@ -188,9 +188,7 @@ func (d *Detector) triggerCallback(event EventType, cb func(event EventType), do
|
|||||||
}
|
}
|
||||||
|
|
||||||
doneChan := make(chan struct{})
|
doneChan := make(chan struct{})
|
||||||
// macOS forces sleep ~30s after kIOMessageSystemWillSleep, so block long
|
timeout := time.NewTimer(500 * time.Millisecond)
|
||||||
// enough for teardown to finish while staying under that deadline.
|
|
||||||
timeout := time.NewTimer(20 * time.Second)
|
|
||||||
defer timeout.Stop()
|
defer timeout.Stop()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -33,17 +33,34 @@ func CtxGetState(ctx context.Context) *contextState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type contextState struct {
|
type contextState struct {
|
||||||
err error
|
err error
|
||||||
status StatusType
|
status StatusType
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
|
onChange func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOnChange installs a callback fired after every successful Set. Used by
|
||||||
|
// the daemon to wire the status recorder's notifyStateChange so any
|
||||||
|
// state.Set in the connect/login paths pushes a fresh snapshot to
|
||||||
|
// SubscribeStatus subscribers without each callsite having to opt in.
|
||||||
|
// The callback runs outside the contextState mutex to avoid a lock-order
|
||||||
|
// dependency with the recorder's stateChangeMux.
|
||||||
|
func (c *contextState) SetOnChange(fn func()) {
|
||||||
|
c.mutex.Lock()
|
||||||
|
c.onChange = fn
|
||||||
|
c.mutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *contextState) Set(update StatusType) {
|
func (c *contextState) Set(update StatusType) {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
|
||||||
|
|
||||||
c.status = update
|
c.status = update
|
||||||
c.err = nil
|
c.err = nil
|
||||||
|
cb := c.onChange
|
||||||
|
c.mutex.Unlock()
|
||||||
|
|
||||||
|
if cb != nil {
|
||||||
|
cb()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *contextState) Status() (StatusType, error) {
|
func (c *contextState) Status() (StatusType, error) {
|
||||||
@@ -57,6 +74,17 @@ func (c *contextState) Status() (StatusType, error) {
|
|||||||
return c.status, nil
|
return c.status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CurrentStatus returns the last status set via Set, ignoring any wrapped
|
||||||
|
// error. Use when the status is needed for reporting purposes (e.g. the
|
||||||
|
// status snapshot stream) and a transient wrapped error from a retry loop
|
||||||
|
// shouldn't blank out the underlying status.
|
||||||
|
func (c *contextState) CurrentStatus() StatusType {
|
||||||
|
c.mutex.Lock()
|
||||||
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
func (c *contextState) Wrap(err error) error {
|
func (c *contextState) Wrap(err error) error {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|||||||
@@ -74,14 +74,6 @@ 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
|
// Start starts the state manager periodic save routine
|
||||||
func (m *Manager) Start() {
|
func (m *Manager) Start() {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
|
|||||||
@@ -32,9 +32,6 @@
|
|||||||
</File>
|
</File>
|
||||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\wintun.dll" />
|
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\wintun.dll" />
|
||||||
<File Id="NetbirdToastIcon" Name="netbird.png" Source=".\client\ui\assets\netbird.png" />
|
<File Id="NetbirdToastIcon" Name="netbird.png" Source=".\client\ui\assets\netbird.png" />
|
||||||
<?if $(var.ArchSuffix) = "amd64" ?>
|
|
||||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\opengl32.dll" />
|
|
||||||
<?endif ?>
|
|
||||||
|
|
||||||
<ServiceInstall
|
<ServiceInstall
|
||||||
Id="NetBirdService"
|
Id="NetBirdService"
|
||||||
@@ -62,6 +59,14 @@
|
|||||||
<Component Id="NetbirdAumidRegistry" Guid="*">
|
<Component Id="NetbirdAumidRegistry" Guid="*">
|
||||||
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
||||||
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
<!-- Pre-seed the CLSID the Wails notifications service reads on
|
||||||
|
first startup (notifications_windows.go:getGUID looks for
|
||||||
|
the CustomActivator value under this key). Without this
|
||||||
|
the service generates a fresh per-install UUID, which
|
||||||
|
diverges from the ToastActivatorCLSID set on the Start
|
||||||
|
Menu / Desktop shortcuts above and the COM activator
|
||||||
|
never fires when a toast is clicked. -->
|
||||||
|
<RegistryValue Name="CustomActivator" Type="string" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
|
||||||
</RegistryKey>
|
</RegistryKey>
|
||||||
</Component>
|
</Component>
|
||||||
<!-- Drop the HKCU Run\Netbird value written by legacy NSIS installers. -->
|
<!-- Drop the HKCU Run\Netbird value written by legacy NSIS installers. -->
|
||||||
@@ -110,10 +115,40 @@
|
|||||||
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
||||||
<util:CloseApplication Id="CloseNetBirdUI" CloseMessage="no" Target="netbird-ui.exe" RebootPrompt="no" TerminateProcess="0" />
|
<util:CloseApplication Id="CloseNetBirdUI" CloseMessage="no" Target="netbird-ui.exe" RebootPrompt="no" TerminateProcess="0" />
|
||||||
|
|
||||||
|
<!-- WebView2 evergreen runtime detection.
|
||||||
|
Probe both the per-machine and per-user EdgeUpdate keys; if either
|
||||||
|
reports a non-empty `pv` value the runtime is already installed
|
||||||
|
and we skip the bootstrapper. -->
|
||||||
|
<Property Id="WEBVIEW2_VERSION_HKLM">
|
||||||
|
<RegistrySearch Id="WV2HKLM" Root="HKLM"
|
||||||
|
Key="SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
||||||
|
Name="pv" Type="raw" Bitness="always64" />
|
||||||
|
</Property>
|
||||||
|
<Property Id="WEBVIEW2_VERSION_HKCU">
|
||||||
|
<RegistrySearch Id="WV2HKCU" Root="HKCU"
|
||||||
|
Key="Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
||||||
|
Name="pv" Type="raw" />
|
||||||
|
</Property>
|
||||||
|
|
||||||
|
<!-- Embed the bootstrapper payload. Path is relative to the WiX
|
||||||
|
working directory; sign-pipelines stages it next to client/
|
||||||
|
via `wails3 generate webview2bootstrapper`. -->
|
||||||
|
<Binary Id="WebView2Bootstrapper" SourceFile=".\client\MicrosoftEdgeWebview2Setup.exe" />
|
||||||
|
|
||||||
|
<CustomAction Id="InstallWebView2"
|
||||||
|
BinaryRef="WebView2Bootstrapper"
|
||||||
|
ExeCommand="/silent /install"
|
||||||
|
Execute="deferred"
|
||||||
|
Impersonate="no"
|
||||||
|
Return="check" />
|
||||||
|
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<Custom Action="InstallWebView2" Before="InstallFinalize"
|
||||||
|
Condition="NOT WEBVIEW2_VERSION_HKLM AND NOT WEBVIEW2_VERSION_HKCU AND NOT REMOVE" />
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
|
||||||
<!-- Icons -->
|
<!-- Icons -->
|
||||||
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\assets\netbird.ico" />
|
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\build\windows\icon.ico" />
|
||||||
<Property Id="ARPPRODUCTICON" Value="NetbirdIcon" />
|
<Property Id="ARPPRODUCTICON" Value="NetbirdIcon" />
|
||||||
|
|
||||||
</Package>
|
</Package>
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ service DaemonService {
|
|||||||
// Status of the service.
|
// Status of the service.
|
||||||
rpc Status(StatusRequest) returns (StatusResponse) {}
|
rpc Status(StatusRequest) returns (StatusResponse) {}
|
||||||
|
|
||||||
|
// SubscribeStatus pushes a fresh StatusResponse on connection state
|
||||||
|
// changes (Connected / Disconnected / Connecting / address change /
|
||||||
|
// peers list change). The first message on the stream is the current
|
||||||
|
// snapshot, so a freshly-subscribed UI doesn't need to also call Status.
|
||||||
|
rpc SubscribeStatus(StatusRequest) returns (stream StatusResponse) {}
|
||||||
|
|
||||||
// Down stops engine work in the daemon.
|
// Down stops engine work in the daemon.
|
||||||
rpc Down(DownRequest) returns (DownResponse) {}
|
rpc Down(DownRequest) returns (DownResponse) {}
|
||||||
|
|
||||||
@@ -109,6 +115,25 @@ service DaemonService {
|
|||||||
// WaitJWTToken waits for JWT authentication completion
|
// WaitJWTToken waits for JWT authentication completion
|
||||||
rpc WaitJWTToken(WaitJWTTokenRequest) returns (WaitJWTTokenResponse) {}
|
rpc WaitJWTToken(WaitJWTTokenRequest) returns (WaitJWTTokenResponse) {}
|
||||||
|
|
||||||
|
// RequestExtendAuthSession initiates an SSO session-extension flow.
|
||||||
|
// The daemon prepares a PKCE/device-code request against the IdP and
|
||||||
|
// returns the verification URI; the UI is expected to open it. The flow
|
||||||
|
// state is kept in the daemon until WaitExtendAuthSession completes it.
|
||||||
|
rpc RequestExtendAuthSession(RequestExtendAuthSessionRequest) returns (RequestExtendAuthSessionResponse) {}
|
||||||
|
|
||||||
|
// WaitExtendAuthSession blocks until the user finishes the SSO step
|
||||||
|
// started by RequestExtendAuthSession, then forwards the resulting JWT
|
||||||
|
// to the management server's ExtendAuthSession RPC. Returns the new
|
||||||
|
// session expiry deadline. The tunnel stays up the entire time.
|
||||||
|
rpc WaitExtendAuthSession(WaitExtendAuthSessionRequest) returns (WaitExtendAuthSessionResponse) {}
|
||||||
|
|
||||||
|
// DismissSessionWarning records that the user clicked "Dismiss" on the
|
||||||
|
// T-WarningLead interactive notification, suppressing the auto-opened
|
||||||
|
// SessionAboutToExpire dialog that would otherwise fire at
|
||||||
|
// T-FinalWarningLead for the current deadline. Idempotent and best-effort:
|
||||||
|
// a missed call only means the fallback dialog will still appear.
|
||||||
|
rpc DismissSessionWarning(DismissSessionWarningRequest) returns (DismissSessionWarningResponse) {}
|
||||||
|
|
||||||
// StartCPUProfile starts CPU profiling in the daemon
|
// StartCPUProfile starts CPU profiling in the daemon
|
||||||
rpc StartCPUProfile(StartCPUProfileRequest) returns (StartCPUProfileResponse) {}
|
rpc StartCPUProfile(StartCPUProfileRequest) returns (StartCPUProfileResponse) {}
|
||||||
|
|
||||||
@@ -119,14 +144,6 @@ service DaemonService {
|
|||||||
|
|
||||||
// ExposeService exposes a local port via the NetBird reverse proxy
|
// ExposeService exposes a local port via the NetBird reverse proxy
|
||||||
rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {}
|
rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {}
|
||||||
|
|
||||||
// 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) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -213,10 +230,6 @@ message LoginRequest {
|
|||||||
optional bool disableSSHAuth = 38;
|
optional bool disableSSHAuth = 38;
|
||||||
optional int32 sshJWTCacheTTL = 39;
|
optional int32 sshJWTCacheTTL = 39;
|
||||||
optional bool disable_ipv6 = 40;
|
optional bool disable_ipv6 = 40;
|
||||||
|
|
||||||
optional bool serverVNCAllowed = 41;
|
|
||||||
|
|
||||||
optional bool disableVNCApproval = 42;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message LoginResponse {
|
message LoginResponse {
|
||||||
@@ -239,6 +252,12 @@ message UpRequest {
|
|||||||
optional string profileName = 1;
|
optional string profileName = 1;
|
||||||
optional string username = 2;
|
optional string username = 2;
|
||||||
reserved 3;
|
reserved 3;
|
||||||
|
// async instructs the daemon to start the connection attempt and return
|
||||||
|
// immediately without waiting for the engine to become ready. Status updates
|
||||||
|
// are delivered via the SubscribeStatus stream. When false (the default) the
|
||||||
|
// RPC blocks until the engine is running or gives up, which is the behaviour
|
||||||
|
// needed by the CLI.
|
||||||
|
bool async = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UpResponse {}
|
message UpResponse {}
|
||||||
@@ -256,6 +275,10 @@ message StatusResponse{
|
|||||||
FullStatus fullStatus = 2;
|
FullStatus fullStatus = 2;
|
||||||
// NetBird daemon version
|
// NetBird daemon version
|
||||||
string daemonVersion = 3;
|
string daemonVersion = 3;
|
||||||
|
// Absolute UTC instant at which the peer's SSO session expires.
|
||||||
|
// Unset when the peer is not SSO-registered or login expiration is disabled.
|
||||||
|
// The UI derives "warning active" from this value and its own clock.
|
||||||
|
google.protobuf.Timestamp sessionExpiresAt = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message DownRequest {}
|
message DownRequest {}
|
||||||
@@ -326,10 +349,6 @@ message GetConfigResponse {
|
|||||||
int32 sshJWTCacheTTL = 26;
|
int32 sshJWTCacheTTL = 26;
|
||||||
|
|
||||||
bool disable_ipv6 = 27;
|
bool disable_ipv6 = 27;
|
||||||
|
|
||||||
bool serverVNCAllowed = 28;
|
|
||||||
|
|
||||||
bool disableVNCApproval = 29;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeerState contains the latest state of a peer
|
// PeerState contains the latest state of a peer
|
||||||
@@ -410,25 +429,6 @@ message SSHServerState {
|
|||||||
repeated SSHSessionInfo sessions = 2;
|
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
|
// FullStatus contains the full state held by the Status instance
|
||||||
message FullStatus {
|
message FullStatus {
|
||||||
ManagementState managementState = 1;
|
ManagementState managementState = 1;
|
||||||
@@ -443,7 +443,6 @@ message FullStatus {
|
|||||||
|
|
||||||
bool lazyConnectionEnabled = 9;
|
bool lazyConnectionEnabled = 9;
|
||||||
SSHServerState sshServerState = 10;
|
SSHServerState sshServerState = 10;
|
||||||
VNCServerState vncServerState = 11;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Networks
|
// Networks
|
||||||
@@ -631,7 +630,6 @@ message SystemEvent {
|
|||||||
AUTHENTICATION = 2;
|
AUTHENTICATION = 2;
|
||||||
CONNECTIVITY = 3;
|
CONNECTIVITY = 3;
|
||||||
SYSTEM = 4;
|
SYSTEM = 4;
|
||||||
APPROVAL = 5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
string id = 1;
|
string id = 1;
|
||||||
@@ -715,10 +713,6 @@ message SetConfigRequest {
|
|||||||
optional bool disableSSHAuth = 33;
|
optional bool disableSSHAuth = 33;
|
||||||
optional int32 sshJWTCacheTTL = 34;
|
optional int32 sshJWTCacheTTL = 34;
|
||||||
optional bool disable_ipv6 = 35;
|
optional bool disable_ipv6 = 35;
|
||||||
|
|
||||||
optional bool serverVNCAllowed = 36;
|
|
||||||
|
|
||||||
optional bool disableVNCApproval = 37;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message SetConfigResponse{}
|
message SetConfigResponse{}
|
||||||
@@ -839,6 +833,55 @@ message WaitJWTTokenResponse {
|
|||||||
int64 expiresIn = 3;
|
int64 expiresIn = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow.
|
||||||
|
message RequestExtendAuthSessionRequest {
|
||||||
|
// Optional OIDC login_hint (typically the user's email) to pre-fill the
|
||||||
|
// IdP login form.
|
||||||
|
optional string hint = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestExtendAuthSessionResponse carries the verification URI the UI
|
||||||
|
// should open in a browser. The daemon retains the flow state and resolves
|
||||||
|
// it via WaitExtendAuthSession.
|
||||||
|
message RequestExtendAuthSessionResponse {
|
||||||
|
// verification URI for the user to open in the browser
|
||||||
|
string verificationURI = 1;
|
||||||
|
// complete verification URI (with embedded user code)
|
||||||
|
string verificationURIComplete = 2;
|
||||||
|
// user code to enter on verification URI (for device-code flows)
|
||||||
|
string userCode = 3;
|
||||||
|
// device code for matching the WaitExtendAuthSession call to this flow
|
||||||
|
string deviceCode = 4;
|
||||||
|
// expiration time in seconds for the device code / PKCE flow
|
||||||
|
int64 expiresIn = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitExtendAuthSessionRequest is sent by the UI after it opens the
|
||||||
|
// verification URI. The daemon blocks on this call until the user
|
||||||
|
// completes (or aborts) the SSO step.
|
||||||
|
message WaitExtendAuthSessionRequest {
|
||||||
|
// device code returned by RequestExtendAuthSession
|
||||||
|
string deviceCode = 1;
|
||||||
|
// user code for verification
|
||||||
|
string userCode = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitExtendAuthSessionResponse carries the refreshed deadline returned
|
||||||
|
// by the management server. Unset when the management server reports the
|
||||||
|
// peer is not eligible for session extension.
|
||||||
|
message WaitExtendAuthSessionResponse {
|
||||||
|
google.protobuf.Timestamp sessionExpiresAt = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissSessionWarningRequest is sent by the UI when the user clicks
|
||||||
|
// "Dismiss" on the T-WarningLead notification.
|
||||||
|
message DismissSessionWarningRequest {}
|
||||||
|
|
||||||
|
// DismissSessionWarningResponse acknowledges the dismissal. Carries no
|
||||||
|
// payload — the daemon's only obligation is to silence the upcoming
|
||||||
|
// T-FinalWarningLead fallback for the current deadline.
|
||||||
|
message DismissSessionWarningResponse {}
|
||||||
|
|
||||||
// StartCPUProfileRequest for starting CPU profiling
|
// StartCPUProfileRequest for starting CPU profiling
|
||||||
message StartCPUProfileRequest {}
|
message StartCPUProfileRequest {}
|
||||||
|
|
||||||
@@ -913,18 +956,3 @@ message StartBundleCaptureRequest {
|
|||||||
message StartBundleCaptureResponse {}
|
message StartBundleCaptureResponse {}
|
||||||
message StopBundleCaptureRequest {}
|
message StopBundleCaptureRequest {}
|
||||||
message StopBundleCaptureResponse {}
|
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 {}
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ func (s *Server) StartCapture(req *proto.StartCaptureRequest, stream proto.Daemo
|
|||||||
return status.Errorf(codes.Internal, "create capture session: %v", err)
|
return status.Errorf(codes.Internal, "create capture session: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
engine, err := s.claimCapture(sess, func() { pw.Close() })
|
engine, err := s.claimCapture(sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sess.Stop()
|
sess.Stop()
|
||||||
pw.Close()
|
pw.Close()
|
||||||
@@ -190,7 +190,10 @@ func (s *Server) StartBundleCapture(_ context.Context, req *proto.StartBundleCap
|
|||||||
|
|
||||||
s.stopBundleCaptureLocked()
|
s.stopBundleCaptureLocked()
|
||||||
s.cleanupBundleCapture()
|
s.cleanupBundleCapture()
|
||||||
s.evictActiveCaptureLocked()
|
|
||||||
|
if s.activeCapture != nil {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
|
||||||
|
}
|
||||||
|
|
||||||
engine, err := s.getCaptureEngineLocked()
|
engine, err := s.getCaptureEngineLocked()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -301,58 +304,29 @@ func (s *Server) cleanupBundleCapture() {
|
|||||||
s.bundleCapture = nil
|
s.bundleCapture = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// claimCapture reserves the engine's capture slot for sess. If another
|
// claimCapture reserves the engine's capture slot for sess. Returns
|
||||||
// capture is already running it is evicted: a previous streaming session
|
// FailedPrecondition if another capture is already active.
|
||||||
// whose gRPC client died and never freed the slot stays stuck otherwise,
|
func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) {
|
||||||
// and a bundle capture is just informational state.
|
|
||||||
func (s *Server) claimCapture(sess *capture.Session, cancel func()) (*internal.Engine, error) {
|
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
defer s.mutex.Unlock()
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
s.evictActiveCaptureLocked()
|
if s.activeCapture != nil {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
|
||||||
|
}
|
||||||
engine, err := s.getCaptureEngineLocked()
|
engine, err := s.getCaptureEngineLocked()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.activeCapture = sess
|
s.activeCapture = sess
|
||||||
s.activeCaptureCancel = cancel
|
|
||||||
return engine, nil
|
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.
|
// releaseCapture clears the active-capture owner if it still matches sess.
|
||||||
func (s *Server) releaseCapture(sess *capture.Session) {
|
func (s *Server) releaseCapture(sess *capture.Session) {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
defer s.mutex.Unlock()
|
defer s.mutex.Unlock()
|
||||||
if s.activeCapture == sess {
|
if s.activeCapture == sess {
|
||||||
s.activeCapture = nil
|
s.activeCapture = nil
|
||||||
s.activeCaptureCancel = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +341,6 @@ func (s *Server) clearCaptureIfOwner(sess *capture.Session, engine *internal.Eng
|
|||||||
log.Debugf("clear capture: %v", err)
|
log.Debugf("clear capture: %v", err)
|
||||||
}
|
}
|
||||||
s.activeCapture = nil
|
s.activeCapture = nil
|
||||||
s.activeCaptureCancel = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) {
|
func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
gstatus "google.golang.org/grpc/status"
|
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/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/expose"
|
"github.com/netbirdio/netbird/client/internal/expose"
|
||||||
@@ -67,6 +68,12 @@ type Server struct {
|
|||||||
logFile string
|
logFile string
|
||||||
|
|
||||||
oauthAuthFlow oauthAuthFlow
|
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
|
mutex sync.Mutex
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
@@ -93,12 +100,8 @@ type Server struct {
|
|||||||
captureEnabled bool
|
captureEnabled bool
|
||||||
bundleCapture *bundleCapture
|
bundleCapture *bundleCapture
|
||||||
// activeCapture is the session currently installed on the engine; guarded by s.mutex.
|
// activeCapture is the session currently installed on the engine; guarded by s.mutex.
|
||||||
activeCapture *capture.Session
|
activeCapture *capture.Session
|
||||||
// activeCaptureCancel tears down the streaming pipe/cancel for the
|
networksDisabled bool
|
||||||
// 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
|
sleepHandler *sleephandler.SleepHandler
|
||||||
|
|
||||||
@@ -127,6 +130,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
|||||||
captureEnabled: captureEnabled,
|
captureEnabled: captureEnabled,
|
||||||
networksDisabled: networksDisabled,
|
networksDisabled: networksDisabled,
|
||||||
jwtCache: newJWTCache(),
|
jwtCache: newJWTCache(),
|
||||||
|
extendAuthSessionFlow: auth.NewPendingFlow(),
|
||||||
}
|
}
|
||||||
agent := &serverAgent{s}
|
agent := &serverAgent{s}
|
||||||
s.sleepHandler = sleephandler.New(agent)
|
s.sleepHandler = sleephandler.New(agent)
|
||||||
@@ -144,6 +148,15 @@ func (s *Server) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state := internal.CtxGetState(s.rootCtx)
|
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 {
|
if err := handlePanicLog(); err != nil {
|
||||||
log.Warnf("failed to redirect stderr: %v", err)
|
log.Warnf("failed to redirect stderr: %v", err)
|
||||||
@@ -224,10 +237,20 @@ func (s *Server) Start() error {
|
|||||||
// mechanism to keep the client connected even when the connection is lost.
|
// 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.
|
// 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{}) {
|
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() {
|
defer func() {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
s.clientRunning = false
|
s.clientRunning = false
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
if giveUpChan != nil {
|
||||||
|
close(giveUpChan)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if s.config.DisableAutoConnect {
|
if s.config.DisableAutoConnect {
|
||||||
@@ -262,6 +285,15 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
|
|||||||
runOperation := func() error {
|
runOperation := func() error {
|
||||||
err := s.connect(ctx, profileConfig, statusRecorder, runningChan)
|
err := s.connect(ctx, profileConfig, statusRecorder, runningChan)
|
||||||
if err != nil {
|
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)
|
log.Debugf("run client connection exited with error: %v. Will retry in the background", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -273,10 +305,6 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
|
|||||||
if err := backoff.Retry(runOperation, backOff); err != nil {
|
if err := backoff.Retry(runOperation, backOff); err != nil {
|
||||||
log.Errorf("operation failed: %v", err)
|
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
|
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
|
||||||
@@ -345,9 +373,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
|||||||
}
|
}
|
||||||
|
|
||||||
if msg.OptionalPreSharedKey != nil {
|
if msg.OptionalPreSharedKey != nil {
|
||||||
if *msg.OptionalPreSharedKey != "" {
|
config.PreSharedKey = msg.OptionalPreSharedKey
|
||||||
config.PreSharedKey = msg.OptionalPreSharedKey
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.CleanDNSLabels {
|
if msg.CleanDNSLabels {
|
||||||
@@ -380,8 +406,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
|||||||
config.RosenpassPermissive = msg.RosenpassPermissive
|
config.RosenpassPermissive = msg.RosenpassPermissive
|
||||||
config.DisableAutoConnect = msg.DisableAutoConnect
|
config.DisableAutoConnect = msg.DisableAutoConnect
|
||||||
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
||||||
config.ServerVNCAllowed = msg.ServerVNCAllowed
|
|
||||||
config.DisableVNCApproval = msg.DisableVNCApproval
|
|
||||||
config.NetworkMonitor = msg.NetworkMonitor
|
config.NetworkMonitor = msg.NetworkMonitor
|
||||||
config.DisableClientRoutes = msg.DisableClientRoutes
|
config.DisableClientRoutes = msg.DisableClientRoutes
|
||||||
config.DisableServerRoutes = msg.DisableServerRoutes
|
config.DisableServerRoutes = msg.DisableServerRoutes
|
||||||
@@ -575,8 +599,35 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
return &proto.LoginResponse{}, nil
|
return &proto.LoginResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitSSOLogin uses the userCode to validate the TokenInfo and
|
// WaitSSOLogin validates the supplied userCode against the in-flight OAuth
|
||||||
// waits for the user to continue with the login on a browser
|
// device/PKCE flow and blocks until the user finishes the browser leg.
|
||||||
|
//
|
||||||
|
// State transitions on exit:
|
||||||
|
//
|
||||||
|
// ┌──────────────────────────────────────────┬──────────────────────────────────┐
|
||||||
|
// │ Outcome │ contextState │
|
||||||
|
// ├──────────────────────────────────────────┼──────────────────────────────────┤
|
||||||
|
// │ Success → loginAttempt → Connected │ StatusConnected (loginAttempt) │
|
||||||
|
// │ Success → loginAttempt → still-NeedsLogin│ StatusNeedsLogin (loginAttempt) │
|
||||||
|
// │ Success → loginAttempt error │ StatusLoginFailed (loginAttempt) │
|
||||||
|
// │ UserCode mismatch │ StatusLoginFailed │
|
||||||
|
// │ WaitToken: context.Canceled (external │ defer runs: status untouched if │
|
||||||
|
// │ abort — profile switch invokes │ already NeedsLogin/LoginFailed,│
|
||||||
|
// │ actCancel/waitCancel, app quit, │ else StatusIdle. Keeps the │
|
||||||
|
// │ another WaitSSOLogin started) │ cancel from leaking as a │
|
||||||
|
// │ │ spurious LoginFailed on the │
|
||||||
|
// │ │ next profile's 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 at the top of the function applies the Idle fallback so callers
|
||||||
|
// that bypass the explicit Set calls (the Canceled branch above, the success
|
||||||
|
// path before loginAttempt) still land on a sensible terminal status.
|
||||||
func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLoginRequest) (*proto.WaitSSOLoginResponse, error) {
|
func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLoginRequest) (*proto.WaitSSOLoginResponse, error) {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
if s.actCancel != nil {
|
if s.actCancel != nil {
|
||||||
@@ -636,7 +687,21 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
|||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
s.oauthAuthFlow.expiresAt = time.Now()
|
s.oauthAuthFlow.expiresAt = time.Now()
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
state.Set(internal.StatusLoginFailed)
|
switch {
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
// External abort (profile switch, app quit, another
|
||||||
|
// WaitSSOLogin started). Not a login failure — let the
|
||||||
|
// top-level defer fall through to StatusIdle so the next
|
||||||
|
// flow starts from a clean state.
|
||||||
|
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)
|
||||||
|
}
|
||||||
log.Errorf("waiting for browser login failed: %v", err)
|
log.Errorf("waiting for browser login failed: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -751,6 +816,9 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
|||||||
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||||
|
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
if msg.GetAsync() {
|
||||||
|
return &proto.UpResponse{}, nil
|
||||||
|
}
|
||||||
return s.waitForUp(callerCtx)
|
return s.waitForUp(callerCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,23 +918,37 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
state := internal.CtxGetState(s.rootCtx)
|
|
||||||
state.Set(internal.StatusIdle)
|
|
||||||
|
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
// Wait for the connectWithRetryRuns goroutine to finish with a short timeout.
|
// Wait for the connectWithRetryRuns goroutine to finish with a short timeout.
|
||||||
// This prevents the goroutine from setting ErrResetConnection after Down() returns.
|
// This prevents the goroutine from setting ErrResetConnection after Down() returns.
|
||||||
// The giveUpChan is closed at the end of connectWithRetryRuns.
|
// 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.
|
||||||
if giveUpChan != nil {
|
if giveUpChan != nil {
|
||||||
select {
|
select {
|
||||||
case <-giveUpChan:
|
case <-giveUpChan:
|
||||||
log.Debugf("client goroutine finished successfully")
|
log.Debugf("client goroutine finished, giveUpChan closed")
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
log.Warnf("timeout waiting for client goroutine to finish, proceeding anyway")
|
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
|
return &proto.DownResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,9 +1202,23 @@ func (s *Server) Status(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
status, err := internal.CtxGetState(s.rootCtx).Status()
|
return s.buildStatusResponse(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.
|
||||||
|
func (s *Server) buildStatusResponse(msg *proto.StatusRequest) (*proto.StatusResponse, error) {
|
||||||
|
state := internal.CtxGetState(s.rootCtx)
|
||||||
|
status, err := state.Status()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
// 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
if status == internal.StatusNeedsLogin && s.isSessionActive.Load() {
|
if status == internal.StatusNeedsLogin && s.isSessionActive.Load() {
|
||||||
@@ -1133,6 +1229,10 @@ func (s *Server) Status(
|
|||||||
|
|
||||||
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
|
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.UpdateManagementAddress(s.config.ManagementURL.String())
|
||||||
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
|
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
|
||||||
|
|
||||||
@@ -1142,7 +1242,6 @@ func (s *Server) Status(
|
|||||||
pbFullStatus := fullStatus.ToProto()
|
pbFullStatus := fullStatus.ToProto()
|
||||||
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
||||||
pbFullStatus.SshServerState = s.getSSHServerState()
|
pbFullStatus.SshServerState = s.getSSHServerState()
|
||||||
pbFullStatus.VncServerState = s.getVNCServerState()
|
|
||||||
statusResponse.FullStatus = pbFullStatus
|
statusResponse.FullStatus = pbFullStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,38 +1281,6 @@ func (s *Server) getSSHServerState() *proto.SSHServerState {
|
|||||||
return 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
|
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
|
||||||
func (s *Server) GetPeerSSHHostKey(
|
func (s *Server) GetPeerSSHHostKey(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -1395,6 +1462,131 @@ func (s *Server) WaitJWTToken(
|
|||||||
}, nil
|
}, 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenInfo, err := oAuthFlow.WaitToken(ctx, authInfo)
|
||||||
|
if err != nil {
|
||||||
|
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.
|
// ExposeService exposes a local port via the NetBird reverse proxy.
|
||||||
func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error {
|
func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
@@ -1454,27 +1646,6 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon
|
|||||||
return nil
|
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 {
|
func isUnixRunningDesktop() bool {
|
||||||
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
||||||
return false
|
return false
|
||||||
@@ -1591,8 +1762,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
|||||||
Mtu: int64(cfg.MTU),
|
Mtu: int64(cfg.MTU),
|
||||||
DisableAutoConnect: cfg.DisableAutoConnect,
|
DisableAutoConnect: cfg.DisableAutoConnect,
|
||||||
ServerSSHAllowed: *cfg.ServerSSHAllowed,
|
ServerSSHAllowed: *cfg.ServerSSHAllowed,
|
||||||
ServerVNCAllowed: cfg.ServerVNCAllowed != nil && *cfg.ServerVNCAllowed,
|
|
||||||
DisableVNCApproval: cfg.DisableVNCApproval != nil && *cfg.DisableVNCApproval,
|
|
||||||
RosenpassEnabled: cfg.RosenpassEnabled,
|
RosenpassEnabled: cfg.RosenpassEnabled,
|
||||||
RosenpassPermissive: cfg.RosenpassPermissive,
|
RosenpassPermissive: cfg.RosenpassPermissive,
|
||||||
LazyConnectionEnabled: cfg.LazyConnectionEnabled,
|
LazyConnectionEnabled: cfg.LazyConnectionEnabled,
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
|||||||
rosenpassEnabled := true
|
rosenpassEnabled := true
|
||||||
rosenpassPermissive := true
|
rosenpassPermissive := true
|
||||||
serverSSHAllowed := true
|
serverSSHAllowed := true
|
||||||
serverVNCAllowed := true
|
|
||||||
disableVNCApproval := true
|
|
||||||
interfaceName := "utun100"
|
interfaceName := "utun100"
|
||||||
wireguardPort := int64(51820)
|
wireguardPort := int64(51820)
|
||||||
preSharedKey := "test-psk"
|
preSharedKey := "test-psk"
|
||||||
@@ -85,8 +83,6 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
|||||||
RosenpassEnabled: &rosenpassEnabled,
|
RosenpassEnabled: &rosenpassEnabled,
|
||||||
RosenpassPermissive: &rosenpassPermissive,
|
RosenpassPermissive: &rosenpassPermissive,
|
||||||
ServerSSHAllowed: &serverSSHAllowed,
|
ServerSSHAllowed: &serverSSHAllowed,
|
||||||
ServerVNCAllowed: &serverVNCAllowed,
|
|
||||||
DisableVNCApproval: &disableVNCApproval,
|
|
||||||
InterfaceName: &interfaceName,
|
InterfaceName: &interfaceName,
|
||||||
WireguardPort: &wireguardPort,
|
WireguardPort: &wireguardPort,
|
||||||
OptionalPreSharedKey: &preSharedKey,
|
OptionalPreSharedKey: &preSharedKey,
|
||||||
@@ -131,10 +127,6 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
|||||||
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
|
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
|
||||||
require.NotNil(t, cfg.ServerSSHAllowed)
|
require.NotNil(t, cfg.ServerSSHAllowed)
|
||||||
require.Equal(t, serverSSHAllowed, *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, interfaceName, cfg.WgIface)
|
||||||
require.Equal(t, int(wireguardPort), cfg.WgPort)
|
require.Equal(t, int(wireguardPort), cfg.WgPort)
|
||||||
require.Equal(t, preSharedKey, cfg.PreSharedKey)
|
require.Equal(t, preSharedKey, cfg.PreSharedKey)
|
||||||
@@ -187,8 +179,6 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
|
|||||||
"RosenpassEnabled": true,
|
"RosenpassEnabled": true,
|
||||||
"RosenpassPermissive": true,
|
"RosenpassPermissive": true,
|
||||||
"ServerSSHAllowed": true,
|
"ServerSSHAllowed": true,
|
||||||
"ServerVNCAllowed": true,
|
|
||||||
"DisableVNCApproval": true,
|
|
||||||
"InterfaceName": true,
|
"InterfaceName": true,
|
||||||
"WireguardPort": true,
|
"WireguardPort": true,
|
||||||
"OptionalPreSharedKey": true,
|
"OptionalPreSharedKey": true,
|
||||||
@@ -250,8 +240,6 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
|
|||||||
"enable-rosenpass": "RosenpassEnabled",
|
"enable-rosenpass": "RosenpassEnabled",
|
||||||
"rosenpass-permissive": "RosenpassPermissive",
|
"rosenpass-permissive": "RosenpassPermissive",
|
||||||
"allow-server-ssh": "ServerSSHAllowed",
|
"allow-server-ssh": "ServerSSHAllowed",
|
||||||
"allow-server-vnc": "ServerVNCAllowed",
|
|
||||||
"disable-vnc-approval": "DisableVNCApproval",
|
|
||||||
"interface-name": "InterfaceName",
|
"interface-name": "InterfaceName",
|
||||||
"wireguard-port": "WireguardPort",
|
"wireguard-port": "WireguardPort",
|
||||||
"preshared-key": "OptionalPreSharedKey",
|
"preshared-key": "OptionalPreSharedKey",
|
||||||
|
|||||||
57
client/server/status_stream.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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(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
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package sessionauth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -15,16 +15,13 @@ const (
|
|||||||
DefaultUserIDClaim = "sub"
|
DefaultUserIDClaim = "sub"
|
||||||
// Wildcard is a special user ID that matches all users
|
// Wildcard is a special user ID that matches all users
|
||||||
Wildcard = "*"
|
Wildcard = "*"
|
||||||
// sessionPubKeyLen is the size of an X25519 static public key in bytes.
|
|
||||||
sessionPubKeyLen = 32
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrEmptyUserID = errors.New("JWT user ID is empty")
|
ErrEmptyUserID = errors.New("JWT user ID is empty")
|
||||||
ErrUserNotAuthorized = errors.New("user is not authorized to access this peer")
|
ErrUserNotAuthorized = errors.New("user is not authorized to access this peer")
|
||||||
ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user")
|
ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user")
|
||||||
ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user")
|
ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user")
|
||||||
ErrSessionKeyNotKnown = errors.New("session pubkey not registered")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Authorizer handles SSH fine-grained access control authorization
|
// Authorizer handles SSH fine-grained access control authorization
|
||||||
@@ -38,17 +35,6 @@ type Authorizer struct {
|
|||||||
// machineUsers maps OS login usernames to lists of authorized user indexes
|
// machineUsers maps OS login usernames to lists of authorized user indexes
|
||||||
machineUsers map[string][]uint32
|
machineUsers map[string][]uint32
|
||||||
|
|
||||||
// sessionPubKeys maps an X25519 static public key (as map-safe
|
|
||||||
// array) to the hashed user identity that key authenticates as.
|
|
||||||
// Populated from management's temporary-access flow; used by VNC to
|
|
||||||
// authenticate via the Noise_IK handshake.
|
|
||||||
sessionPubKeys map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash
|
|
||||||
// sessionDisplayNames mirrors sessionPubKeys with the optional
|
|
||||||
// human-readable display name management associated with each
|
|
||||||
// session key. Used by the per-connection UI approval prompt; not
|
|
||||||
// consulted by any authorization decision.
|
|
||||||
sessionDisplayNames map[[sessionPubKeyLen]byte]string
|
|
||||||
|
|
||||||
// mu protects the list of users
|
// mu protects the list of users
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -64,29 +50,13 @@ type Config struct {
|
|||||||
// MachineUsers maps OS login usernames to indexes in AuthorizedUsers
|
// MachineUsers maps OS login usernames to indexes in AuthorizedUsers
|
||||||
// If a user wants to login as a specific OS user, their index must be in the corresponding list
|
// If a user wants to login as a specific OS user, their index must be in the corresponding list
|
||||||
MachineUsers map[string][]uint32
|
MachineUsers map[string][]uint32
|
||||||
|
|
||||||
// SessionPubKeys binds ephemeral X25519 static public keys to hashed
|
|
||||||
// user identities. Populated for VNC; ignored on the SSH side.
|
|
||||||
SessionPubKeys []SessionPubKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionPubKey is a single ephemeral-key entry: the 32-byte X25519
|
|
||||||
// static public key plus the hashed user identity it authenticates as,
|
|
||||||
// optionally plus a human-readable display name for the UI approval
|
|
||||||
// prompt to identify the requester.
|
|
||||||
type SessionPubKey struct {
|
|
||||||
PubKey []byte
|
|
||||||
UserIDHash sshuserhash.UserIDHash
|
|
||||||
DisplayName string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthorizer creates a new SSH authorizer with empty configuration
|
// NewAuthorizer creates a new SSH authorizer with empty configuration
|
||||||
func NewAuthorizer() *Authorizer {
|
func NewAuthorizer() *Authorizer {
|
||||||
a := &Authorizer{
|
a := &Authorizer{
|
||||||
userIDClaim: DefaultUserIDClaim,
|
userIDClaim: DefaultUserIDClaim,
|
||||||
machineUsers: make(map[string][]uint32),
|
machineUsers: make(map[string][]uint32),
|
||||||
sessionPubKeys: make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash),
|
|
||||||
sessionDisplayNames: make(map[[sessionPubKeyLen]byte]string),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return a
|
return a
|
||||||
@@ -102,8 +72,6 @@ func (a *Authorizer) Update(config *Config) {
|
|||||||
a.userIDClaim = DefaultUserIDClaim
|
a.userIDClaim = DefaultUserIDClaim
|
||||||
a.authorizedUsers = []sshuserhash.UserIDHash{}
|
a.authorizedUsers = []sshuserhash.UserIDHash{}
|
||||||
a.machineUsers = make(map[string][]uint32)
|
a.machineUsers = make(map[string][]uint32)
|
||||||
a.sessionPubKeys = make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash)
|
|
||||||
a.sessionDisplayNames = make(map[[sessionPubKeyLen]byte]string)
|
|
||||||
log.Info("SSH authorization cleared")
|
log.Info("SSH authorization cleared")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -126,35 +94,8 @@ func (a *Authorizer) Update(config *Config) {
|
|||||||
}
|
}
|
||||||
a.machineUsers = machineUsers
|
a.machineUsers = machineUsers
|
||||||
|
|
||||||
sessionPubKeys := make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash, len(config.SessionPubKeys))
|
log.Debugf("SSH auth: updated with %d authorized users, %d machine user mappings",
|
||||||
sessionDisplayNames := make(map[[sessionPubKeyLen]byte]string, len(config.SessionPubKeys))
|
len(config.AuthorizedUsers), len(machineUsers))
|
||||||
conflicted := make(map[[sessionPubKeyLen]byte]struct{})
|
|
||||||
for _, e := range config.SessionPubKeys {
|
|
||||||
if len(e.PubKey) != sessionPubKeyLen {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var key [sessionPubKeyLen]byte
|
|
||||||
copy(key[:], e.PubKey)
|
|
||||||
if _, bad := conflicted[key]; bad {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if existing, ok := sessionPubKeys[key]; ok && existing != e.UserIDHash {
|
|
||||||
log.Warnf("SSH auth: session pubkey bound to conflicting user hashes; dropping binding")
|
|
||||||
delete(sessionPubKeys, key)
|
|
||||||
delete(sessionDisplayNames, key)
|
|
||||||
conflicted[key] = struct{}{}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sessionPubKeys[key] = e.UserIDHash
|
|
||||||
if e.DisplayName != "" {
|
|
||||||
sessionDisplayNames[key] = e.DisplayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.sessionPubKeys = sessionPubKeys
|
|
||||||
a.sessionDisplayNames = sessionDisplayNames
|
|
||||||
|
|
||||||
log.Debugf("SSH auth: updated with %d authorized users, %d machine user mappings, %d session pubkeys",
|
|
||||||
len(config.AuthorizedUsers), len(machineUsers), len(sessionPubKeys))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorize validates if a user is authorized to login as the specified OS user.
|
// Authorize validates if a user is authorized to login as the specified OS user.
|
||||||
@@ -214,54 +155,6 @@ func (a *Authorizer) GetUserIDClaim() string {
|
|||||||
return a.userIDClaim
|
return a.userIDClaim
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupSessionKey resolves a Noise-verified static public key to the
|
|
||||||
// hashed user identity registered with it. Fails closed when the key is
|
|
||||||
// unknown.
|
|
||||||
func (a *Authorizer) LookupSessionKey(pubKey []byte) (sshuserhash.UserIDHash, error) {
|
|
||||||
var zero sshuserhash.UserIDHash
|
|
||||||
if len(pubKey) != sessionPubKeyLen {
|
|
||||||
return zero, fmt.Errorf("session pubkey wrong length: %d", len(pubKey))
|
|
||||||
}
|
|
||||||
var key [sessionPubKeyLen]byte
|
|
||||||
copy(key[:], pubKey)
|
|
||||||
a.mu.RLock()
|
|
||||||
hash, ok := a.sessionPubKeys[key]
|
|
||||||
a.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return zero, ErrSessionKeyNotKnown
|
|
||||||
}
|
|
||||||
return hash, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LookupSessionDisplayName returns the human-readable display name
|
|
||||||
// management associated with a session pubkey, or empty string when none
|
|
||||||
// is recorded. Never returns an error: a missing/unknown key reports as
|
|
||||||
// "" and the caller falls back to other identifiers.
|
|
||||||
func (a *Authorizer) LookupSessionDisplayName(pubKey []byte) string {
|
|
||||||
if len(pubKey) != sessionPubKeyLen {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var key [sessionPubKeyLen]byte
|
|
||||||
copy(key[:], pubKey)
|
|
||||||
a.mu.RLock()
|
|
||||||
name := a.sessionDisplayNames[key]
|
|
||||||
a.mu.RUnlock()
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthorizeOSUserBySessionKey resolves the OS-user mapping for a session
|
|
||||||
// key. Mirrors Authorize but skips the JWT-hash step since the key has
|
|
||||||
// already been verified and the user identity hash is in hand.
|
|
||||||
func (a *Authorizer) AuthorizeOSUserBySessionKey(userIDHash sshuserhash.UserIDHash, osUsername string) (string, error) {
|
|
||||||
a.mu.RLock()
|
|
||||||
defer a.mu.RUnlock()
|
|
||||||
userIndex, found := a.findUserIndex(userIDHash)
|
|
||||||
if !found {
|
|
||||||
return "", fmt.Errorf("session user (hash: %s) not in authorized list for OS user %q: %w", userIDHash, osUsername, ErrUserNotAuthorized)
|
|
||||||
}
|
|
||||||
return a.checkMachineUserMapping("session", osUsername, userIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// findUserIndex finds the index of a hashed user ID in the authorized users list
|
// findUserIndex finds the index of a hashed user ID in the authorized users list
|
||||||
// Returns the index and true if found, 0 and false if not found
|
// Returns the index and true if found, 0 and false if not found
|
||||||
func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) {
|
func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) {
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package sessionauth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -611,61 +610,3 @@ func TestAuthorizer_Wildcard_WithPartialIndexes_AllowsAllUsers(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.ErrorIs(t, err, ErrUserNotAuthorized, "unauthorized user should be denied")
|
assert.ErrorIs(t, err, ErrUserNotAuthorized, "unauthorized user should be denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthorizer_LookupSessionKey_Valid(t *testing.T) {
|
|
||||||
pub := bytesRepeat(0x11, sessionPubKeyLen)
|
|
||||||
userHash, err := sshauth.HashUserID("alice")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
a := NewAuthorizer()
|
|
||||||
a.Update(&Config{
|
|
||||||
AuthorizedUsers: []sshauth.UserIDHash{userHash},
|
|
||||||
MachineUsers: map[string][]uint32{Wildcard: {0}},
|
|
||||||
SessionPubKeys: []SessionPubKey{{PubKey: pub, UserIDHash: userHash}},
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := a.LookupSessionKey(pub)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, userHash, got)
|
|
||||||
|
|
||||||
if _, err := a.AuthorizeOSUserBySessionKey(got, "alice"); err != nil {
|
|
||||||
t.Fatalf("AuthorizeOSUserBySessionKey: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthorizer_LookupSessionKey_UnknownPub(t *testing.T) {
|
|
||||||
a := NewAuthorizer()
|
|
||||||
a.Update(&Config{})
|
|
||||||
_, err := a.LookupSessionKey(bytesRepeat(0x22, sessionPubKeyLen))
|
|
||||||
require.ErrorIs(t, err, ErrSessionKeyNotKnown)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthorizer_LookupSessionKey_WrongLength(t *testing.T) {
|
|
||||||
a := NewAuthorizer()
|
|
||||||
_, err := a.LookupSessionKey([]byte("short"))
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthorizer_LookupSessionKey_UpdateClears(t *testing.T) {
|
|
||||||
pub := bytesRepeat(0x33, sessionPubKeyLen)
|
|
||||||
userHash, err := sshauth.HashUserID("alice")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
a := NewAuthorizer()
|
|
||||||
a.Update(&Config{SessionPubKeys: []SessionPubKey{{PubKey: pub, UserIDHash: userHash}}})
|
|
||||||
if _, err := a.LookupSessionKey(pub); err != nil {
|
|
||||||
t.Fatalf("setup lookup: %v", err)
|
|
||||||
}
|
|
||||||
a.Update(&Config{})
|
|
||||||
if _, err := a.LookupSessionKey(pub); !errors.Is(err, ErrSessionKeyNotKnown) {
|
|
||||||
t.Fatalf("expected ErrSessionKeyNotKnown, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bytesRepeat(b byte, n int) []byte {
|
|
||||||
out := make([]byte, n)
|
|
||||||
for i := range out {
|
|
||||||
out[i] = b
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||||
"github.com/netbirdio/netbird/client/ssh/server"
|
"github.com/netbirdio/netbird/client/ssh/server"
|
||||||
"github.com/netbirdio/netbird/client/ssh/testutil"
|
"github.com/netbirdio/netbird/client/ssh/testutil"
|
||||||
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||||
"github.com/netbirdio/netbird/client/ssh/client"
|
"github.com/netbirdio/netbird/client/ssh/client"
|
||||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||||
"github.com/netbirdio/netbird/client/ssh/testutil"
|
"github.com/netbirdio/netbird/client/ssh/testutil"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||||
"github.com/netbirdio/netbird/shared/auth"
|
"github.com/netbirdio/netbird/shared/auth"
|
||||||
"github.com/netbirdio/netbird/shared/auth/jwt"
|
"github.com/netbirdio/netbird/shared/auth/jwt"
|
||||||
@@ -197,14 +197,6 @@ type Config struct {
|
|||||||
|
|
||||||
// HostKey is the SSH server host key in PEM format
|
// HostKey is the SSH server host key in PEM format
|
||||||
HostKeyPEM []byte
|
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
|
// SessionInfo contains information about an active SSH session
|
||||||
@@ -216,15 +208,12 @@ type SessionInfo struct {
|
|||||||
PortForwards []string
|
PortForwards []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an SSH server instance from the supplied Config. Fields are
|
// New creates an SSH server instance with the provided host key and optional JWT configuration
|
||||||
// read once at construction; mutating Config afterwards has no effect.
|
// If jwtConfig is nil, JWT authentication is disabled
|
||||||
// JWT == nil disables JWT authentication.
|
|
||||||
func New(config *Config) *Server {
|
func New(config *Config) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
mu: sync.RWMutex{},
|
mu: sync.RWMutex{},
|
||||||
hostKeyPEM: config.HostKeyPEM,
|
hostKeyPEM: config.HostKeyPEM,
|
||||||
netstackNet: config.NetstackNet,
|
|
||||||
wgAddress: config.NetworkValidation,
|
|
||||||
sessions: make(map[sessionKey]*sessionState),
|
sessions: make(map[sessionKey]*sessionState),
|
||||||
pendingAuthJWT: make(map[authKey]string),
|
pendingAuthJWT: make(map[authKey]string),
|
||||||
remoteForwardListeners: make(map[forwardKey]net.Listener),
|
remoteForwardListeners: make(map[forwardKey]net.Listener),
|
||||||
@@ -445,6 +434,20 @@ func (s *Server) buildSessionInfo(state *sessionState) SessionInfo {
|
|||||||
return info
|
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
|
// UpdateSSHAuth updates the SSH fine-grained access control configuration
|
||||||
// This should be called when network map updates include new SSH auth configuration
|
// This should be called when network map updates include new SSH auth configuration
|
||||||
func (s *Server) UpdateSSHAuth(config *sshauth.Config) {
|
func (s *Server) UpdateSSHAuth(config *sshauth.Config) {
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ type ConvertOptions struct {
|
|||||||
IPsFilter map[string]struct{}
|
IPsFilter map[string]struct{}
|
||||||
ConnectionTypeFilter string
|
ConnectionTypeFilter string
|
||||||
ProfileName 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 {
|
type PeerStateDetailOutput struct {
|
||||||
@@ -131,19 +135,6 @@ type SSHServerStateOutput struct {
|
|||||||
Sessions []SSHSessionOutput `json:"sessions" yaml:"sessions"`
|
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 {
|
type OutputOverview struct {
|
||||||
Peers PeersStateOutput `json:"peers" yaml:"peers"`
|
Peers PeersStateOutput `json:"peers" yaml:"peers"`
|
||||||
CliVersion string `json:"cliVersion" yaml:"cliVersion"`
|
CliVersion string `json:"cliVersion" yaml:"cliVersion"`
|
||||||
@@ -166,7 +157,11 @@ type OutputOverview struct {
|
|||||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"`
|
LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"`
|
||||||
ProfileName string `json:"profileName" yaml:"profileName"`
|
ProfileName string `json:"profileName" yaml:"profileName"`
|
||||||
SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"`
|
SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"`
|
||||||
VNCServerState VNCServerStateOutput `json:"vncServer" yaml:"vncServer"`
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertToStatusOutputOverview converts protobuf status to the output overview.
|
// ConvertToStatusOutputOverview converts protobuf status to the output overview.
|
||||||
@@ -187,7 +182,6 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
|||||||
|
|
||||||
relayOverview := mapRelays(pbFullStatus.GetRelays())
|
relayOverview := mapRelays(pbFullStatus.GetRelays())
|
||||||
sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState())
|
sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState())
|
||||||
vncServerOverview := mapVNCServer(pbFullStatus.GetVncServerState())
|
|
||||||
peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter)
|
peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter)
|
||||||
|
|
||||||
overview := OutputOverview{
|
overview := OutputOverview{
|
||||||
@@ -212,7 +206,10 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
|||||||
LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(),
|
LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(),
|
||||||
ProfileName: opts.ProfileName,
|
ProfileName: opts.ProfileName,
|
||||||
SSHServerState: sshServerOverview,
|
SSHServerState: sshServerOverview,
|
||||||
VNCServerState: vncServerOverview,
|
}
|
||||||
|
if !opts.SessionExpiresAt.IsZero() {
|
||||||
|
t := opts.SessionExpiresAt
|
||||||
|
overview.SessionExpiresAt = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Anonymize {
|
if opts.Anonymize {
|
||||||
@@ -287,26 +284,6 @@ 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(
|
func mapPeers(
|
||||||
peers []*proto.PeerState,
|
peers []*proto.PeerState,
|
||||||
statusFilter string,
|
statusFilter string,
|
||||||
@@ -569,28 +546,17 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
|
||||||
|
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var forwardingRulesString string
|
var forwardingRulesString string
|
||||||
if o.NumberOfForwardingRules > 0 {
|
if o.NumberOfForwardingRules > 0 {
|
||||||
forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules)
|
forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules)
|
||||||
@@ -619,9 +585,9 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
|||||||
"Quantum resistance: %s\n"+
|
"Quantum resistance: %s\n"+
|
||||||
"Lazy connection: %s\n"+
|
"Lazy connection: %s\n"+
|
||||||
"SSH Server: %s\n"+
|
"SSH Server: %s\n"+
|
||||||
"VNC Server: %s\n"+
|
|
||||||
"Networks: %s\n"+
|
"Networks: %s\n"+
|
||||||
"%s"+
|
"%s"+
|
||||||
|
"%s"+
|
||||||
"Peers count: %s\n",
|
"Peers count: %s\n",
|
||||||
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
|
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
|
||||||
o.DaemonVersion,
|
o.DaemonVersion,
|
||||||
@@ -638,9 +604,9 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
|||||||
rosenpassEnabledStatus,
|
rosenpassEnabledStatus,
|
||||||
lazyConnectionEnabledStatus,
|
lazyConnectionEnabledStatus,
|
||||||
sshServerStatus,
|
sshServerStatus,
|
||||||
vncServerStatus,
|
|
||||||
networks,
|
networks,
|
||||||
forwardingRulesString,
|
forwardingRulesString,
|
||||||
|
sessionExpiryString,
|
||||||
peersCountString,
|
peersCountString,
|
||||||
)
|
)
|
||||||
return summary
|
return summary
|
||||||
@@ -998,26 +964,6 @@ 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) {
|
func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||||
for i, peer := range overview.Peers.Details {
|
for i, peer := range overview.Peers.Details {
|
||||||
peer := peer
|
peer := peer
|
||||||
@@ -1038,19 +984,6 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
|||||||
overview.Relays.Details[i] = detail
|
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 i, nsGroup := range overview.NSServerGroups {
|
||||||
for j, domain := range nsGroup.Domains {
|
for j, domain := range nsGroup.Domains {
|
||||||
overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomain(domain)
|
overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomain(domain)
|
||||||
@@ -1062,9 +995,13 @@ func anonymizeNSServerGroups(a *anonymize.Anonymizer, overview *OutputOverview)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func anonymizeEvents(a *anonymize.Anonymizer, overview *OutputOverview) {
|
for i, route := range overview.Networks {
|
||||||
|
overview.Networks[i] = a.AnonymizeRoute(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
overview.FQDN = a.AnonymizeDomain(overview.FQDN)
|
||||||
|
|
||||||
for i, event := range overview.Events {
|
for i, event := range overview.Events {
|
||||||
overview.Events[i].Message = a.AnonymizeString(event.Message)
|
overview.Events[i].Message = a.AnonymizeString(event.Message)
|
||||||
overview.Events[i].UserMessage = a.AnonymizeString(event.UserMessage)
|
overview.Events[i].UserMessage = a.AnonymizeString(event.UserMessage)
|
||||||
@@ -1073,24 +1010,67 @@ func anonymizeEvents(a *anonymize.Anonymizer, overview *OutputOverview) {
|
|||||||
event.Metadata[k] = a.AnonymizeString(v)
|
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 {
|
for i, session := range overview.SSHServerState.Sessions {
|
||||||
overview.SSHServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, session.RemoteAddress)
|
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].Command = a.AnonymizeString(session.Command)
|
overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command)
|
||||||
}
|
}
|
||||||
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)
|
// FormatRemainingDuration renders a time.Duration for the "Session expires"
|
||||||
overview.VNCServerState.Sessions[i].UserID = a.AnonymizeString(sess.UserID)
|
// line. Examples: "2h 15m", "47m 12s", "8s", "expired 3m ago".
|
||||||
overview.VNCServerState.Sessions[i].Initiator = a.AnonymizeString(sess.Initiator)
|
//
|
||||||
|
// 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
|
||||||
|
min = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
days := d / day
|
||||||
|
d -= days * day
|
||||||
|
hours := d / hour
|
||||||
|
d -= hours * hour
|
||||||
|
minutes := d / min
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,10 +240,6 @@ var overview = OutputOverview{
|
|||||||
Enabled: false,
|
Enabled: false,
|
||||||
Sessions: []SSHSessionOutput{},
|
Sessions: []SSHSessionOutput{},
|
||||||
},
|
},
|
||||||
VNCServerState: VNCServerStateOutput{
|
|
||||||
Enabled: false,
|
|
||||||
Sessions: []VNCSessionOutput{},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConversionFromFullStatusToOutputOverview(t *testing.T) {
|
func TestConversionFromFullStatusToOutputOverview(t *testing.T) {
|
||||||
@@ -408,10 +404,6 @@ func TestParsingToJSON(t *testing.T) {
|
|||||||
"sshServer":{
|
"sshServer":{
|
||||||
"enabled":false,
|
"enabled":false,
|
||||||
"sessions":[]
|
"sessions":[]
|
||||||
},
|
|
||||||
"vncServer":{
|
|
||||||
"enabled":false,
|
|
||||||
"sessions":[]
|
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
// @formatter:on
|
// @formatter:on
|
||||||
@@ -521,9 +513,6 @@ profileName: ""
|
|||||||
sshServer:
|
sshServer:
|
||||||
enabled: false
|
enabled: false
|
||||||
sessions: []
|
sessions: []
|
||||||
vncServer:
|
|
||||||
enabled: false
|
|
||||||
sessions: []
|
|
||||||
`
|
`
|
||||||
|
|
||||||
assert.Equal(t, expectedYAML, yaml)
|
assert.Equal(t, expectedYAML, yaml)
|
||||||
@@ -593,7 +582,6 @@ Interface type: Kernel
|
|||||||
Quantum resistance: false
|
Quantum resistance: false
|
||||||
Lazy connection: false
|
Lazy connection: false
|
||||||
SSH Server: Disabled
|
SSH Server: Disabled
|
||||||
VNC Server: Disabled
|
|
||||||
Networks: 10.10.0.0/24
|
Networks: 10.10.0.0/24
|
||||||
Peers count: 2/2 Connected
|
Peers count: 2/2 Connected
|
||||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
|
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
|
||||||
@@ -619,7 +607,6 @@ Interface type: Kernel
|
|||||||
Quantum resistance: false
|
Quantum resistance: false
|
||||||
Lazy connection: false
|
Lazy connection: false
|
||||||
SSH Server: Disabled
|
SSH Server: Disabled
|
||||||
VNC Server: Disabled
|
|
||||||
Networks: 10.10.0.0/24
|
Networks: 10.10.0.0/24
|
||||||
Peers count: 2/2 Connected
|
Peers count: 2/2 Connected
|
||||||
`
|
`
|
||||||
@@ -654,3 +641,50 @@ 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,7 +62,6 @@ type Info struct {
|
|||||||
RosenpassEnabled bool
|
RosenpassEnabled bool
|
||||||
RosenpassPermissive bool
|
RosenpassPermissive bool
|
||||||
ServerSSHAllowed bool
|
ServerSSHAllowed bool
|
||||||
ServerVNCAllowed bool
|
|
||||||
|
|
||||||
DisableClientRoutes bool
|
DisableClientRoutes bool
|
||||||
DisableServerRoutes bool
|
DisableServerRoutes bool
|
||||||
@@ -84,7 +83,6 @@ type Info struct {
|
|||||||
func (i *Info) SetFlags(
|
func (i *Info) SetFlags(
|
||||||
rosenpassEnabled, rosenpassPermissive bool,
|
rosenpassEnabled, rosenpassPermissive bool,
|
||||||
serverSSHAllowed *bool,
|
serverSSHAllowed *bool,
|
||||||
serverVNCAllowed *bool,
|
|
||||||
disableClientRoutes, disableServerRoutes,
|
disableClientRoutes, disableServerRoutes,
|
||||||
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool,
|
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool,
|
||||||
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
|
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
|
||||||
@@ -95,9 +93,6 @@ func (i *Info) SetFlags(
|
|||||||
if serverSSHAllowed != nil {
|
if serverSSHAllowed != nil {
|
||||||
i.ServerSSHAllowed = *serverSSHAllowed
|
i.ServerSSHAllowed = *serverSSHAllowed
|
||||||
}
|
}
|
||||||
if serverVNCAllowed != nil {
|
|
||||||
i.ServerVNCAllowed = *serverVNCAllowed
|
|
||||||
}
|
|
||||||
|
|
||||||
i.DisableClientRoutes = disableClientRoutes
|
i.DisableClientRoutes = disableClientRoutes
|
||||||
i.DisableServerRoutes = disableServerRoutes
|
i.DisableServerRoutes = disableServerRoutes
|
||||||
|
|||||||
8
client/ui/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.task
|
||||||
|
bin
|
||||||
|
frontend/dist
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/bindings
|
||||||
|
frontend/.vite
|
||||||
|
build/linux/appimage/build
|
||||||
|
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||||
151
client/ui/CLAUDE.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# 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_linux.go` — `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` to avoid the blank-white window on VMs / minimal WMs.
|
||||||
|
- `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 ships light + dark PNGs; Windows reuses the light PNG (multi-frame `.ico` never redrew on Wails3's `NIM_MODIFY`).
|
||||||
|
|
||||||
|
### 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` | `Trigger` (enforced installer) / `GetInstallerResult` / `Quit` (used by the `/update` page after a successful install). |
|
||||||
|
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. 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}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage`, persists, and broadcasts `netbird:preferences:changed`. |
|
||||||
|
|
||||||
|
`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.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
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's `screens/Profiles.tsx` 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 (`layouts/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** (`SettingsProfiles.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 (translucent macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise.
|
||||||
|
- **BrowserLogin** (`/#/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`layouts/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.
|
||||||
|
- **SessionExpired** (`/#/session-expired`) and **SessionAboutToExpire** (`/#/session-about-to-expire?seconds=<n>`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Currently triggered only by the DEV-only "Development" Settings tab; daemon-status integration is a follow-up.
|
||||||
|
|
||||||
|
All four auxiliary windows 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 auxiliaries.
|
||||||
|
|
||||||
|
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 `frontend/src/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). 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`, append a row to `_index.json`, add the static import in `frontend/src/i18n/index.ts`, rebuild. Embed lives in `client/ui/main.go`'s `embed.FS`.
|
||||||
|
|
||||||
|
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.* / sessionExpired.* / 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}`.
|
||||||
|
|
||||||
|
## Wails Dialogs (frontend, `@wailsio/runtime`)
|
||||||
|
|
||||||
|
API surface — `Dialogs.Info` / `Warning` / `Error` / `Question` / `OpenFile` / `SaveFile`, options shape, per-OS behaviour, and the Go-side frameless-window pattern — lives in `WAILS-DIALOGS.md` (sibling). The conventions for **when** to use a native dialog vs inline UI are in the "Conventions" section below.
|
||||||
|
|
||||||
|
## Conventions in this codebase
|
||||||
|
|
||||||
|
### Errors → native dialogs
|
||||||
|
|
||||||
|
User-actionable operation failures (config save, profile switch, debug bundle, update, etc.) surface via `Dialogs.Error` with an action-named title — "Save Settings Failed", "Switch Profile Failed", not "Error" / "Something went wrong". The dialog itself already says "Error" visually.
|
||||||
|
|
||||||
|
Confirmations use `Dialogs.Warning` with explicit `Buttons`. The promise resolves with the **button Label string**, not an index — pin the label into a variable before comparing (especially with i18n, where labels translate). Full API in `WAILS-DIALOGS.md`.
|
||||||
|
|
||||||
|
**Skip native dialogs** 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); dedicated screens like `/update` may show an additional inline header alongside the dialog so the screen isn't blank after dismissal.
|
||||||
|
|
||||||
|
### 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` and `screens/Profiles.tsx` both call 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".
|
||||||
|
|
||||||
|
## 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
|
||||||
8
client/ui/LINUX-TRAY.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 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.
|
||||||
100
client/ui/README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 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: `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`,
|
||||||
|
`libayatana-appindicator3-dev`
|
||||||
|
|
||||||
|
## 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.
|
||||||
58
client/ui/Taskfile.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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
|
||||||
56
client/ui/WAILS-DIALOGS.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
//go:build !(linux && 386)
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
args := []string{
|
|
||||||
"--approval-request-id=" + requestID,
|
|
||||||
"--approval-kind=" + ev.Metadata["kind"],
|
|
||||||
"--approval-initiator=" + ev.Metadata["initiator"],
|
|
||||||
"--approval-peer-name=" + ev.Metadata["peer_name"],
|
|
||||||
"--approval-source-ip=" + ev.Metadata["source_ip"],
|
|
||||||
"--approval-username=" + ev.Metadata["username"],
|
|
||||||
"--approval-expires-at=" + ev.Metadata["expires_at"],
|
|
||||||
"--approval-key-fingerprint=" + ev.Metadata["peer_pubkey"],
|
|
||||||
"--approval-subject=" + ev.UserMessage,
|
|
||||||
}
|
|
||||||
go s.eventHandler.runSelfCommand(s.ctx, "approval", args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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?"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
BIN
client/ui/assets/netbird-menu-dot-connected.png
Normal file
|
After Width: | Height: | Size: 452 B |
BIN
client/ui/assets/netbird-menu-dot-connecting.png
Normal file
|
After Width: | Height: | Size: 452 B |
BIN
client/ui/assets/netbird-menu-dot-error.png
Normal file
|
After Width: | Height: | Size: 433 B |
BIN
client/ui/assets/netbird-menu-dot-idle.png
Normal file
|
After Width: | Height: | Size: 483 B |
BIN
client/ui/assets/netbird-menu-dot-login.png
Normal file
|
After Width: | Height: | Size: 475 B |
BIN
client/ui/assets/netbird-menu-dot-offline.png
Normal file
|
After Width: | Height: | Size: 456 B |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 103 KiB |