Compare commits
221 Commits
refactor/m
...
ui-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1985caf993 | ||
|
|
16570b3223 | ||
|
|
967235e964 | ||
|
|
7d876571da | ||
|
|
e6a624dcee | ||
|
|
bee92f5fcd | ||
|
|
f4914fdfcc | ||
|
|
2cdc6ef1c6 | ||
|
|
3279b705fe | ||
|
|
e94a4cbce5 | ||
|
|
c1db8ab0ab | ||
|
|
2bf945e745 | ||
|
|
4556d52a60 | ||
|
|
51b243bdfa | ||
|
|
e09bc8894d | ||
|
|
55c1f44fb0 | ||
|
|
ac8d417c12 | ||
|
|
dccc0ebe4b | ||
|
|
35498c572a | ||
|
|
cda621bb27 | ||
|
|
d57b30f8d5 | ||
|
|
d82b950718 | ||
|
|
3bd058d425 | ||
|
|
0082f51830 | ||
|
|
e4420b1f96 | ||
|
|
a5635f8825 | ||
|
|
7ea5e37dd4 | ||
|
|
9d7ef9b255 | ||
|
|
966fbec119 | ||
|
|
f693d268b4 | ||
|
|
09f4109b01 | ||
|
|
ad7d7fa881 | ||
|
|
944a258459 | ||
|
|
b84c7618e7 | ||
|
|
ec5da43d73 | ||
|
|
a8ad73d2d9 | ||
|
|
a241112a1d | ||
|
|
e62dff0f66 | ||
|
|
5cecca2c23 | ||
|
|
0e83d2ad94 | ||
|
|
1f9a829f2c | ||
|
|
004a305e46 | ||
|
|
c77e5cef85 | ||
|
|
13179081d2 | ||
|
|
2d3c8fc555 | ||
|
|
61aa3a53ed | ||
|
|
80d6df6260 | ||
|
|
53bbc2d551 | ||
|
|
d9f0189b57 | ||
|
|
91e0520f27 | ||
|
|
67a1f3c4fe | ||
|
|
b6d20edfeb | ||
|
|
18d0019332 | ||
|
|
ecee7df5d8 | ||
|
|
1d783c33d9 | ||
|
|
b14feef1d7 | ||
|
|
0935a5675d | ||
|
|
4818599a93 | ||
|
|
f8c107b087 | ||
|
|
d624c2db74 | ||
|
|
513ecd456c | ||
|
|
8f957ff41a | ||
|
|
598fcbd817 | ||
|
|
17a365926d | ||
|
|
577ce6deb5 | ||
|
|
580cfa0dc5 | ||
|
|
8d4f35352f | ||
|
|
85029898a5 | ||
|
|
c3aeb5be15 | ||
|
|
df61f22d96 | ||
|
|
32df29bbd4 | ||
|
|
0a458ead8b | ||
|
|
aab8274b1a | ||
|
|
d3b660afba | ||
|
|
341848b1ae | ||
|
|
414e7815e4 | ||
|
|
ef6b4f7538 | ||
|
|
a7b26e3c0d | ||
|
|
42534b24c5 | ||
|
|
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 |
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: '22'
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 11
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev 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: '22'
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 11
|
||||||
|
- name: Install wails3 CLI
|
||||||
|
# Version derived from go.mod so the binding generator always matches
|
||||||
|
# the wails runtime the binary links against.
|
||||||
|
run: |
|
||||||
|
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||||
|
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||||
- name: Run GoReleaser
|
- 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:
|
||||||
|
|||||||
2
.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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -280,6 +280,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'
|
||||||
@@ -326,9 +363,9 @@ DetailPrint "Deleting application files..."
|
|||||||
Delete "$INSTDIR\${UI_APP_EXE}"
|
Delete "$INSTDIR\${UI_APP_EXE}"
|
||||||
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
||||||
Delete "$INSTDIR\wintun.dll"
|
Delete "$INSTDIR\wintun.dll"
|
||||||
!if ${ARCH} == "amd64"
|
# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove
|
||||||
|
# any leftover copy on uninstall so old upgrades don't leave it behind.
|
||||||
Delete "$INSTDIR\opengl32.dll"
|
Delete "$INSTDIR\opengl32.dll"
|
||||||
!endif
|
|
||||||
DetailPrint "Removing application directory..."
|
DetailPrint "Removing application directory..."
|
||||||
RmDir /r "$INSTDIR"
|
RmDir /r "$INSTDIR"
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,6 +22,25 @@ import (
|
|||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// peerLoginExpiredMsg is the exact phrase the management server returns
|
||||||
|
// when a previously SSO-enrolled peer's login has expired. Sourced from
|
||||||
|
// shared/management/status/error.go (NewPeerLoginExpiredError). Matched
|
||||||
|
// by substring so a future server-side rewording that keeps the phrase
|
||||||
|
// still triggers the friendly fallback in Login().
|
||||||
|
const peerLoginExpiredMsg = "peer login has expired"
|
||||||
|
|
||||||
|
// errSetupKeyOnSSOExpiredPeer replaces the raw management error when the
|
||||||
|
// user runs `netbird login -k <setup-key>` against a peer that was
|
||||||
|
// originally enrolled via SSO. Wrapped in a PermissionDenied gRPC status
|
||||||
|
// so callers' existing isPermissionDenied / isAuthError checks still
|
||||||
|
// classify it correctly (early-exit from retry backoff, StatusNeedsLogin
|
||||||
|
// in the server state machine).
|
||||||
|
var errSetupKeyOnSSOExpiredPeer = status.Error(
|
||||||
|
codes.PermissionDenied,
|
||||||
|
"this peer was originally enrolled via SSO and its session has expired. "+
|
||||||
|
"Setup keys can only enrol new peers — run `netbird up` (interactive SSO) to re-login.",
|
||||||
|
)
|
||||||
|
|
||||||
// Auth manages authentication operations with the management server
|
// Auth manages authentication operations with the management server
|
||||||
// It maintains a long-lived connection and automatically handles reconnection with backoff
|
// It maintains a long-lived connection and automatically handles reconnection with backoff
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
@@ -184,6 +204,15 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err
|
|||||||
log.Debugf("peer registration required")
|
log.Debugf("peer registration required")
|
||||||
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
|
_, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// The peer pub-key is already on file with the management
|
||||||
|
// server (originally enrolled via SSO) and the session has
|
||||||
|
// expired. The setup-key path can only enrol new peers, so
|
||||||
|
// retrying with -k will keep failing. Replace the raw mgm
|
||||||
|
// message with an actionable hint that tells the user to
|
||||||
|
// re-authenticate via SSO instead.
|
||||||
|
if setupKey != "" && jwtToken == "" && isPeerLoginExpired(err) {
|
||||||
|
err = errSetupKeyOnSSOExpiredPeer
|
||||||
|
}
|
||||||
isAuthError = isPermissionDenied(err)
|
isAuthError = isPermissionDenied(err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -474,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)
|
||||||
|
}
|
||||||
387
client/internal/auth/sessionwatch/watcher.go
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
// Package sessionwatch tracks the SSO session expiry deadline that the
|
||||||
|
// management server publishes via LoginResponse / SyncResponse and fires
|
||||||
|
// two warning events at fixed lead times before expiry: an interactive
|
||||||
|
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
|
||||||
|
// fallback dialog.
|
||||||
|
//
|
||||||
|
// The watcher is idempotent: Update may be called as often as the network
|
||||||
|
// map snapshots arrive. Repeating the same deadline is a no-op; a new
|
||||||
|
// deadline reschedules the timers and arms a fresh warning cycle.
|
||||||
|
//
|
||||||
|
// Warning firing is edge-detected. Each unique deadline value fires each
|
||||||
|
// warning callback at most once.
|
||||||
|
package sessionwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Skew tolerates a small clock difference between the management
|
||||||
|
// server and this peer before treating a deadline as "in the past".
|
||||||
|
// Slightly above typical NTP drift; tight enough that the UI doesn't
|
||||||
|
// paint a stale expiry as if it were valid.
|
||||||
|
Skew = 30 * time.Second
|
||||||
|
|
||||||
|
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||||
|
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||||
|
// glitch, and silently arming a 100-year timer would hide the bug.
|
||||||
|
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
|
||||||
|
|
||||||
|
// WarningLead is how far before expiry the first (interactive)
|
||||||
|
// warning fires. Drives the T-10 OS notification with
|
||||||
|
// Extend/Dismiss actions.
|
||||||
|
WarningLead = 10 * time.Minute
|
||||||
|
|
||||||
|
// FinalWarningLead is how far before expiry the fallback final
|
||||||
|
// warning fires. Drives the auto-opened SessionAboutToExpire dialog,
|
||||||
|
// but only when the user has not dismissed the T-WarningLead warning
|
||||||
|
// for the same deadline. Must be strictly less than WarningLead.
|
||||||
|
FinalWarningLead = 2 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrDeadlineBeforeEpoch is returned by Update when the supplied
|
||||||
|
// deadline pre-dates 1970-01-01.
|
||||||
|
ErrDeadlineBeforeEpoch = errors.New("session deadline before unix epoch")
|
||||||
|
|
||||||
|
// ErrDeadlineTooFarFuture is returned by Update when the supplied
|
||||||
|
// deadline is more than maxDeadlineHorizon in the future.
|
||||||
|
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
|
||||||
|
|
||||||
|
// ErrDeadlineInPast is returned by Update when the supplied deadline
|
||||||
|
// is more than Skew in the past.
|
||||||
|
ErrDeadlineInPast = errors.New("session deadline in the past")
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusRecorder is the side-effect surface the watcher drives on every
|
||||||
|
// state transition. Production wires this to peer.Status (SetSessionExpiresAt
|
||||||
|
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
||||||
|
// a fake recorder so the same surface is observable without an engine.
|
||||||
|
//
|
||||||
|
// The watcher is the single owner of the deadline propagated to the
|
||||||
|
// recorder: every set, clear, sanity-check rejection and Close routes the
|
||||||
|
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
|
||||||
|
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
|
||||||
|
// fans out its own state-change notification, so no separate notify is
|
||||||
|
// needed.) The recorder is server-scoped and outlives this engine-scoped
|
||||||
|
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
|
||||||
|
// a profile switch) would leave the next session showing the previous one's
|
||||||
|
// stale "expires in" value.
|
||||||
|
//
|
||||||
|
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
|
||||||
|
// composes the metadata internally so the wire format (MetaSession*) is
|
||||||
|
// owned by sessionwatch, not the caller.
|
||||||
|
type StatusRecorder interface {
|
||||||
|
SetSessionExpiresAt(deadline time.Time)
|
||||||
|
PublishEvent(
|
||||||
|
severity cProto.SystemEvent_Severity,
|
||||||
|
category cProto.SystemEvent_Category,
|
||||||
|
message string,
|
||||||
|
userMessage string,
|
||||||
|
metadata map[string]string,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher observes the latest session deadline and fires two warnings
|
||||||
|
// before it expires: the interactive T-WarningLead notification, and the
|
||||||
|
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
|
||||||
|
// the first one for the same deadline). Safe for concurrent use.
|
||||||
|
type Watcher struct {
|
||||||
|
lead time.Duration
|
||||||
|
finalLead time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
current time.Time
|
||||||
|
timer *time.Timer
|
||||||
|
finalTimer *time.Timer
|
||||||
|
firedAt time.Time // deadline value the T-WarningLead callback last fired against
|
||||||
|
finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against
|
||||||
|
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
|
||||||
|
closed bool
|
||||||
|
recorder StatusRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a watcher with the package defaults WarningLead and
|
||||||
|
// FinalWarningLead. Pass nil for recorder to silence side effects (handy
|
||||||
|
// in unit tests that exercise sanity checks without observing the publish
|
||||||
|
// path).
|
||||||
|
func New(recorder StatusRecorder) *Watcher {
|
||||||
|
return NewWithLeads(WarningLead, FinalWarningLead, recorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithLeads returns a watcher with custom lead times. Useful for tests.
|
||||||
|
// final must be strictly less than lead; otherwise both timers fire in the
|
||||||
|
// wrong order or simultaneously and the UI flow breaks. A zero final lead
|
||||||
|
// disables the final-warning timer entirely (see armTimerLocked) so a
|
||||||
|
// millisecond-scale deadline doesn't flush both timers in one tick.
|
||||||
|
func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||||
|
return &Watcher{
|
||||||
|
lead: lead,
|
||||||
|
finalLead: final,
|
||||||
|
recorder: recorder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
|
||||||
|
// a Sync push from the server omits the field because login expiration
|
||||||
|
// was disabled).
|
||||||
|
//
|
||||||
|
// Same-value updates are no-ops. A different non-zero value cancels any
|
||||||
|
// pending timer, resets the "already fired" guard, and arms a new one.
|
||||||
|
//
|
||||||
|
// Returns one of the sentinel Err* values when the deadline fails the
|
||||||
|
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
|
||||||
|
// In every error case the watcher first clears its state so it stays
|
||||||
|
// consistent with what the caller will push into its other sinks (e.g.
|
||||||
|
// applySessionDeadline forces a zero deadline into the status recorder
|
||||||
|
// after a non-nil error).
|
||||||
|
func (w *Watcher) Update(deadline time.Time) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if deadline.IsZero() {
|
||||||
|
w.clearLocked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
switch {
|
||||||
|
case deadline.Before(time.Unix(0, 0)):
|
||||||
|
w.clearLocked()
|
||||||
|
return fmt.Errorf("%w: %v", ErrDeadlineBeforeEpoch, deadline)
|
||||||
|
case deadline.After(now.Add(maxDeadlineHorizon)):
|
||||||
|
w.clearLocked()
|
||||||
|
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
|
||||||
|
case deadline.Before(now.Add(-Skew)):
|
||||||
|
w.clearLocked()
|
||||||
|
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deadline.Equal(w.current) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w.stopTimerLocked()
|
||||||
|
w.current = deadline
|
||||||
|
// Reset every per-deadline guard so a refreshed deadline arms a fresh
|
||||||
|
// warning cycle: both edge triggers and the user Dismiss decision
|
||||||
|
// (the user agreed to the old deadline expiring; a new deadline
|
||||||
|
// restarts the contract).
|
||||||
|
w.firedAt = time.Time{}
|
||||||
|
w.finalFiredAt = time.Time{}
|
||||||
|
w.dismissedAt = time.Time{}
|
||||||
|
|
||||||
|
w.armTimerLocked(deadline)
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder != nil {
|
||||||
|
recorder.SetSessionExpiresAt(deadline)
|
||||||
|
}
|
||||||
|
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline returns the most recently observed deadline. Zero when no
|
||||||
|
// deadline is currently tracked.
|
||||||
|
func (w *Watcher) Deadline() time.Time {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.current
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss records the user's "Dismiss" action against the current deadline
|
||||||
|
// and suppresses the upcoming final-warning callback for that deadline.
|
||||||
|
// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
|
||||||
|
// deadline resets the dismissal so the final-warning cycle re-arms.
|
||||||
|
//
|
||||||
|
// No-op when the watcher holds no deadline or has been closed.
|
||||||
|
func (w *Watcher) Dismiss() {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.closed || w.current.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.dismissedAt.Equal(w.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.dismissedAt = w.current
|
||||||
|
// Cancel the armed final-warning timer eagerly. fireFinal would also
|
||||||
|
// gate on dismissedAt, but stopping the timer avoids a wakeup with
|
||||||
|
// nothing to do and makes the intent visible.
|
||||||
|
if w.finalTimer != nil {
|
||||||
|
w.finalTimer.Stop()
|
||||||
|
w.finalTimer = nil
|
||||||
|
}
|
||||||
|
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops any pending timer and drops the deadline on the status
|
||||||
|
// recorder. Update calls after Close are ignored. Clearing the recorder
|
||||||
|
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
|
||||||
|
// from leaving the next session showing this one's stale "expires in"
|
||||||
|
// value — the recorder is server-scoped and outlives this engine-scoped
|
||||||
|
// watcher, so nothing else drops the anchor on teardown.
|
||||||
|
func (w *Watcher) Close() {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.closed = true
|
||||||
|
w.stopTimerLocked()
|
||||||
|
hadDeadline := !w.current.IsZero()
|
||||||
|
w.current = time.Time{}
|
||||||
|
w.firedAt = time.Time{}
|
||||||
|
w.finalFiredAt = time.Time{}
|
||||||
|
w.dismissedAt = time.Time{}
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder != nil && hadDeadline {
|
||||||
|
recorder.SetSessionExpiresAt(time.Time{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||||
|
// downstream consumers (SubscribeStatus stream, UI) drop their anchor.
|
||||||
|
// The caller must hold w.mu; this helper releases it before invoking
|
||||||
|
// the recorder.
|
||||||
|
func (w *Watcher) clearLocked() {
|
||||||
|
if w.current.IsZero() {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.stopTimerLocked()
|
||||||
|
w.current = time.Time{}
|
||||||
|
w.firedAt = time.Time{}
|
||||||
|
w.finalFiredAt = time.Time{}
|
||||||
|
w.dismissedAt = time.Time{}
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder != nil {
|
||||||
|
recorder.SetSessionExpiresAt(time.Time{})
|
||||||
|
}
|
||||||
|
log.Infof("auth session deadline cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) stopTimerLocked() {
|
||||||
|
if w.timer != nil {
|
||||||
|
w.timer.Stop()
|
||||||
|
w.timer = nil
|
||||||
|
}
|
||||||
|
if w.finalTimer != nil {
|
||||||
|
w.finalTimer.Stop()
|
||||||
|
w.finalTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) armTimerLocked(deadline time.Time) {
|
||||||
|
w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) })
|
||||||
|
// finalLead <= 0 disables the final-warning timer entirely. Used by
|
||||||
|
// tests that predate the final-warning fallback so a millisecond-scale
|
||||||
|
// deadline does not flush both timers at once.
|
||||||
|
if w.finalLead > 0 {
|
||||||
|
w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) fire(armedFor time.Time) {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed || !w.current.Equal(armedFor) {
|
||||||
|
// Deadline moved while we were waiting (e.g. a successful extend).
|
||||||
|
// The reschedule path armed a fresh timer; this one is stale.
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.firedAt = armedFor
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("auth session expiry soon warning fired")
|
||||||
|
publishWarning(recorder, armedFor, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra
|
||||||
|
// dismiss-gate: if the user dismissed the T-WarningLead notification for
|
||||||
|
// this deadline, the final warning is suppressed entirely.
|
||||||
|
func (w *Watcher) fireFinal(armedFor time.Time) {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.closed || !w.current.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.dismissedAt.Equal(armedFor) {
|
||||||
|
w.mu.Unlock()
|
||||||
|
log.Infof("auth session final-warning skipped (dismissed by user)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.finalFiredAt = armedFor
|
||||||
|
recorder := w.recorder
|
||||||
|
w.mu.Unlock()
|
||||||
|
if recorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("auth session final-warning fired")
|
||||||
|
publishWarning(recorder, armedFor, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
|
||||||
|
// past it dispatches on the next scheduler tick so a state-change recorder
|
||||||
|
// notification (invoked after w.mu is released) lands first. Caller must
|
||||||
|
// hold w.mu.
|
||||||
|
func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer {
|
||||||
|
delay := time.Until(fireAt)
|
||||||
|
if delay <= 0 {
|
||||||
|
return time.AfterFunc(0, cb)
|
||||||
|
}
|
||||||
|
return time.AfterFunc(delay, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishWarning composes the SystemEvent for a watcher-fired warning and
|
||||||
|
// pushes it through the recorder. Severity is CRITICAL on both — bypassing
|
||||||
|
// the user's Notifications toggle is deliberate: missing the warning
|
||||||
|
// window forces the post-mortem SessionExpired flow (tunnel torn down,
|
||||||
|
// lock icon, manual re-login), which is the UX we are trying to avoid.
|
||||||
|
func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
|
||||||
|
lead := WarningLead
|
||||||
|
message := "session expiry warning"
|
||||||
|
meta := map[string]string{
|
||||||
|
MetaSessionWarning: "true",
|
||||||
|
MetaSessionExpiresAt: FormatExpiresAt(deadline),
|
||||||
|
}
|
||||||
|
if final {
|
||||||
|
lead = FinalWarningLead
|
||||||
|
message = "session expiry final warning"
|
||||||
|
meta[MetaSessionFinal] = "true"
|
||||||
|
}
|
||||||
|
meta[MetaSessionLeadMinutes] = FormatLeadMinutes(lead)
|
||||||
|
|
||||||
|
recorder.PublishEvent(
|
||||||
|
cProto.SystemEvent_CRITICAL,
|
||||||
|
cProto.SystemEvent_AUTHENTICATION,
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
meta,
|
||||||
|
)
|
||||||
|
}
|
||||||
519
client/internal/auth/sessionwatch/watcher_test.go
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
package sessionwatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRecorder satisfies StatusRecorder and records every call so tests
|
||||||
|
// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent
|
||||||
|
// land in the same ordered events slice (with the Kind distinguishing
|
||||||
|
// them) so tests that care about ordering still work. lastDeadline holds
|
||||||
|
// the most recent value passed to SetSessionExpiresAt so tests can assert
|
||||||
|
// the recorder ended up cleared/set as expected.
|
||||||
|
type fakeRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []event
|
||||||
|
lastDeadline time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateChange eventKind = iota
|
||||||
|
publish
|
||||||
|
)
|
||||||
|
|
||||||
|
type event struct {
|
||||||
|
kind eventKind
|
||||||
|
// Set only for publish events.
|
||||||
|
severity cProto.SystemEvent_Severity
|
||||||
|
category cProto.SystemEvent_Category
|
||||||
|
message string
|
||||||
|
meta map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op,
|
||||||
|
// a real change records the new value and fans out a state-change (the
|
||||||
|
// production recorder calls notifyStateChange internally). The baseline
|
||||||
|
// is the zero time, so an initial clear before any deadline is set emits
|
||||||
|
// nothing — matching the real recorder.
|
||||||
|
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.lastDeadline.Equal(deadline) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.lastDeadline = deadline
|
||||||
|
r.events = append(r.events, event{kind: stateChange})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRecorder) deadline() time.Time {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.lastDeadline
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRecorder) PublishEvent(
|
||||||
|
severity cProto.SystemEvent_Severity,
|
||||||
|
category cProto.SystemEvent_Category,
|
||||||
|
message string,
|
||||||
|
_ string,
|
||||||
|
metadata map[string]string,
|
||||||
|
) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.events = append(r.events, event{
|
||||||
|
kind: publish,
|
||||||
|
severity: severity,
|
||||||
|
category: category,
|
||||||
|
message: message,
|
||||||
|
meta: metadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRecorder) snapshot() []event {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]event, len(r.events))
|
||||||
|
copy(out, r.events)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e event) isFinalWarning() bool {
|
||||||
|
return e.kind == publish && e.meta[MetaSessionFinal] == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e event) isWarning() bool {
|
||||||
|
return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
func countWhere(events []event, pred func(event) bool) int {
|
||||||
|
n := 0
|
||||||
|
for _, e := range events {
|
||||||
|
if pred(e) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if got := r.snapshot(); len(got) >= want {
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
got := r.snapshot()
|
||||||
|
t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
|
||||||
|
// matching the lead-only behaviour the pre-final-warning tests assume.
|
||||||
|
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
||||||
|
return NewWithLeads(lead, 0, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
_ = w.Update(time.Time{})
|
||||||
|
|
||||||
|
if got := r.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no events on initial zero, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNonZeroFiresStateChange(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(time.Hour)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 1)
|
||||||
|
if events[0].kind != stateChange {
|
||||||
|
t.Fatalf("expected stateChange, got %+v", events[0])
|
||||||
|
}
|
||||||
|
if !w.Deadline().Equal(d) {
|
||||||
|
t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameDeadlineIsNoop(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(time.Hour)
|
||||||
|
_ = w.Update(d)
|
||||||
|
_ = w.Update(d)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 1)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
lead := 50 * time.Millisecond
|
||||||
|
w := newWatcher(lead, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
// Deadline 80ms out — warning should fire after ~30ms.
|
||||||
|
d := time.Now().Add(80 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if events[0].kind != stateChange {
|
||||||
|
t.Fatalf("event[0] should be stateChange, got %+v", events[0])
|
||||||
|
}
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("event[1] should be a warning publish, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r) // lead > delta => fire immediately
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(10 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if !events[1].isWarning() {
|
||||||
|
t.Fatalf("expected immediate warning publish, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
lead := 50 * time.Millisecond
|
||||||
|
w := newWatcher(lead, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
|
||||||
|
_ = w.Update(first)
|
||||||
|
|
||||||
|
// Replace with a far-future deadline before the warning fires.
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
second := time.Now().Add(time.Hour)
|
||||||
|
_ = w.Update(second)
|
||||||
|
|
||||||
|
// Wait past when first's warning would have fired.
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
|
||||||
|
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
|
||||||
|
t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
lead := 30 * time.Millisecond
|
||||||
|
w := newWatcher(lead, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
first := time.Now().Add(50 * time.Millisecond)
|
||||||
|
_ = w.Update(first)
|
||||||
|
|
||||||
|
// Wait for stateChange + warning of the first cycle.
|
||||||
|
waitForEvents(t, r, 2)
|
||||||
|
|
||||||
|
// Simulate a successful extend: brand new deadline.
|
||||||
|
second := time.Now().Add(60 * time.Millisecond)
|
||||||
|
_ = w.Update(second)
|
||||||
|
|
||||||
|
// 4 events total: stateChange, warning (first), stateChange, warning (second).
|
||||||
|
events := waitForEvents(t, r, 4)
|
||||||
|
if events[2].kind != stateChange {
|
||||||
|
t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2])
|
||||||
|
}
|
||||||
|
if !events[3].isWarning() {
|
||||||
|
t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(2 * time.Hour)
|
||||||
|
_ = w.Update(d)
|
||||||
|
waitForEvents(t, r, 1)
|
||||||
|
|
||||||
|
_ = w.Update(time.Time{})
|
||||||
|
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if events[1].kind != stateChange {
|
||||||
|
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("Deadline should be zero after clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRejectsBeforeEpoch(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
good := time.Now().Add(time.Hour)
|
||||||
|
if err := w.Update(good); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := w.Update(time.Unix(-100, 0))
|
||||||
|
if !errors.Is(err, ErrDeadlineBeforeEpoch) {
|
||||||
|
t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
good := time.Now().Add(time.Hour)
|
||||||
|
if err := w.Update(good); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour))
|
||||||
|
if !errors.Is(err, ErrDeadlineTooFarFuture) {
|
||||||
|
t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
good := time.Now().Add(time.Hour)
|
||||||
|
if err := w.Update(good); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
// Drain the stateChange from the seed.
|
||||||
|
waitForEvents(t, r, 1)
|
||||||
|
|
||||||
|
err := w.Update(time.Now().Add(-1 * time.Hour))
|
||||||
|
if !errors.Is(err, ErrDeadlineInPast) {
|
||||||
|
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().IsZero() {
|
||||||
|
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
|
||||||
|
}
|
||||||
|
events := waitForEvents(t, r, 2)
|
||||||
|
if events[1].kind != stateChange {
|
||||||
|
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateWithinSkewAccepted(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
|
||||||
|
d := time.Now().Add(-5 * time.Second)
|
||||||
|
if err := w.Update(d); err != nil {
|
||||||
|
t.Fatalf("within-skew Update should succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if !w.Deadline().Equal(d) {
|
||||||
|
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseSilencesUpdates(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(50*time.Millisecond, r)
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
_ = w.Update(time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if got := r.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no events after Close, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
|
||||||
|
// holding a live deadline must zero the recorder on Close so the next
|
||||||
|
// engine's watcher (and the UI reading the shared server-scoped recorder)
|
||||||
|
// doesn't start out showing the previous session's stale "expires in".
|
||||||
|
func TestCloseClearsRecorderDeadline(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r)
|
||||||
|
|
||||||
|
d := time.Now().Add(2 * time.Hour)
|
||||||
|
if err := w.Update(d); err != nil {
|
||||||
|
t.Fatalf("seed Update: %v", err)
|
||||||
|
}
|
||||||
|
if got := r.deadline(); !got.Equal(d) {
|
||||||
|
t.Fatalf("recorder deadline after Update = %v, want %v", got, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
if got := r.deadline(); !got.IsZero() {
|
||||||
|
t.Fatalf("recorder deadline after Close = %v, want zero", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric
|
||||||
|
// case: closing a watcher that never held a deadline must not emit a
|
||||||
|
// redundant clear (the recorder may legitimately hold a value written by
|
||||||
|
// some other path; the watcher only owns what it set).
|
||||||
|
func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := newWatcher(time.Hour, r)
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
if got := r.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no events from Close on an empty watcher, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
// Warning fires at deadline-80ms, final at deadline-30ms.
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
// Expect stateChange + warning + final-warning.
|
||||||
|
events := waitForEvents(t, r, 3)
|
||||||
|
|
||||||
|
if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 stateChange, got %+v", events)
|
||||||
|
}
|
||||||
|
if countWhere(events, event.isWarning) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 warning publish, got %+v", events)
|
||||||
|
}
|
||||||
|
if countWhere(events, event.isFinalWarning) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 final-warning publish, got %+v", events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warning must precede final (same deadline, longer lead fires first).
|
||||||
|
var wIdx, fIdx int
|
||||||
|
for i, e := range events {
|
||||||
|
switch {
|
||||||
|
case e.isWarning():
|
||||||
|
wIdx = i
|
||||||
|
case e.isFinalWarning():
|
||||||
|
fIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wIdx > fIdx {
|
||||||
|
t.Fatalf("warning must publish before final-warning, got order %+v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissSuppressesFinalWarning(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
// Wait for the warning publish so we know we're inside the warning
|
||||||
|
// window, then dismiss before the final timer would fire.
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if countWhere(r.snapshot(), event.isWarning) >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if countWhere(r.snapshot(), event.isWarning) < 1 {
|
||||||
|
t.Fatalf("warning did not publish in time, events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Dismiss()
|
||||||
|
|
||||||
|
// Now wait past when the final would have fired.
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
|
||||||
|
if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 {
|
||||||
|
t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissResetByNewDeadline(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
first := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(first)
|
||||||
|
|
||||||
|
// Dismiss against the first deadline.
|
||||||
|
w.Dismiss()
|
||||||
|
|
||||||
|
// Replace with a fresh deadline before the first's timers complete.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
second := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(second)
|
||||||
|
|
||||||
|
// The second cycle must publish a final-warning (the dismiss state
|
||||||
|
// did not carry over).
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if countWhere(r.snapshot(), event.isFinalWarning) < 1 {
|
||||||
|
t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
||||||
|
r := &fakeRecorder{}
|
||||||
|
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
|
||||||
|
w.Dismiss()
|
||||||
|
|
||||||
|
d := time.Now().Add(100 * time.Millisecond)
|
||||||
|
_ = w.Update(d)
|
||||||
|
|
||||||
|
// Final warning should still publish — Dismiss only acts on the current
|
||||||
|
// deadline, and there was none at the time of the call.
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -35,6 +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/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"
|
||||||
@@ -250,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
|
||||||
@@ -293,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
|
||||||
@@ -333,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()
|
||||||
}
|
}
|
||||||
@@ -865,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)
|
||||||
}
|
}
|
||||||
|
|||||||
99
client/internal/engine_authsession.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplySessionDeadline propagates the absolute SSO session deadline carried on
|
||||||
|
// LoginResponse / SyncResponse to both the watcher (for the edge-triggered
|
||||||
|
// warning) and the status recorder (for the SubscribeStatus / Status RPC
|
||||||
|
// snapshot the UI consumes).
|
||||||
|
//
|
||||||
|
// The wire field is 3-state:
|
||||||
|
// - nil → snapshot carries no info; keep the
|
||||||
|
// previously-anchored deadline (no-op)
|
||||||
|
// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is
|
||||||
|
// disabled; clear both sinks
|
||||||
|
// - valid timestamp → new deadline; arm watcher, expose on
|
||||||
|
// status recorder
|
||||||
|
//
|
||||||
|
// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected
|
||||||
|
// value is treated as a clear on both sinks: the alternative — leaving the
|
||||||
|
// previously-known deadline in place — risks the UI confidently displaying
|
||||||
|
// a stale "expires in X" while the server has actually invalidated it.
|
||||||
|
func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
|
||||||
|
if ts == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var deadline time.Time
|
||||||
|
// Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled".
|
||||||
|
// Everything else flows through Watcher.Update, whose sanity-checks
|
||||||
|
// reject out-of-range / pre-epoch / far-future / too-stale values and
|
||||||
|
// clear on rejection.
|
||||||
|
if ts.GetSeconds() != 0 || ts.GetNanos() != 0 {
|
||||||
|
deadline = ts.AsTime().UTC()
|
||||||
|
}
|
||||||
|
if e.sessionWatcher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Watcher.Update owns the propagation to the status recorder (the
|
||||||
|
// SubscribeStatus / Status snapshot the UI reads): a set writes the
|
||||||
|
// deadline, a clear or a sanity-check rejection writes the zero value.
|
||||||
|
// Keeping a single writer is what stops the recorder from drifting out
|
||||||
|
// of sync with the warning timers.
|
||||||
|
if err := e.sessionWatcher.Update(deadline); err != nil {
|
||||||
|
log.Errorf("auth session deadline rejected: %v, clearing", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissSessionWarning records the user's "Dismiss" click on the
|
||||||
|
// T-WarningLead interactive notification and suppresses the upcoming
|
||||||
|
// T-FinalWarningLead fallback for the current deadline. No-op when the
|
||||||
|
// watcher is not running or holds no deadline.
|
||||||
|
func (e *Engine) DismissSessionWarning() {
|
||||||
|
if e.sessionWatcher == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.sessionWatcher.Dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtendAuthSession asks the management server to refresh the SSO session
|
||||||
|
// expiry deadline using the supplied JWT, then mirrors the new deadline into
|
||||||
|
// the daemon's state. The tunnel is untouched; no resync, no reconnect.
|
||||||
|
//
|
||||||
|
// Returns the new absolute UTC deadline (or zero time when the server
|
||||||
|
// reports the peer is not eligible for extension).
|
||||||
|
func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) {
|
||||||
|
if jwtToken == "" {
|
||||||
|
return time.Time{}, errors.New("jwt token is required")
|
||||||
|
}
|
||||||
|
if e.mgmClient == nil {
|
||||||
|
return time.Time{}, errors.New("management client is not initialised")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := system.GetInfoWithChecks(ctx, e.checks)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to collect system info for session extend: %v", err)
|
||||||
|
info = system.GetInfo(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("extend auth session on management: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.ApplySessionDeadline(resp.GetSessionExpiresAt())
|
||||||
|
|
||||||
|
if resp.GetSessionExpiresAt().IsValid() {
|
||||||
|
return resp.GetSessionExpiresAt().AsTime().UTC(), nil
|
||||||
|
}
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
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")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/peer/id"
|
"github.com/netbirdio/netbird/client/internal/peer/id"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer/worker"
|
"github.com/netbirdio/netbird/client/internal/peer/worker"
|
||||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||||
@@ -899,7 +900,7 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to deterministic key if no NetBird PSK is configured
|
// Fallback to deterministic key if no NetBird PSK is configured
|
||||||
determKey, err := conn.rosenpassDetermKey()
|
determKey, err := rosenpass.DeterministicSeedKey(conn.config.LocalKey, conn.config.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err)
|
conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err)
|
||||||
return nil
|
return nil
|
||||||
@@ -908,26 +909,6 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
|
|||||||
return determKey
|
return determKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: move this logic into Rosenpass package
|
|
||||||
func (conn *Conn) rosenpassDetermKey() (*wgtypes.Key, error) {
|
|
||||||
lk := []byte(conn.config.LocalKey)
|
|
||||||
rk := []byte(conn.config.Key) // remote key
|
|
||||||
var keyInput []byte
|
|
||||||
if string(lk) > string(rk) {
|
|
||||||
//nolint:gocritic
|
|
||||||
keyInput = append(lk[:16], rk[:16]...)
|
|
||||||
} else {
|
|
||||||
//nolint:gocritic
|
|
||||||
keyInput = append(rk[:16], lk[:16]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := wgtypes.NewKey(keyInput)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isController(config ConnConfig) bool {
|
func isController(config ConnConfig) bool {
|
||||||
return config.LocalKey > config.Key
|
return config.LocalKey > config.Key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -190,21 +191,27 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
// every private-service request) don't contend against each other.
|
// every private-service request) don't contend against each other.
|
||||||
// Pure read methods take RLock; anything that mutates state takes Lock.
|
// Pure read methods take RLock; anything that mutates state takes Lock.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
mux sync.RWMutex
|
mux sync.RWMutex
|
||||||
peers map[string]State
|
peers map[string]State
|
||||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||||
signalState bool
|
signalState bool
|
||||||
signalError error
|
signalError error
|
||||||
managementState bool
|
managementState bool
|
||||||
managementError error
|
managementError error
|
||||||
relayStates []relay.ProbeResult
|
relayStates []relay.ProbeResult
|
||||||
localPeer LocalPeerState
|
localPeer LocalPeerState
|
||||||
offlinePeers []State
|
offlinePeers []State
|
||||||
mgmAddress string
|
mgmAddress string
|
||||||
signalAddress string
|
signalAddress string
|
||||||
notifier *notifier
|
notifier *notifier
|
||||||
rosenpassEnabled bool
|
rosenpassEnabled bool
|
||||||
rosenpassPermissive bool
|
rosenpassPermissive bool
|
||||||
|
// sessionExpiresAt is the absolute UTC instant at which the peer's SSO
|
||||||
|
// session expires. Zero when the peer is not SSO-tracked or login
|
||||||
|
// expiration is disabled. Populated from management LoginResponse /
|
||||||
|
// SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC
|
||||||
|
// so the UI can show remaining time without itself talking to mgm.
|
||||||
|
sessionExpiresAt time.Time
|
||||||
nsGroupStates []NSGroupState
|
nsGroupStates []NSGroupState
|
||||||
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
||||||
lazyConnectionEnabled bool
|
lazyConnectionEnabled bool
|
||||||
@@ -220,6 +227,21 @@ type Status struct {
|
|||||||
eventStreams map[string]chan *proto.SystemEvent
|
eventStreams map[string]chan *proto.SystemEvent
|
||||||
eventQueue *EventQueue
|
eventQueue *EventQueue
|
||||||
|
|
||||||
|
// stateChangeStreams fan-out connection-state changes (connected /
|
||||||
|
// disconnected / connecting / address change / peers list change) to
|
||||||
|
// every active SubscribeStatus gRPC stream. Each subscriber gets a
|
||||||
|
// buffered chan; the notifier non-blockingly pings them so a slow
|
||||||
|
// consumer can never stall the daemon.
|
||||||
|
stateChangeMux sync.Mutex
|
||||||
|
stateChangeStreams map[string]chan struct{}
|
||||||
|
|
||||||
|
// networksRevision bumps whenever the routed-networks set or their
|
||||||
|
// selected state changes (driven by the route manager). Surfaced in the
|
||||||
|
// status snapshot so the UI can fingerprint on it and re-fetch
|
||||||
|
// ListNetworks only on a real change. Atomic so the snapshot builder can
|
||||||
|
// read it without taking mux.
|
||||||
|
networksRevision atomic.Uint64
|
||||||
|
|
||||||
ingressGwMgr *ingressgw.Manager
|
ingressGwMgr *ingressgw.Manager
|
||||||
|
|
||||||
routeIDLookup routeIDLookup
|
routeIDLookup routeIDLookup
|
||||||
@@ -233,6 +255,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,
|
||||||
@@ -382,6 +405,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +431,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +457,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +507,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +544,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,6 +580,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,6 +619,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,6 +713,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 {
|
||||||
@@ -741,6 +772,41 @@ func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.localAddressChanged(fqdn, ip)
|
d.notifier.localAddressChanged(fqdn, ip)
|
||||||
|
d.notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionExpiresAt records the absolute UTC instant at which the peer's
|
||||||
|
// SSO session is set to expire. Pass the zero value to clear (e.g. when the
|
||||||
|
// management server stops publishing a deadline because login expiration was
|
||||||
|
// disabled or the peer is not SSO-tracked). Same-value updates are no-ops;
|
||||||
|
// real changes fan out via notifyStateChange so SubscribeStatus consumers
|
||||||
|
// pick up the new deadline on their next read.
|
||||||
|
func (d *Status) SetSessionExpiresAt(deadline time.Time) {
|
||||||
|
d.mux.Lock()
|
||||||
|
if d.sessionExpiresAt.Equal(deadline) {
|
||||||
|
d.mux.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.sessionExpiresAt = deadline
|
||||||
|
d.mux.Unlock()
|
||||||
|
d.notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
|
||||||
|
// or the zero value when no deadline is tracked. A deadline that has already
|
||||||
|
// slipped into the past reports as "none": once the session has expired it is
|
||||||
|
// no longer a meaningful countdown, and the sessionwatch.Watcher does not
|
||||||
|
// arm a timer at the deadline itself to clear it (only the two pre-expiry
|
||||||
|
// warnings). Without this guard the UI would keep painting a stale
|
||||||
|
// "expires in …" against a moment that has passed until the next login,
|
||||||
|
// extend, or teardown rewrote the value.
|
||||||
|
func (d *Status) GetSessionExpiresAt() time.Time {
|
||||||
|
d.mux.Lock()
|
||||||
|
defer d.mux.Unlock()
|
||||||
|
if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return d.sessionExpiresAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLocalPeerStateRoute adds a route to the local peer state
|
// AddLocalPeerStateRoute adds a route to the local peer state
|
||||||
@@ -809,6 +875,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
|
||||||
@@ -821,6 +888,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
|
||||||
@@ -833,6 +901,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
|
||||||
@@ -873,6 +942,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
|
||||||
@@ -885,6 +955,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) {
|
||||||
@@ -1082,16 +1153,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
|
||||||
@@ -1233,6 +1307,79 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent {
|
|||||||
return d.eventQueue.GetAll()
|
return d.eventQueue.GetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubscribeToStateChanges hands back a channel that receives a tick on
|
||||||
|
// every connection-state change (connected / disconnected / connecting /
|
||||||
|
// address change / peers-list change). The channel is buffered to one
|
||||||
|
// pending tick so a coalesced burst still wakes the consumer exactly
|
||||||
|
// once. Pass the returned id to UnsubscribeFromStateChanges to detach.
|
||||||
|
func (d *Status) SubscribeToStateChanges() (string, <-chan struct{}) {
|
||||||
|
d.stateChangeMux.Lock()
|
||||||
|
defer d.stateChangeMux.Unlock()
|
||||||
|
|
||||||
|
id := uuid.New().String()
|
||||||
|
ch := make(chan struct{}, 1)
|
||||||
|
d.stateChangeStreams[id] = ch
|
||||||
|
return id, ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel
|
||||||
|
// and closes it so any consumer goroutine selecting on the channel
|
||||||
|
// unblocks cleanly.
|
||||||
|
func (d *Status) UnsubscribeFromStateChanges(id string) {
|
||||||
|
d.stateChangeMux.Lock()
|
||||||
|
defer d.stateChangeMux.Unlock()
|
||||||
|
|
||||||
|
if ch, ok := d.stateChangeStreams[id]; ok {
|
||||||
|
close(ch)
|
||||||
|
delete(d.stateChangeStreams, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops
|
||||||
|
// the tick if a subscriber's buffer is full — by definition the consumer
|
||||||
|
// is already going to fetch the latest snapshot, so multiple pending ticks
|
||||||
|
// would be redundant.
|
||||||
|
func (d *Status) notifyStateChange() {
|
||||||
|
d.stateChangeMux.Lock()
|
||||||
|
defer d.stateChangeMux.Unlock()
|
||||||
|
|
||||||
|
for _, ch := range d.stateChangeStreams {
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyStateChange is the public wake-the-subscribers entry point used by
|
||||||
|
// callers that mutate state outside the peer recorder — most importantly
|
||||||
|
// the connect-state machine, which writes StatusNeedsLogin into the
|
||||||
|
// shared contextState (client/internal/state.go) without touching any
|
||||||
|
// recorder field. Without this push the SubscribeStatus stream stays on
|
||||||
|
// the previous snapshot until an unrelated peer/management/signal
|
||||||
|
// change happens to fire notifyStateChange, leaving the UI's status
|
||||||
|
// out of sync with the daemon.
|
||||||
|
func (d *Status) NotifyStateChange() {
|
||||||
|
d.notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BumpNetworksRevision increments the routed-networks revision and wakes every
|
||||||
|
// SubscribeStatus subscriber. The route manager calls it when a network map
|
||||||
|
// changes the available routes or when a selection is applied — the peer
|
||||||
|
// status itself only records actively-routed (chosen) networks, so without
|
||||||
|
// this bump a candidate route appearing/disappearing would never reach the UI.
|
||||||
|
func (d *Status) BumpNetworksRevision() {
|
||||||
|
d.networksRevision.Add(1)
|
||||||
|
d.notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNetworksRevision returns the current routed-networks revision, surfaced in
|
||||||
|
// the status snapshot so the UI can detect route/selection changes (see
|
||||||
|
// BumpNetworksRevision).
|
||||||
|
func (d *Status) GetNetworksRevision() uint64 {
|
||||||
|
return d.networksRevision.Load()
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.Unlock()
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ func hashRosenpassKey(key []byte) string {
|
|||||||
return hex.EncodeToString(hasher.Sum(nil))
|
return hex.EncodeToString(hasher.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rpServer is the subset of rp.Server used by Manager. Defined as an interface
|
||||||
|
// so tests can substitute a mock without spinning up a real UDP server.
|
||||||
|
type rpServer interface {
|
||||||
|
AddPeer(rp.PeerConfig) (rp.PeerID, error)
|
||||||
|
RemovePeer(rp.PeerID) error
|
||||||
|
Run() error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
ifaceName string
|
ifaceName string
|
||||||
spk []byte
|
spk []byte
|
||||||
@@ -36,7 +45,7 @@ type Manager struct {
|
|||||||
preSharedKey *[32]byte
|
preSharedKey *[32]byte
|
||||||
rpPeerIDs map[string]*rp.PeerID
|
rpPeerIDs map[string]*rp.PeerID
|
||||||
rpWgHandler *NetbirdHandler
|
rpWgHandler *NetbirdHandler
|
||||||
server *rp.Server
|
server rpServer
|
||||||
lock sync.Mutex
|
lock sync.Mutex
|
||||||
port int
|
port int
|
||||||
wgIface PresharedKeySetter
|
wgIface PresharedKeySetter
|
||||||
@@ -51,7 +60,22 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
|
|||||||
|
|
||||||
rpKeyHash := hashRosenpassKey(public)
|
rpKeyHash := hashRosenpassKey(public)
|
||||||
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
|
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
|
||||||
return &Manager{ifaceName: wgIfaceName, rpKeyHash: rpKeyHash, spk: public, ssk: secret, preSharedKey: (*[32]byte)(preSharedKey), rpPeerIDs: make(map[string]*rp.PeerID), lock: sync.Mutex{}}, nil
|
return &Manager{
|
||||||
|
ifaceName: wgIfaceName,
|
||||||
|
rpKeyHash: rpKeyHash,
|
||||||
|
spk: public,
|
||||||
|
ssk: secret,
|
||||||
|
preSharedKey: (*[32]byte)(preSharedKey),
|
||||||
|
rpPeerIDs: make(map[string]*rp.PeerID),
|
||||||
|
// rpWgHandler is created here (instead of only in generateConfig) so it
|
||||||
|
// is never nil between NewManager and Run(). Otherwise an early
|
||||||
|
// OnConnected call (race observed on Android, issue #4341) panics on
|
||||||
|
// nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
|
||||||
|
// replace it with a fresh handler on each Run() to clear stale peer
|
||||||
|
// state from previous engine sessions.
|
||||||
|
rpWgHandler: NewNetbirdHandler(),
|
||||||
|
lock: sync.Mutex{},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetPubKey() []byte {
|
func (m *Manager) GetPubKey() []byte {
|
||||||
@@ -65,6 +89,16 @@ func (m *Manager) GetAddress() *net.UDPAddr {
|
|||||||
|
|
||||||
// addPeer adds a new peer to the Rosenpass server
|
// addPeer adds a new peer to the Rosenpass server
|
||||||
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
|
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
|
||||||
|
// Defense in depth against issue #4341 (Android crash): if Run() has not
|
||||||
|
// completed yet, m.server / m.rpWgHandler may be nil. Return an explicit
|
||||||
|
// error instead of panicking on nil-receiver dereference.
|
||||||
|
if m.server == nil {
|
||||||
|
return fmt.Errorf("rosenpass server not initialized")
|
||||||
|
}
|
||||||
|
if m.rpWgHandler == nil {
|
||||||
|
return fmt.Errorf("rosenpass wg handler not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
pcfg := rp.PeerConfig{PublicKey: rosenpassPubKey}
|
pcfg := rp.PeerConfig{PublicKey: rosenpassPubKey}
|
||||||
if m.preSharedKey != nil {
|
if m.preSharedKey != nil {
|
||||||
@@ -79,6 +113,16 @@ func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuar
|
|||||||
if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil {
|
if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil {
|
||||||
return fmt.Errorf("failed to resolve peer endpoint address: %w", err)
|
return fmt.Errorf("failed to resolve peer endpoint address: %w", err)
|
||||||
}
|
}
|
||||||
|
// Our local Rosenpass UDP server binds on the IPv6 wildcard ([::]) — see
|
||||||
|
// GetAddress(). The remote peer's endpoint (pcfg.Endpoint) is the destination
|
||||||
|
// our server will sendto when initiating handshakes. ResolveUDPAddr returns a
|
||||||
|
// 4-byte IPv4 for IPv4 hosts, which the kernel rejects (EDESTADDRREQ) when
|
||||||
|
// sent from an AF_INET6 socket. Normalize the remote endpoint to IPv4-mapped
|
||||||
|
// IPv6 so its address family matches our listening socket.
|
||||||
|
// TODO: maybe bind the Rosenpass UDP server to the peer wg IP addr
|
||||||
|
if v4 := pcfg.Endpoint.IP.To4(); v4 != nil {
|
||||||
|
pcfg.Endpoint.IP = v4.To16()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
peerID, err := m.server.AddPeer(pcfg)
|
peerID, err := m.server.AddPeer(pcfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,24 +226,31 @@ func (m *Manager) Run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.server, err = rp.NewUDPServer(conf)
|
server, err := rp.NewUDPServer(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.lock.Lock()
|
||||||
|
m.server = server
|
||||||
|
m.lock.Unlock()
|
||||||
|
|
||||||
log.Infof("starting rosenpass server on port %d", m.port)
|
log.Infof("starting rosenpass server on port %d", m.port)
|
||||||
|
|
||||||
return m.server.Run()
|
return server.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the Rosenpass server
|
// Close closes the Rosenpass server
|
||||||
func (m *Manager) Close() error {
|
func (m *Manager) Close() error {
|
||||||
if m.server != nil {
|
m.lock.Lock()
|
||||||
err := m.server.Close()
|
server := m.server
|
||||||
if err != nil {
|
m.server = nil
|
||||||
log.Errorf("failed closing local rosenpass server")
|
m.lock.Unlock()
|
||||||
}
|
if server == nil {
|
||||||
m.server = nil
|
return nil
|
||||||
|
}
|
||||||
|
if err := server.Close(); err != nil {
|
||||||
|
log.Errorf("failed closing local rosenpass server: %v", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,412 @@
|
|||||||
package rosenpass
|
package rosenpass
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
rp "cunicu.li/go-rosenpass"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- test doubles -----------------------------------------------------------
|
||||||
|
|
||||||
|
type addPeerCall struct {
|
||||||
|
cfg rp.PeerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type removePeerCall struct {
|
||||||
|
id rp.PeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
addCalls []addPeerCall
|
||||||
|
removed []removePeerCall
|
||||||
|
nextID rp.PeerID
|
||||||
|
addErr error
|
||||||
|
removeErr error
|
||||||
|
closed bool
|
||||||
|
ran bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.addCalls = append(m.addCalls, addPeerCall{cfg: cfg})
|
||||||
|
if m.addErr != nil {
|
||||||
|
return rp.PeerID{}, m.addErr
|
||||||
|
}
|
||||||
|
// Increment a byte in nextID so distinct peers get distinct IDs.
|
||||||
|
m.nextID[0]++
|
||||||
|
return m.nextID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockServer) RemovePeer(id rp.PeerID) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.removed = append(m.removed, removePeerCall{id: id})
|
||||||
|
return m.removeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockServer) Run() error { m.ran = true; return nil }
|
||||||
|
func (m *mockServer) Close() error { m.closed = true; return nil }
|
||||||
|
|
||||||
|
type setPSKCall struct {
|
||||||
|
peerKey string
|
||||||
|
psk wgtypes.Key
|
||||||
|
updateOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockIface struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []setPSKCall
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.calls = append(m.calls, setPSKCall{peerKey: peerKey, psk: psk, updateOnly: updateOnly})
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestManager builds a Manager with deterministic spk so tie-break
|
||||||
|
// against a peer pubkey is controllable from tests. The provided spk byte
|
||||||
|
// becomes the first byte; remaining bytes are zero.
|
||||||
|
func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
|
||||||
|
spk := make([]byte, 32)
|
||||||
|
spk[0] = spkFirstByte
|
||||||
|
return &Manager{
|
||||||
|
ifaceName: "wt0",
|
||||||
|
spk: spk,
|
||||||
|
ssk: make([]byte, 32),
|
||||||
|
rpKeyHash: "test-hash",
|
||||||
|
rpPeerIDs: make(map[string]*rp.PeerID),
|
||||||
|
rpWgHandler: NewNetbirdHandler(),
|
||||||
|
server: mock,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validWGKey returns a deterministic 32-byte wireguard public key (base64).
|
||||||
|
func validWGKey(t *testing.T, lastByte byte) string {
|
||||||
|
t.Helper()
|
||||||
|
var k wgtypes.Key
|
||||||
|
k[31] = lastByte
|
||||||
|
return k.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pure helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHashRosenpassKey_Deterministic(t *testing.T) {
|
||||||
|
key := []byte("hello-rosenpass")
|
||||||
|
require.Equal(t, hashRosenpassKey(key), hashRosenpassKey(key))
|
||||||
|
require.Len(t, hashRosenpassKey(key), 64) // sha256 hex
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashRosenpassKey_DifferentInputsDifferOutputs(t *testing.T) {
|
||||||
|
require.NotEqual(t, hashRosenpassKey([]byte("a")), hashRosenpassKey([]byte("b")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLogLevel_DefaultWhenUnset(t *testing.T) {
|
||||||
|
// Snapshot + unset to exercise the LookupEnv ok=false branch. t.Setenv
|
||||||
|
// can only set, not delete, so do it manually with restore via t.Cleanup.
|
||||||
|
prev, hadPrev := os.LookupEnv(defaultLogLevelVar)
|
||||||
|
require.NoError(t, os.Unsetenv(defaultLogLevelVar))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if hadPrev {
|
||||||
|
_ = os.Setenv(defaultLogLevelVar, prev)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv(defaultLogLevelVar)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
require.Equal(t, defaultLog.String(), getLogLevel().String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLogLevel_Cases(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"debug": "DEBUG",
|
||||||
|
"info": "INFO",
|
||||||
|
"warn": "WARN",
|
||||||
|
"error": "ERROR",
|
||||||
|
"unknown": "INFO", // default fallback
|
||||||
|
}
|
||||||
|
for input, wantStr := range cases {
|
||||||
|
input, wantStr := input, wantStr
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
t.Setenv(defaultLogLevelVar, input)
|
||||||
|
require.Equal(t, wantStr, getLogLevel().String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFindRandomAvailableUDPPort(t *testing.T) {
|
func TestFindRandomAvailableUDPPort(t *testing.T) {
|
||||||
port, err := findRandomAvailableUDPPort()
|
port, err := findRandomAvailableUDPPort()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Greater(t, port, 0)
|
require.Greater(t, port, 0)
|
||||||
require.LessOrEqual(t, port, 65535)
|
require.LessOrEqual(t, port, 65535)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- addPeer ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestAddPeer_HigherLocalPubkey_SetsEndpoint(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv) // local spk lexicographically larger
|
||||||
|
|
||||||
|
remotePubKey := make([]byte, 32) // remote spk = all zeros (smaller)
|
||||||
|
err := m.addPeer(remotePubKey, "rosenpass-host:7000", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, srv.addCalls, 1)
|
||||||
|
|
||||||
|
ep := srv.addCalls[0].cfg.Endpoint
|
||||||
|
require.NotNil(t, ep, "initiator side must set Endpoint")
|
||||||
|
require.Equal(t, 7000, ep.Port)
|
||||||
|
require.Equal(t, "100.1.1.1", ep.IP.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_HigherLocalPubkey_EndpointIPIsIPv4Mapped(t *testing.T) {
|
||||||
|
// Regression guard for the EDESTADDRREQ fix: Endpoint.IP must be 16-byte
|
||||||
|
// (IPv4-mapped IPv6) so it matches the AF_INET6 listening socket family.
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ep := srv.addCalls[0].cfg.Endpoint
|
||||||
|
require.NotNil(t, ep)
|
||||||
|
require.Len(t, ep.IP, 16, "IPv4 endpoint must be normalized to 16-byte v4-mapped form")
|
||||||
|
require.True(t, ep.IP.To4() != nil, "Endpoint must still be detected as IPv4")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_LowerLocalPubkey_LeavesEndpointNil(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0x00, srv) // local spk smaller
|
||||||
|
|
||||||
|
remotePubKey := make([]byte, 32)
|
||||||
|
remotePubKey[0] = 0xFF
|
||||||
|
err := m.addPeer(remotePubKey, "rp:5000", "100.1.1.1", validWGKey(t, 2))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Nil(t, srv.addCalls[0].cfg.Endpoint, "responder side must NOT set Endpoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_PresharedKeyPropagated(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
psk := &wgtypes.Key{0x42}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
m.preSharedKey = (*[32]byte)(psk)
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 3))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, [32]byte(*psk), [32]byte(srv.addCalls[0].cfg.PresharedKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_InvalidRosenpassAddr_ReturnsError(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv) // initiator path → parses rosenpassAddr
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "not-a-host-port", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, srv.addCalls, "server.AddPeer must not run when address parse fails")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_InvalidWireGuardPubKey_ReturnsError(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", "not-a-valid-key")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_ServerError_Propagates(t *testing.T) {
|
||||||
|
srv := &mockServer{addErr: errors.New("boom")}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for issue #4341 (Android crash). If Run() has not completed
|
||||||
|
// before OnConnected fires, m.rpWgHandler or m.server may be nil. Without the
|
||||||
|
// nil guards, m.rpWgHandler.AddPeer panics on nil receiver.
|
||||||
|
func TestAddPeer_NilHandler_ReturnsErrorNoCrash(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
m.rpWgHandler = nil // simulate Run() not yet completed
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "wg handler not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
|
||||||
|
m := newTestManager(0xFF, nil)
|
||||||
|
m.server = nil // simulate Run() not yet completed
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "server not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager must pre-initialize rpWgHandler so the nil-receiver crash from
|
||||||
|
// issue #4341 cannot occur in the window between NewManager and Run().
|
||||||
|
func TestNewManager_PreInitializesHandler(t *testing.T) {
|
||||||
|
psk := wgtypes.Key{}
|
||||||
|
m, err := NewManager(&psk, "wt0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_RecordsPeerID(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
wgKey := validWGKey(t, 5)
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, m.rpPeerIDs, wgKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OnConnected / OnDisconnected ------------------------------------------
|
||||||
|
|
||||||
|
func TestOnConnected_NilRemotePubKey_NoAddPeer(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
m.OnConnected(validWGKey(t, 1), nil, "100.1.1.1", "rp:5000")
|
||||||
|
require.Empty(t, srv.addCalls, "nil remote rosenpass pubkey must skip AddPeer")
|
||||||
|
require.Empty(t, m.rpPeerIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnConnected_ValidPubKey_CallsAddPeer(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
wgKey := validWGKey(t, 1)
|
||||||
|
m.OnConnected(wgKey, make([]byte, 32), "100.1.1.1", "rp:5000")
|
||||||
|
require.Len(t, srv.addCalls, 1)
|
||||||
|
require.Contains(t, m.rpPeerIDs, wgKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnDisconnected_UnknownPeer_NoOp(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
m.OnDisconnected(validWGKey(t, 99))
|
||||||
|
require.Empty(t, srv.removed, "unknown peer key must not call RemovePeer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnDisconnected_KnownPeer_CallsRemoveAndForgets(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
wgKey := validWGKey(t, 1)
|
||||||
|
require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
|
||||||
|
require.Contains(t, m.rpPeerIDs, wgKey)
|
||||||
|
|
||||||
|
m.OnDisconnected(wgKey)
|
||||||
|
require.Len(t, srv.removed, 1)
|
||||||
|
require.NotContains(t, m.rpPeerIDs, wgKey, "peer must be forgotten after disconnect")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IsPresharedKeyInitialized ---------------------------------------------
|
||||||
|
|
||||||
|
func TestIsPresharedKeyInitialized_UnknownPeer_ReturnsFalse(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
require.False(t, m.IsPresharedKeyInitialized(validWGKey(t, 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
wgKey := validWGKey(t, 2)
|
||||||
|
require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
|
||||||
|
require.False(t, m.IsPresharedKeyInitialized(wgKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NetbirdHandler.outputKey ----------------------------------------------
|
||||||
|
|
||||||
|
func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
iface := &mockIface{}
|
||||||
|
h.SetInterface(iface)
|
||||||
|
|
||||||
|
pid := rp.PeerID{0x01}
|
||||||
|
wgKey := wgtypes.Key{0xAA}
|
||||||
|
h.AddPeer(pid, "wt0", rp.Key(wgKey))
|
||||||
|
|
||||||
|
psk := rp.Key{0xBB}
|
||||||
|
h.HandshakeCompleted(pid, psk)
|
||||||
|
|
||||||
|
require.Len(t, iface.calls, 1)
|
||||||
|
require.False(t, iface.calls[0].updateOnly, "first PSK rotation must use updateOnly=false")
|
||||||
|
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
iface := &mockIface{}
|
||||||
|
h.SetInterface(iface)
|
||||||
|
|
||||||
|
pid := rp.PeerID{0x02}
|
||||||
|
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xCC}))
|
||||||
|
|
||||||
|
h.HandshakeCompleted(pid, rp.Key{0x01}) // first
|
||||||
|
h.HandshakeCompleted(pid, rp.Key{0x02}) // second
|
||||||
|
|
||||||
|
require.Len(t, iface.calls, 2)
|
||||||
|
require.False(t, iface.calls[0].updateOnly)
|
||||||
|
require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
// no SetInterface — iface remains nil
|
||||||
|
pid := rp.PeerID{0x03}
|
||||||
|
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
|
||||||
|
|
||||||
|
// Must not panic.
|
||||||
|
h.HandshakeCompleted(pid, rp.Key{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
iface := &mockIface{}
|
||||||
|
h.SetInterface(iface)
|
||||||
|
|
||||||
|
h.HandshakeCompleted(rp.PeerID{0xFF}, rp.Key{})
|
||||||
|
require.Empty(t, iface.calls, "unknown peer id must not trigger SetPresharedKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
iface := &mockIface{}
|
||||||
|
h.SetInterface(iface)
|
||||||
|
|
||||||
|
pid := rp.PeerID{0x04}
|
||||||
|
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xDD}))
|
||||||
|
h.HandshakeCompleted(pid, rp.Key{0x01})
|
||||||
|
require.True(t, h.IsPeerInitialized(pid))
|
||||||
|
|
||||||
|
h.RemovePeer(pid)
|
||||||
|
require.False(t, h.IsPeerInitialized(pid), "RemovePeer must clear initialized flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
pid := rp.PeerID{0x05}
|
||||||
|
wgKey := wgtypes.Key{0xEE}
|
||||||
|
h.AddPeer(pid, "wt0", rp.Key(wgKey))
|
||||||
|
|
||||||
|
iface := &mockIface{}
|
||||||
|
h.SetInterface(iface) // set after AddPeer
|
||||||
|
|
||||||
|
h.HandshakeCompleted(pid, rp.Key{0x42})
|
||||||
|
require.Len(t, iface.calls, 1)
|
||||||
|
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
||||||
|
}
|
||||||
|
|||||||
42
client/internal/rosenpass/seed.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package rosenpass
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
|
||||||
|
// of peer public keys. Both peers, given the same key pair, produce the same
|
||||||
|
// output regardless of which side runs the function: the inputs are ordered
|
||||||
|
// lexicographically before concatenation.
|
||||||
|
//
|
||||||
|
// NetBird uses this value as the initial Rosenpass-side preshared key when no
|
||||||
|
// explicit account-level PSK is configured, so both peers converge on the same
|
||||||
|
// PSK before the first post-quantum handshake completes.
|
||||||
|
//
|
||||||
|
// The resulting key MUST NOT be treated as quantum-safe: it is deterministic
|
||||||
|
// from public keys and exists only to seed WireGuard until Rosenpass rotates
|
||||||
|
// in a real post-quantum PSK.
|
||||||
|
func DeterministicSeedKey(localKey, remoteKey string) (*wgtypes.Key, error) {
|
||||||
|
lk := []byte(localKey)
|
||||||
|
rk := []byte(remoteKey)
|
||||||
|
if len(lk) < 16 || len(rk) < 16 {
|
||||||
|
return nil, fmt.Errorf("rosenpass: peer keys must be at least 16 bytes (got local=%d, remote=%d)", len(lk), len(rk))
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyInput []byte
|
||||||
|
if localKey > remoteKey {
|
||||||
|
keyInput = append(keyInput, lk[:16]...)
|
||||||
|
keyInput = append(keyInput, rk[:16]...)
|
||||||
|
} else {
|
||||||
|
keyInput = append(keyInput, rk[:16]...)
|
||||||
|
keyInput = append(keyInput, lk[:16]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := wgtypes.NewKey(keyInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rosenpass: deterministic seed key: %w", err)
|
||||||
|
}
|
||||||
|
return &key, nil
|
||||||
|
}
|
||||||
44
client/internal/rosenpass/seed_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package rosenpass
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeterministicSeedKey_SameForBothSides(t *testing.T) {
|
||||||
|
// Peer A and peer B must derive the same PSK regardless of which side
|
||||||
|
// computes it: the function orders inputs internally.
|
||||||
|
a := strings.Repeat("a", 32)
|
||||||
|
b := strings.Repeat("b", 32)
|
||||||
|
|
||||||
|
keyAB, err := DeterministicSeedKey(a, b)
|
||||||
|
require.NoError(t, err)
|
||||||
|
keyBA, err := DeterministicSeedKey(b, a)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, keyAB.String(), keyBA.String(), "swapping arguments must yield identical key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeterministicSeedKey_ChangesWithKeys(t *testing.T) {
|
||||||
|
a := strings.Repeat("a", 32)
|
||||||
|
b := strings.Repeat("b", 32)
|
||||||
|
c := strings.Repeat("c", 32)
|
||||||
|
|
||||||
|
keyAB, err := DeterministicSeedKey(a, b)
|
||||||
|
require.NoError(t, err)
|
||||||
|
keyAC, err := DeterministicSeedKey(a, c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, keyAB.String(), keyAC.String(), "different peer pair must yield different key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeterministicSeedKey_TooShortKey_ReturnsError(t *testing.T) {
|
||||||
|
short := "short" // < 16 bytes
|
||||||
|
long := strings.Repeat("x", 32)
|
||||||
|
|
||||||
|
_, err := DeterministicSeedKey(short, long)
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = DeterministicSeedKey(long, short)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
191
client/internal/routemanager/exit_node_selection_test.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package routemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||||
|
"github.com/netbirdio/netbird/route"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newExitNodeTestManager() *DefaultManager {
|
||||||
|
return &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitRoute(netID, peer string, skipAutoApply bool) *route.Route {
|
||||||
|
return &route.Route{
|
||||||
|
NetID: route.NetID(netID),
|
||||||
|
Network: netip.MustParsePrefix("0.0.0.0/0"),
|
||||||
|
Peer: peer,
|
||||||
|
SkipAutoApply: skipAutoApply,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickPreferredExitNode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
info exitNodeInfo
|
||||||
|
want route.NetID
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "persisted user selection wins over management",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"a", "b", "c"},
|
||||||
|
userSelected: []route.NetID{"b"},
|
||||||
|
selectedByManagement: []route.NetID{"a"},
|
||||||
|
},
|
||||||
|
want: "b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple user-selected self-heal to deterministic min",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"a", "b", "c"},
|
||||||
|
userSelected: []route.NetID{"c", "a"},
|
||||||
|
},
|
||||||
|
want: "a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit opt-out keeps none",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"a", "b"},
|
||||||
|
userDeselected: []route.NetID{"a", "b"},
|
||||||
|
},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fresh defaults to management auto-apply pick",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"a", "b", "c"},
|
||||||
|
selectedByManagement: []route.NetID{"b"},
|
||||||
|
},
|
||||||
|
want: "b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no user pick and no management auto-apply selects none",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"c", "a", "b"},
|
||||||
|
},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user-deselect does not block a management auto-apply sibling",
|
||||||
|
info: exitNodeInfo{
|
||||||
|
allIDs: []route.NetID{"a", "b"},
|
||||||
|
userDeselected: []route.NetID{"a"},
|
||||||
|
selectedByManagement: []route.NetID{"b"},
|
||||||
|
},
|
||||||
|
want: "b",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, pickPreferredExitNode(tt.info), "preferred exit node")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnforceSingleExitNode(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
all := []route.NetID{"a", "b", "c"}
|
||||||
|
|
||||||
|
m.enforceSingleExitNode("b", all)
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("a"), "a should be deselected")
|
||||||
|
assert.True(t, m.routeSelector.IsSelected("b"), "b should be the only selected exit node")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("c"), "c should be deselected")
|
||||||
|
|
||||||
|
// Switching the preferred node moves the single selection.
|
||||||
|
m.enforceSingleExitNode("c", all)
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("a"), "a stays deselected")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("b"), "b should now be deselected")
|
||||||
|
assert.True(t, m.routeSelector.IsSelected("c"), "c should now be selected")
|
||||||
|
|
||||||
|
// Empty preferred turns every exit node off.
|
||||||
|
m.enforceSingleExitNode("", all)
|
||||||
|
for _, id := range all {
|
||||||
|
assert.False(t, m.routeSelector.IsSelected(id), "no exit node should be selected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnforceSingleExitNode_RespectsDeselectAll(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
m.routeSelector.DeselectAllRoutes()
|
||||||
|
|
||||||
|
m.enforceSingleExitNode("b", []route.NetID{"a", "b"})
|
||||||
|
|
||||||
|
assert.True(t, m.routeSelector.IsDeselectAllActive(), "global deselect-all must stay in effect")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("b"), "no exit node should be forced on while deselect-all is set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRouteSelectorFromManagement_FreshSelectsOne(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
routes := route.HAMap{
|
||||||
|
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
||||||
|
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
||||||
|
"lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
|
||||||
|
"exitC|0.0.0.0/0": {exitRoute("exitC", "p4", false)},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
// Exactly one exit node (the deterministic first) is selected.
|
||||||
|
assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA is the deterministic default")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB must not also be selected")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitC"), "exitC must not also be selected")
|
||||||
|
// Non-exit routes are left at their default-on state.
|
||||||
|
assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRouteSelectorFromManagement_HonorsPersistedPick(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
routes := route.HAMap{
|
||||||
|
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
||||||
|
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
||||||
|
}
|
||||||
|
all := []route.NetID{"exitA", "exitB"}
|
||||||
|
|
||||||
|
// Simulate the state the runtime select path leaves behind: exactly one
|
||||||
|
// exit node explicitly selected, its sibling deselected.
|
||||||
|
require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exitB"}, true, all))
|
||||||
|
require.NoError(t, m.routeSelector.DeselectRoutes([]route.NetID{"exitA"}, all))
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
assert.True(t, m.routeSelector.IsSelected("exitB"), "persisted pick must stay selected")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitA"), "the other exit node stays deselected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRouteSelectorFromManagement_OptOutKeepsNone(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
routes := route.HAMap{
|
||||||
|
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
||||||
|
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
||||||
|
}
|
||||||
|
all := []route.NetID{"exitA", "exitB"}
|
||||||
|
|
||||||
|
// User deselected exit nodes and selected none.
|
||||||
|
require.NoError(t, m.routeSelector.DeselectRoutes(all, all))
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitA"), "opt-out keeps exitA off")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitB"), "opt-out keeps exitB off")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRouteSelectorFromManagement_NoAutoApplySelectsNone(t *testing.T) {
|
||||||
|
m := newExitNodeTestManager()
|
||||||
|
// SkipAutoApply=true: management offers the exit nodes but doesn't request
|
||||||
|
// auto-activation, so none should be selected until the user picks one.
|
||||||
|
routes := route.HAMap{
|
||||||
|
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)},
|
||||||
|
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitA"), "no auto-apply keeps exitA off")
|
||||||
|
assert.False(t, m.routeSelector.IsSelected("exitB"), "no auto-apply keeps exitB off")
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -439,6 +440,11 @@ func (m *DefaultManager) UpdateRoutes(
|
|||||||
|
|
||||||
m.updateClientNetworks(updateSerial, filteredClientRoutes)
|
m.updateClientNetworks(updateSerial, filteredClientRoutes)
|
||||||
m.notifier.OnNewRoutes(filteredClientRoutes)
|
m.notifier.OnNewRoutes(filteredClientRoutes)
|
||||||
|
// A new network map can add or drop route/exit-node candidates without
|
||||||
|
// touching any peer's chosen-route state, so the peer status alone
|
||||||
|
// wouldn't notify SubscribeStatus subscribers. Bump the revision so the
|
||||||
|
// UI re-fetches ListNetworks.
|
||||||
|
m.statusRecorder.BumpNetworksRevision()
|
||||||
}
|
}
|
||||||
m.clientRoutes = clientRoutes
|
m.clientRoutes = clientRoutes
|
||||||
|
|
||||||
@@ -579,6 +585,10 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) {
|
|||||||
if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil {
|
if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil {
|
||||||
log.Errorf("failed to update state: %v", err)
|
log.Errorf("failed to update state: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A selection change flips Network.selected without altering the candidate
|
||||||
|
// set, so bump the revision to push the new state to the UI.
|
||||||
|
m.statusRecorder.BumpNetworksRevision()
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopObsoleteClients stops the client network watcher for the networks that are not in the new list
|
// stopObsoleteClients stops the client network watcher for the networks that are not in the new list
|
||||||
@@ -698,15 +708,22 @@ func resolveURLsToIPs(urls []string) []net.IP {
|
|||||||
return ips
|
return ips
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
|
// updateRouteSelectorFromManagement reconciles exit-node selection on every
|
||||||
|
// network map: it keeps at most one exit node selected — the user's persisted
|
||||||
|
// pick, else whatever management marks for auto-apply (SkipAutoApply=false),
|
||||||
|
// else none. We never auto-activate an exit node the map doesn't request; it
|
||||||
|
// stays off until the user picks it. Exit nodes are mutually exclusive, but the
|
||||||
|
// RouteSelector stores routes with default-on semantics, so without this every
|
||||||
|
// available exit node would report selected at once.
|
||||||
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
|
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
|
||||||
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
|
info := m.collectExitNodeInfo(clientRoutes)
|
||||||
if len(exitNodeInfo.allIDs) == 0 {
|
if len(info.allIDs) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.updateExitNodeSelections(exitNodeInfo)
|
preferred := pickPreferredExitNode(info)
|
||||||
m.logExitNodeUpdate(exitNodeInfo)
|
m.enforceSingleExitNode(preferred, info.allIDs)
|
||||||
|
m.logExitNodeUpdate(info, preferred)
|
||||||
}
|
}
|
||||||
|
|
||||||
type exitNodeInfo struct {
|
type exitNodeInfo struct {
|
||||||
@@ -716,6 +733,10 @@ type exitNodeInfo struct {
|
|||||||
userDeselected []route.NetID
|
userDeselected []route.NetID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collectExitNodeInfo categorises the available exit nodes by their persisted
|
||||||
|
// selection state. It keys on the base (v4) NetID and skips the synthesized
|
||||||
|
// "-v6" partner, which inherits its base's selection through the RouteSelector
|
||||||
|
// — counting it separately would double-count the pair.
|
||||||
func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeInfo {
|
func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeInfo {
|
||||||
var info exitNodeInfo
|
var info exitNodeInfo
|
||||||
|
|
||||||
@@ -725,6 +746,9 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI
|
|||||||
}
|
}
|
||||||
|
|
||||||
netID := haID.NetID()
|
netID := haID.NetID()
|
||||||
|
if strings.HasSuffix(string(netID), route.V6ExitSuffix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
info.allIDs = append(info.allIDs, netID)
|
info.allIDs = append(info.allIDs, netID)
|
||||||
|
|
||||||
if m.routeSelector.HasUserSelectionForRoute(netID) {
|
if m.routeSelector.HasUserSelectionForRoute(netID) {
|
||||||
@@ -761,45 +785,69 @@ func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) {
|
// pickPreferredExitNode chooses the single exit node to keep selected. In order:
|
||||||
routesToDeselect := m.getRoutesToDeselect(info.allIDs)
|
// - a persisted user selection wins (deterministic if several survive from
|
||||||
m.deselectExitNodes(routesToDeselect)
|
// legacy state, so the set self-heals down to one);
|
||||||
m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs)
|
// - otherwise activate only what management marks for auto-apply
|
||||||
|
// (SkipAutoApply=false); the lexicographically first if it marks several.
|
||||||
|
//
|
||||||
|
// Returns "" when neither holds — we never force an arbitrary exit node on. A
|
||||||
|
// route the map doesn't auto-apply stays off until the user selects it.
|
||||||
|
// info.userDeselected is informational only: an explicit deselect simply keeps
|
||||||
|
// that route out of both lists above, so it can't be picked.
|
||||||
|
func pickPreferredExitNode(info exitNodeInfo) route.NetID {
|
||||||
|
if len(info.userSelected) > 0 {
|
||||||
|
return minNetID(info.userSelected)
|
||||||
|
}
|
||||||
|
if len(info.selectedByManagement) > 0 {
|
||||||
|
return minNetID(info.selectedByManagement)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *DefaultManager) getRoutesToDeselect(allIDs []route.NetID) []route.NetID {
|
// enforceSingleExitNode makes preferred the only selected exit node: every other
|
||||||
var routesToDeselect []route.NetID
|
// available exit node is deselected and preferred (if any) is selected, without
|
||||||
for _, netID := range allIDs {
|
// disturbing non-exit route selections. A global deselect-all is left untouched
|
||||||
if !m.routeSelector.HasUserSelectionForRoute(netID) {
|
// so the user's "all off" stays in effect.
|
||||||
routesToDeselect = append(routesToDeselect, netID)
|
func (m *DefaultManager) enforceSingleExitNode(preferred route.NetID, allIDs []route.NetID) {
|
||||||
|
if m.routeSelector.IsDeselectAllActive() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
others := make([]route.NetID, 0, len(allIDs))
|
||||||
|
for _, id := range allIDs {
|
||||||
|
if id != preferred {
|
||||||
|
others = append(others, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return routesToDeselect
|
if len(others) > 0 {
|
||||||
}
|
if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil {
|
||||||
|
log.Warnf("deselect other exit nodes: %v", err)
|
||||||
func (m *DefaultManager) deselectExitNodes(routesToDeselect []route.NetID) {
|
}
|
||||||
if len(routesToDeselect) == 0 {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if preferred != "" {
|
||||||
err := m.routeSelector.DeselectRoutes(routesToDeselect, routesToDeselect)
|
if err := m.routeSelector.SelectRoutes([]route.NetID{preferred}, true, allIDs); err != nil {
|
||||||
if err != nil {
|
log.Warnf("select preferred exit node %q: %v", preferred, err)
|
||||||
log.Warnf("Failed to deselect exit nodes: %v", err)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *DefaultManager) selectExitNodesByManagement(selectedByManagement []route.NetID, allIDs []route.NetID) {
|
func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.NetID) {
|
||||||
if len(selectedByManagement) == 0 {
|
log.Debugf("Exit node selection: %d available, preferred=%q (%d user-selected, %d user-deselected, %d management-selected)",
|
||||||
return
|
len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement))
|
||||||
}
|
|
||||||
|
|
||||||
err := m.routeSelector.SelectRoutes(selectedByManagement, true, allIDs)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("Failed to select exit nodes: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo) {
|
// minNetID returns the lexicographically smallest NetID, for a deterministic
|
||||||
log.Debugf("Updated route selector: %d exit nodes available, %d selected by management, %d user-selected, %d user-deselected",
|
// default pick that stays stable across restarts.
|
||||||
len(info.allIDs), len(info.selectedByManagement), len(info.userSelected), len(info.userDeselected))
|
func minNetID(ids []route.NetID) route.NetID {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
best := ids[0]
|
||||||
|
for _, id := range ids[1:] {
|
||||||
|
if id < best {
|
||||||
|
best = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
|
|||||||
return rs.isSelectedLocked(routeID)
|
return rs.isSelectedLocked(routeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsDeselectAllActive reports whether the global "deselect all" flag is set,
|
||||||
|
// i.e. the user disabled every route. Callers enforcing per-route invariants
|
||||||
|
// (e.g. single exit node) should leave the selection untouched when it is.
|
||||||
|
func (rs *RouteSelector) IsDeselectAllActive() bool {
|
||||||
|
rs.mu.RLock()
|
||||||
|
defer rs.mu.RUnlock()
|
||||||
|
|
||||||
|
return rs.deselectAll
|
||||||
|
}
|
||||||
|
|
||||||
// FilterSelected removes unselected routes from the provided map.
|
// FilterSelected removes unselected routes from the provided map.
|
||||||
func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
|
func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
|
||||||
rs.mu.RLock()
|
rs.mu.RLock()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -96,17 +96,19 @@ func (m *Manager) Stop(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
cancel := m.cancel
|
||||||
|
done := m.done
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
if m.cancel == nil {
|
if cancel == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.cancel()
|
cancel()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-m.done:
|
case <-done:
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return 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>
|
||||||
</StandardDirectory>
|
</StandardDirectory>
|
||||||
@@ -85,10 +90,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) {}
|
||||||
|
|
||||||
@@ -227,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 {}
|
||||||
@@ -244,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 {}
|
||||||
@@ -408,6 +443,12 @@ message FullStatus {
|
|||||||
|
|
||||||
bool lazyConnectionEnabled = 9;
|
bool lazyConnectionEnabled = 9;
|
||||||
SSHServerState sshServerState = 10;
|
SSHServerState sshServerState = 10;
|
||||||
|
|
||||||
|
// networksRevision bumps whenever the set of routed networks (route and
|
||||||
|
// exit-node candidates) or their selected state changes. The UI fingerprints
|
||||||
|
// on it to know when to re-fetch ListNetworks via the push stream, instead
|
||||||
|
// of polling on every status snapshot.
|
||||||
|
uint64 networksRevision = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Networks
|
// Networks
|
||||||
@@ -798,6 +839,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 {}
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,17 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
|
|||||||
if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
|
if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
|
||||||
return nil, fmt.Errorf("select routes: %w", err)
|
return nil, fmt.Errorf("select routes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exit nodes are mutually exclusive: if this selection activates an
|
||||||
|
// exit node, deselect every other available exit node so two can't be
|
||||||
|
// selected at once. Non-exit route selections are left untouched.
|
||||||
|
if requestActivatesExitNode(routes, routesMap) {
|
||||||
|
if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 {
|
||||||
|
if err := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil {
|
||||||
|
return nil, fmt.Errorf("deselect sibling exit nodes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
routeManager.TriggerSelection(routeManager.GetClientRoutes())
|
routeManager.TriggerSelection(routeManager.GetClientRoutes())
|
||||||
|
|
||||||
@@ -249,3 +260,38 @@ func toNetIDs(routes []string) []route.NetID {
|
|||||||
}
|
}
|
||||||
return netIDs
|
return netIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isExitNodeRoutes(routes []*route.Route) bool {
|
||||||
|
return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network))
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestActivatesExitNode reports whether any requested NetID maps to an exit
|
||||||
|
// node (default route) in the current route table.
|
||||||
|
func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool {
|
||||||
|
for _, id := range requested {
|
||||||
|
if isExitNodeRoutes(routesMap[id]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherExitNodeIDs returns every available exit-node NetID that is not in the
|
||||||
|
// requested set — the siblings to deselect so a single exit node stays active.
|
||||||
|
func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID {
|
||||||
|
keep := make(map[route.NetID]struct{}, len(requested))
|
||||||
|
for _, id := range requested {
|
||||||
|
keep[id] = struct{}{}
|
||||||
|
}
|
||||||
|
var others []route.NetID
|
||||||
|
for id, routes := range routesMap {
|
||||||
|
if !isExitNodeRoutes(routes) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := keep[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
others = append(others, id)
|
||||||
|
}
|
||||||
|
return others
|
||||||
|
}
|
||||||
|
|||||||
26
client/server/network_exitnode_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/route"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExitNodeSelectionHelpers(t *testing.T) {
|
||||||
|
routesMap := map[route.NetID][]*route.Route{
|
||||||
|
"exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}},
|
||||||
|
"exitB": {{Network: netip.MustParsePrefix("::/0")}},
|
||||||
|
"lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node")
|
||||||
|
assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node")
|
||||||
|
assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node")
|
||||||
|
assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node")
|
||||||
|
|
||||||
|
others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"})
|
||||||
|
assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -123,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)
|
||||||
@@ -140,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)
|
||||||
@@ -220,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 {
|
||||||
@@ -258,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
|
||||||
}
|
}
|
||||||
@@ -269,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
|
||||||
@@ -341,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 {
|
||||||
@@ -569,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 {
|
||||||
@@ -630,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
|
||||||
}
|
}
|
||||||
@@ -745,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1114,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() {
|
||||||
@@ -1127,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)
|
||||||
|
|
||||||
@@ -1136,6 +1242,7 @@ 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.NetworksRevision = s.statusRecorder.GetNetworksRevision()
|
||||||
statusResponse.FullStatus = pbFullStatus
|
statusResponse.FullStatus = pbFullStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1356,6 +1463,144 @@ 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preempt a previous WaitExtendAuthSession (e.g. when the tray
|
||||||
|
// notification and the about-to-expire dialog both start a flow on
|
||||||
|
// the same deadline). The older waiter exits via context.Canceled;
|
||||||
|
// the new one takes over the IdP poll.
|
||||||
|
s.extendAuthSessionFlow.CancelWait()
|
||||||
|
|
||||||
|
waitCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
s.extendAuthSessionFlow.SetWaitCancel(cancel)
|
||||||
|
|
||||||
|
tokenInfo, err := oAuthFlow.WaitToken(waitCtx, authInfo)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return nil, gstatus.Errorf(codes.Canceled, "extend-session flow preempted")
|
||||||
|
}
|
||||||
|
return nil, gstatus.Errorf(codes.Internal, "failed to obtain JWT token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear pending flow before talking to mgm so a retry can re-initiate.
|
||||||
|
s.extendAuthSessionFlow.Clear()
|
||||||
|
|
||||||
|
if connectClient == nil {
|
||||||
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
||||||
|
}
|
||||||
|
engine := connectClient.Engine()
|
||||||
|
if engine == nil {
|
||||||
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "engine is not initialised")
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse())
|
||||||
|
if err != nil {
|
||||||
|
return nil, gstatus.Errorf(codes.Internal, "management ExtendAuthSession failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &proto.WaitExtendAuthSessionResponse{}
|
||||||
|
if !deadline.IsZero() {
|
||||||
|
resp.SessionExpiresAt = timestamppb.New(deadline)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissSessionWarning forwards the user's "Dismiss" click on the
|
||||||
|
// T-WarningLead notification down to the engine's sessionWatcher so the
|
||||||
|
// T-FinalWarningLead fallback is suppressed for the current deadline.
|
||||||
|
// Best-effort: when the client/engine is not yet running the call is a
|
||||||
|
// successful no-op (the watcher has no deadline to dismiss anyway).
|
||||||
|
func (s *Server) DismissSessionWarning(
|
||||||
|
_ context.Context,
|
||||||
|
_ *proto.DismissSessionWarningRequest,
|
||||||
|
) (*proto.DismissSessionWarningResponse, error) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
connectClient := s.connectClient
|
||||||
|
s.mutex.Unlock()
|
||||||
|
if connectClient == nil {
|
||||||
|
return &proto.DismissSessionWarningResponse{}, nil
|
||||||
|
}
|
||||||
|
if engine := connectClient.Engine(); engine != nil {
|
||||||
|
engine.DismissSessionWarning()
|
||||||
|
}
|
||||||
|
return &proto.DismissSessionWarningResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ExposeService exposes a local port via the NetBird reverse proxy.
|
// 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()
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -153,6 +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"`
|
||||||
|
// 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.
|
||||||
@@ -198,6 +207,10 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
|||||||
ProfileName: opts.ProfileName,
|
ProfileName: opts.ProfileName,
|
||||||
SSHServerState: sshServerOverview,
|
SSHServerState: sshServerOverview,
|
||||||
}
|
}
|
||||||
|
if !opts.SessionExpiresAt.IsZero() {
|
||||||
|
t := opts.SessionExpiresAt
|
||||||
|
overview.SessionExpiresAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
if opts.Anonymize {
|
if opts.Anonymize {
|
||||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||||
@@ -535,6 +548,15 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
|||||||
|
|
||||||
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)
|
||||||
@@ -565,6 +587,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
|||||||
"SSH Server: %s\n"+
|
"SSH 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,
|
||||||
@@ -583,6 +606,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
|||||||
sshServerStatus,
|
sshServerStatus,
|
||||||
networks,
|
networks,
|
||||||
forwardingRulesString,
|
forwardingRulesString,
|
||||||
|
sessionExpiryString,
|
||||||
peersCountString,
|
peersCountString,
|
||||||
)
|
)
|
||||||
return summary
|
return summary
|
||||||
@@ -996,3 +1020,57 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
|||||||
overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command)
|
overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatRemainingDuration renders a time.Duration for the "Session expires"
|
||||||
|
// line. Examples: "2h 15m", "47m 12s", "8s", "expired 3m ago".
|
||||||
|
//
|
||||||
|
// Granularity drops to seconds only under a minute, otherwise minutes are
|
||||||
|
// the smallest unit shown — sub-minute precision is noise for a deadline
|
||||||
|
// that's hours or days out.
|
||||||
|
func FormatRemainingDuration(d time.Duration) string {
|
||||||
|
if d <= 0 {
|
||||||
|
return "expired " + HumaniseDuration(-d) + " ago"
|
||||||
|
}
|
||||||
|
return HumaniseDuration(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HumaniseDuration renders a positive duration in compact form (e.g.
|
||||||
|
// "2h 15m", "47m", "8s"). Exposed alongside FormatRemainingDuration so
|
||||||
|
// callers that don't need the "expired … ago" wording can format
|
||||||
|
// positive durations directly.
|
||||||
|
func HumaniseDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
s := int(d.Round(time.Second).Seconds())
|
||||||
|
if s < 1 {
|
||||||
|
s = 1
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%ds", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
day = 24 * time.Hour
|
||||||
|
hour = time.Hour
|
||||||
|
minute = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
days := int64(d / day)
|
||||||
|
d -= time.Duration(days) * day
|
||||||
|
hours := int64(d / hour)
|
||||||
|
d -= time.Duration(hours) * hour
|
||||||
|
minutes := int64(d / minute)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case days > 0:
|
||||||
|
if hours == 0 {
|
||||||
|
return fmt.Sprintf("%dd", days)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dd %dh", days, hours)
|
||||||
|
case hours > 0:
|
||||||
|
if minutes == 0 {
|
||||||
|
return fmt.Sprintf("%dh", hours)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dm", minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -641,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")
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
155
client/ui/CLAUDE.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# 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` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
|
||||||
|
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
|
||||||
|
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |
|
||||||
|
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists. Both broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. |
|
||||||
|
|
||||||
|
`DaemonConn` is defined in `services/conn.go`; `ptrStr` (string-to-*string helper for proto pointer fields) lives there too.
|
||||||
|
|
||||||
|
## Daemon proto
|
||||||
|
- Proto source: `../proto/daemon.proto`. Generated Go in `../proto/*.pb.go`.
|
||||||
|
- Regen: `cd ../proto && protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative daemon.proto`
|
||||||
|
- Pinned versions (see `daemon.pb.go` header): `protoc v7.34.1`, `protoc-gen-go v1.36.6`. CI's `proto-version-check` workflow fails on mismatch.
|
||||||
|
- After proto regen, also regen Wails bindings so the TS layer picks up new fields.
|
||||||
|
|
||||||
|
## Events bus
|
||||||
|
|
||||||
|
`main.go` registers five typed events for the frontend: `netbird:status` (`Status`), `netbird:event` (`SystemEvent`), `netbird:profile:changed` (`ProfileRef`), `netbird:update:available` (`UpdateAvailable`), `netbird:update:progress` (`UpdateProgress`). `netbird:profile:changed` fires from `ProfileSwitcher.SwitchActive` after a successful daemon-side switch — both the React `ProfileContext` and the tray subscribe so a flip driven from one surface paints in the others (the daemon itself does not emit a profile event). Plus three plain-string events:
|
||||||
|
|
||||||
|
- `EventTriggerLogin = "trigger-login"` — tray asking the frontend's `startLogin()` to begin an SSO flow. The tray does **not** show the main window when emitting — the hidden webview is alive and subscribed, so `startLogin` runs and the only visible surface is the BrowserLogin popup it opens.
|
||||||
|
- `EventBrowserLoginCancel = "browser-login:cancel"` — the `BrowserLogin` window's Cancel button or red-X close. `startLogin()` listens and tears down the daemon's pending `WaitSSOLogin`.
|
||||||
|
- `preferences.EventPreferencesChanged = "netbird:preferences:changed"` — emitted after every successful `SetLanguage` (payload `{language}`). Both the tray menu rebuild and the React `i18next.changeLanguage` subscribe so a flip from any window paints everywhere.
|
||||||
|
- `EventSettingsOpen = "netbird:settings:open"` (payload: tab string, e.g. `"general"` / `"profiles"`) — emitted by `WindowManager.OpenSettings(tab)` to set the active tab before Go calls `Show`/`Focus`. The matching reset-to-General on close lives in the React side via `document.visibilitychange` (Wails events from the Go close hook race `Hide` and flash the previous tab for one frame).
|
||||||
|
|
||||||
|
Daemon connection status strings (`services/peers.go`) mirror `internal.Status*` in `client/internal/state.go`: `Connected`, `Connecting`, `Idle`, `NeedsLogin`, `LoginFailed`, `SessionExpired`, plus the synthetic `DaemonUnavailable` emitted by `Peers` when the socket is unreachable.
|
||||||
|
|
||||||
|
## Profile switching
|
||||||
|
|
||||||
|
`services/profileswitcher.go` is the single source of truth for the reconnect policy. Both the tray (`tray.go switchProfile`) and the frontend (via `modules/profiles/ProfileContext.tsx`'s `switchProfile`, which `modules/profiles/ProfilesTab.tsx` and the header `ProfileDropdown` go through) call `ProfileSwitcher.SwitchActive`; identical inputs give identical state transitions.
|
||||||
|
|
||||||
|
Reconnect policy (driven by `prevStatus` from `Peers.Get`):
|
||||||
|
|
||||||
|
| Previous status | Action | Optimistic UI | Suppressed events until new flow begins |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle |
|
||||||
|
| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle |
|
||||||
|
| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — |
|
||||||
|
| Idle | Switch only | (no change) | — |
|
||||||
|
|
||||||
|
Only Connected/Connecting trigger `Peers.BeginProfileSwitch`. That:
|
||||||
|
1. Sets a 30s `switchInProgress` guard.
|
||||||
|
2. Emits a synthetic `Status{Status: StatusConnecting}` so both tray and React paint immediately.
|
||||||
|
3. Tells `statusStreamLoop` to drop the daemon's stale Connected updates (peer count drops as the engine tears down) and the transient Idle in between Down and the new Up.
|
||||||
|
|
||||||
|
`shouldSuppress` releases the guard as soon as a status that signals the new flow began arrives:
|
||||||
|
- **Suppressed**: Connected, Idle
|
||||||
|
- **Pass through and clear**: Connecting / NeedsLogin / LoginFailed / SessionExpired / DaemonUnavailable
|
||||||
|
- **Timeout fallback**: 30s elapsed → clear flag, emit normally.
|
||||||
|
|
||||||
|
`Peers.CancelProfileSwitch` aborts the suppression — called by `tray.go handleDisconnect` so the user's "Disconnect while Connecting" click paints through immediately.
|
||||||
|
|
||||||
|
Also: `ProfileSwitcher.SwitchActive` mirrors the daemon switch into the user-side `profilemanager` (`~/Library/Application Support/netbird/active_profile`). The CLI's `netbird up` reads this file and sends the resolved profile name back; if it diverges from the daemon's `/var/lib/netbird/active_profile.json`, the daemon silently flips back. Mirror failures don't abort the switch — surfaced as a warning.
|
||||||
|
|
||||||
|
## Auxiliary windows (`WindowManager`)
|
||||||
|
|
||||||
|
The main window is created up front in `main.go`. Auxiliary windows are created on demand by `services.WindowManager`:
|
||||||
|
|
||||||
|
- **Settings** (`/#/settings`) — opened from the header gear icon (`pages/main/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`ProfilesTab.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (opaque macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. **Unlike the other auxiliary windows**, Settings is created eagerly (hidden) inside `NewWindowManager` and hides on close instead of being destroyed — first open is instant. The window stays at a single URL (`/#/settings`) forever; `OpenSettings(tab)` does **not** call `SetURL`. Instead it emits `netbird:settings:open` with the target tab (empty → `"general"`), then calls `Show`/`Focus`. `SettingsPage` keeps the active tab in React local state and listens for the event to switch. **Reset-on-close lives in the React side**, not the Go close hook: `SettingsPage` listens for `document.visibilitychange` and resets the tab to General when the page goes hidden. Doing it via `Event.Emit` from the close hook didn't work — the dispatch goroutine races `Hide`, the JS listener often runs only after the *next* `Show`, and the user sees a one-frame flash of the previous tab. The Page Visibility API fires before WebKit throttles the page, so the state update lands while we're still in foreground JS. (The earlier `SetURL` path re-loaded the WKWebView entirely, re-mounting the `AppLayout` provider stack and visibly flashing the `SettingsSkeleton` while `SettingsContext` re-fetched config.)
|
||||||
|
- **BrowserLogin** (`/#/dialog/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`pages/main/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes.
|
||||||
|
- **SessionExpired** (`/#/dialog/session-expired`) and **SessionAboutToExpire** (`/#/dialog/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 no triggers wired — daemon-status integration is a follow-up.
|
||||||
|
- **InstallProgress** (`/#/dialog/install-progress?version=<v>`) — opened by `WindowManager.OpenInstallProgress(version)` from `ClientVersionContext` (force-install branch on `installing` flip, user-driven enforced branch from `triggerUpdate`). 360-wide auto-sized via `useAutoSizeWindow`, `AlwaysOnTop`. Owns its own polling loop against `Update.GetInstallerResult` with the 5-second daemon-down-grace (sustained gRPC failure = success → call `Update.Quit()`). Hides every other visible window on open (restored on close).
|
||||||
|
|
||||||
|
The four lazy auxiliary windows (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress) are **destroyed** on close (mutex-guarded singleton; `closing` hook nils the field). Destroying rather than hiding is deliberate — Wails' macOS dock-reopen handler resurrects hidden windows, which we don't want for transient surfaces. Settings is the exception: it's created hidden up-front and uses a `RegisterHook` close interceptor (`e.Cancel(); Hide()`) to keep the webview warm.
|
||||||
|
|
||||||
|
On macOS, `main.go` overrides Wails' default `applicationShouldHandleReopen` listener (which shows *every* hidden window — see `pkg/application/events_common_darwin.go`) by registering an application event hook that cancels the event and shows only the main window. Without this, clicking the dock icon would resurrect the hide-on-close Settings window alongside the main one.
|
||||||
|
|
||||||
|
The main window is **hidden** on close (the `WindowClosing` hook calls `e.Cancel(); window.Hide()`). The user reaches "really quit" through the tray → Quit menu entry.
|
||||||
|
|
||||||
|
## Localisation (i18n)
|
||||||
|
|
||||||
|
The locale tree under `client/ui/i18n/locales/` is the single source of truth for both Go (tray, OS notifications) and React (every user-facing string). It sits next to the Go `i18n` package (the tray's consumer) so a single JSON tree drives both surfaces. Layout: `_index.json` lists shipped languages (`code` / `displayName` / `englishName`); `<code>/common.json` per language. `en/common.json` must exist (the `Bundle` loader hard-fails without it); languages listed in `_index.json` without a bundle are skipped with a warning. Placeholders are single-braced (`"Install version {version}"`) — Go substitutes via `Bundle.Translate(lang, key, "name", value, ...)`; React uses i18next with `interpolation: { prefix: "{", suffix: "}" }`.
|
||||||
|
|
||||||
|
Adding a language: drop a `<code>/common.json` under `client/ui/i18n/locales/`, append a row to `_index.json`, rebuild. Go reads the tree via `//go:embed all:i18n/locales` in `client/ui/main.go`; Vite reads it via the `../../../i18n/locales/*/common.json` glob in `frontend/src/lib/i18n.ts`, with `server.fs.allow` in `vite.config.ts` whitelisting the parent dir so the dev server can serve files outside `frontend/`.
|
||||||
|
|
||||||
|
Package layout:
|
||||||
|
- `client/ui/i18n/` — pure `LanguageCode` / `Language` / `Bundle` loader. No Wails / no daemon. Reads the tree from an `fs.FS` passed in by `main.go`.
|
||||||
|
- `client/ui/preferences/` — `Store` persists `UIPreferences{language}` to `os.UserConfigDir()/netbird/ui-preferences.json` (per-OS-user, shared across daemon profiles). Validates against an injected `LanguageValidator` (`*i18n.Bundle`). No file → in-memory default `en`, persisted on first `SetLanguage`. Broadcasts via in-process pub/sub + optional Wails event emitter.
|
||||||
|
- `services/i18n.go` + `services/preferences.go` — Wails facades. Preferences emits `netbird:preferences:changed` (payload `{language}`) on every `SetLanguage`.
|
||||||
|
|
||||||
|
Key conventions: `tray.*` / `notify.*` (Go-side), `common.* / connect.* / nav.* / profile.* / settings.* / update.* / browserLogin.* / 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). The install-progress window owns its own error UI in-place (timeout/canceled/failed phases) — no native dialog needed there.
|
||||||
|
|
||||||
|
### OS notifications
|
||||||
|
|
||||||
|
The tray uses Wails' built-in `notifications` service. One `notifications.NotificationService` is created in `main.go` and passed into `TrayServices.Notifier`. Notification IDs are prefixed for coalescing: `netbird-update-<version>`, `netbird-event-<id>`, `netbird-tray-error`, `netbird-session-expired`. Notifications are gated by the user's "Notifications" toggle (cached in `Tray.notificationsEnabled`, seeded from `Settings.GetConfig` at boot). `Severity == "critical"` events bypass the gate, mirroring the legacy Fyne `event.Manager`.
|
||||||
|
|
||||||
|
### Profile switching invariants
|
||||||
|
|
||||||
|
`ProfileSwitcher.SwitchActive` is the only switch path on the TS side — `ProfileContext.switchProfile` is the single TS wrapper, and `modules/profiles/ProfilesTab.tsx` + the header `ProfileDropdown` both go through it. The Go side captures `prevStatus`, drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`, mirrors into the user-side `profilemanager`, and conditionally fires Down/Up per the reconnect-policy table above.
|
||||||
|
|
||||||
|
**Never call `Connection.Up` on an Idle/NeedsLogin daemon** — the daemon's internal 50s `waitForUp` blocks until `DeadlineExceeded`. `Connection.Up` from the frontend is reserved for the explicit Connect button (`ConnectionStatusSwitch.connect`) and the post-SSO resume inside `startLogin`; the gating for profile-switch reconnects lives Go-side in `ProfileSwitcher.SwitchActive`.
|
||||||
|
|
||||||
|
## Build / dev tasks
|
||||||
|
|
||||||
|
`task dev` (Wails dev, live reload), `task build` (prod build for the current OS, dispatches to `build/{darwin,linux,windows}/Taskfile.yml`), `task build:server` / `run:server` / `build:docker` / `run:docker` (server-mode variants in `build/Taskfile.yml`). **No** `task generate:bindings` alias — run `wails3 generate bindings -clean=true -ts` directly from this directory. CLI flags + log-target semantics are documented in the `main.go` bullet under "Layout".
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
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-16.png
Normal file
|
After Width: | Height: | Size: 526 B |
BIN
client/ui/assets/netbird-menu-24.png
Normal file
|
After Width: | Height: | Size: 739 B |
BIN
client/ui/assets/netbird-menu-about-18.png
Normal file
|
After Width: | Height: | Size: 838 B |
BIN
client/ui/assets/netbird-menu-dot-connected-16.png
Normal file
|
After Width: | Height: | Size: 508 B |
BIN
client/ui/assets/netbird-menu-dot-connected-22.png
Normal file
|
After Width: | Height: | Size: 615 B |
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-16.png
Normal file
|
After Width: | Height: | Size: 520 B |
BIN
client/ui/assets/netbird-menu-dot-connecting-22.png
Normal file
|
After Width: | Height: | Size: 637 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-16.png
Normal file
|
After Width: | Height: | Size: 532 B |
BIN
client/ui/assets/netbird-menu-dot-error-22.png
Normal file
|
After Width: | Height: | Size: 629 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-16.png
Normal file
|
After Width: | Height: | Size: 490 B |
BIN
client/ui/assets/netbird-menu-dot-idle-22.png
Normal file
|
After Width: | Height: | Size: 602 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-offline-16.png
Normal file
|
After Width: | Height: | Size: 512 B |
BIN
client/ui/assets/netbird-menu-dot-offline-22.png
Normal file
|
After Width: | Height: | Size: 605 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 |
BIN
client/ui/assets/netbird-systemtray-needs-login-macos.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
client/ui/assets/netbird-systemtray-needs-login.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.3 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.7 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 104 KiB |
10
client/ui/assets/svg/needs-login.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<g transform="translate(0.5 4.5) scale(1.0)" opacity="0.7">
|
||||||
|
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||||
|
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||||
|
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="25" cy="25" r="7" fill="white"/>
|
||||||
|
<path d="M 22.6 24.5 v -1.4 a 2.4 2.4 0 0 1 4.8 0 v 1.4" fill="none" stroke="#D97706" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<rect x="21.6" y="24.5" width="6.8" height="4.9" rx="0.9" fill="none" stroke="#D97706" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 847 B |
7
client/ui/assets/svg/netbird-menu.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(0.5 4.5)">
|
||||||
|
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||||
|
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||||
|
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 556 B |
135
client/ui/authsession/service.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
package authsession
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtendStartParams optionally pre-fills the IdP login form.
|
||||||
|
type ExtendStartParams struct {
|
||||||
|
// Hint is the OIDC login_hint, typically the user's email.
|
||||||
|
Hint string `json:"hint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtendStartResult tells the UI what to open and how to match the
|
||||||
|
// follow-up Wait call to the daemon's pending flow.
|
||||||
|
type ExtendStartResult struct {
|
||||||
|
VerificationURI string `json:"verificationUri"`
|
||||||
|
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||||
|
UserCode string `json:"userCode"`
|
||||||
|
DeviceCode string `json:"deviceCode"`
|
||||||
|
ExpiresIn int64 `json:"expiresIn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtendWaitParams identifies the pending flow by the device/user code
|
||||||
|
// the UI received from RequestExtend.
|
||||||
|
type ExtendWaitParams struct {
|
||||||
|
DeviceCode string `json:"deviceCode"`
|
||||||
|
UserCode string `json:"userCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtendResult carries the refreshed deadline. ExpiresAt is nil when the
|
||||||
|
// management server reported the peer is not eligible for session
|
||||||
|
// extension. Preempted is true when a newer WaitExtend (e.g. started from
|
||||||
|
// another UI surface for the same deadline) took over the IdP poll —
|
||||||
|
// callers should treat the call as a no-op rather than a failure.
|
||||||
|
type ExtendResult struct {
|
||||||
|
ExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
|
||||||
|
Preempted bool `json:"preempted,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DaemonConn yields a lazy daemon gRPC client. Mirrors services.DaemonConn
|
||||||
|
// in the Wails services package; duplicated here so the Session can be
|
||||||
|
// owned by authsession without an import cycle.
|
||||||
|
type DaemonConn interface {
|
||||||
|
Client() (proto.DaemonServiceClient, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session bundles the session-auth daemon RPCs the UI drives — the
|
||||||
|
// interactive extend flow (RequestExtend + WaitExtend) and the Dismiss
|
||||||
|
// hand-off. The tray uses it directly; the Wails-bound wrapper in
|
||||||
|
// client/ui/services exposes only the subset the React frontend needs.
|
||||||
|
type Session struct {
|
||||||
|
conn DaemonConn
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSession returns a Session backed by the shared daemon connection.
|
||||||
|
func NewSession(conn DaemonConn) *Session {
|
||||||
|
return &Session{conn: conn}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestExtend starts the SSO session-extension flow on the daemon and
|
||||||
|
// returns the verification URI for the UI to open.
|
||||||
|
func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) {
|
||||||
|
cli, err := s.conn.Client()
|
||||||
|
if err != nil {
|
||||||
|
return ExtendStartResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &proto.RequestExtendAuthSessionRequest{}
|
||||||
|
if p.Hint != "" {
|
||||||
|
h := p.Hint
|
||||||
|
req.Hint = &h
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := cli.RequestExtendAuthSession(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return ExtendStartResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtendStartResult{
|
||||||
|
VerificationURI: resp.GetVerificationURI(),
|
||||||
|
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
||||||
|
UserCode: resp.GetUserCode(),
|
||||||
|
DeviceCode: resp.GetDeviceCode(),
|
||||||
|
ExpiresIn: resp.GetExpiresIn(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitExtend blocks until the user completes the SSO flow started by
|
||||||
|
// RequestExtend, then returns the new session deadline (or nil when the
|
||||||
|
// management server reports the peer ineligible).
|
||||||
|
func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) {
|
||||||
|
cli, err := s.conn.Client()
|
||||||
|
if err != nil {
|
||||||
|
return ExtendResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := cli.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{
|
||||||
|
DeviceCode: p.DeviceCode,
|
||||||
|
UserCode: p.UserCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Canceled {
|
||||||
|
return ExtendResult{Preempted: true}, nil
|
||||||
|
}
|
||||||
|
return ExtendResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := ExtendResult{}
|
||||||
|
if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
|
||||||
|
t := ts.AsTime().UTC()
|
||||||
|
out.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissWarning records the user's "Dismiss" click on the T-WarningLead
|
||||||
|
// notification so the daemon suppresses the T-FinalWarningLead fallback
|
||||||
|
// dialog for the current deadline. Best-effort: the daemon never reports
|
||||||
|
// a "deadline not found" error — a stale or no-op call is silently swallowed.
|
||||||
|
func (s *Session) DismissWarning(ctx context.Context) error {
|
||||||
|
cli, err := s.conn.Client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = cli.DismissSessionWarning(ctx, &proto.DismissSessionWarningRequest{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
83
client/ui/authsession/warning.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
// Package authsession holds the UI-side domain logic for the SSO
|
||||||
|
// session-extend feature. Wails service facades in
|
||||||
|
// client/ui/services/session*.go are thin adapters around the types and
|
||||||
|
// functions defined here; the parsing, request shapes, and constants
|
||||||
|
// live in this package so future-us can reason about (and test) the
|
||||||
|
// feature without dragging the Wails service surface around with it.
|
||||||
|
package authsession
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metadata keys the daemon attaches to session-warning SystemEvents.
|
||||||
|
// Re-exported from sessionwatch (single source of truth on the daemon
|
||||||
|
// side) so UI-side consumers don't have to import the daemon-internal
|
||||||
|
// package directly.
|
||||||
|
const (
|
||||||
|
MetaWarning = sessionwatch.MetaSessionWarning
|
||||||
|
MetaFinal = sessionwatch.MetaSessionFinal
|
||||||
|
MetaExpiresAt = sessionwatch.MetaSessionExpiresAt
|
||||||
|
MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes
|
||||||
|
)
|
||||||
|
|
||||||
|
// Warning is the typed payload emitted on the session-warning Wails
|
||||||
|
// events. The React side subscribes to "netbird:session:warning" and
|
||||||
|
// "netbird:session:final-warning" and receives this shape.
|
||||||
|
//
|
||||||
|
// ExpiresAt is best-effort: when the metadata is missing or malformed
|
||||||
|
// (e.g. an older daemon emits the event without the timestamp) it stays
|
||||||
|
// zero — the UI can fall back to the Status snapshot.
|
||||||
|
type Warning struct {
|
||||||
|
// ExpiresAt is the absolute UTC deadline the warning was fired
|
||||||
|
// against. The UI displays remaining time relative to its own clock.
|
||||||
|
ExpiresAt time.Time `json:"sessionExpiresAt"`
|
||||||
|
// LeadMinutes is the warning's configured lead time in minutes
|
||||||
|
// (WarningLead for the T-10 event, FinalWarningLead for the T-2
|
||||||
|
// event). Exposed so the UI can show "expires in ~N minutes" without
|
||||||
|
// hardcoding either constant on its side.
|
||||||
|
LeadMinutes int `json:"leadMinutes"`
|
||||||
|
// Final is true on the T-FinalWarningLead fallback event and false
|
||||||
|
// on the regular T-WarningLead notification. Exposed so a frontend
|
||||||
|
// listener bound to the dedicated final-warning Wails event still
|
||||||
|
// receives a payload it can self-describe (and so a tray that
|
||||||
|
// happens to see both event streams can branch in one place).
|
||||||
|
Final bool `json:"final"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WarningFromMetadata parses the daemon's SystemEvent metadata into a
|
||||||
|
// Warning payload. Returns (nil, false) when the event is not a
|
||||||
|
// session-warning at all (the common case). When the metadata flag is
|
||||||
|
// set but a field fails to parse, the field stays at its zero value and
|
||||||
|
// the event is still surfaced — the UI gets to decide how to handle it.
|
||||||
|
func WarningFromMetadata(meta map[string]string) (*Warning, bool) {
|
||||||
|
if meta == nil || meta[MetaWarning] != "true" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &Warning{
|
||||||
|
Final: meta[MetaFinal] == "true",
|
||||||
|
}
|
||||||
|
if raw := meta[MetaExpiresAt]; raw != "" {
|
||||||
|
if t, err := sessionwatch.ParseExpiresAt(raw); err == nil {
|
||||||
|
out.ExpiresAt = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw := meta[MetaLeadMinutes]; raw != "" {
|
||||||
|
if n, err := sessionwatch.ParseLeadMinutes(raw); err == nil {
|
||||||
|
out.LeadMinutes = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseExpiresAt decodes a MetaExpiresAt metadata value to a UTC time.
|
||||||
|
// Thin re-export of sessionwatch.ParseExpiresAt so UI-side call sites
|
||||||
|
// (tray, frontend bindings) don't import the daemon-internal package.
|
||||||
|
func ParseExpiresAt(s string) (time.Time, error) {
|
||||||
|
return sessionwatch.ParseExpiresAt(s)
|
||||||
|
}
|
||||||
82
client/ui/authsession/warning_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
package authsession
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWarningFromMetadata_NotASessionWarning(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
meta map[string]string
|
||||||
|
}{
|
||||||
|
{"nil metadata", nil},
|
||||||
|
{"empty map", map[string]string{}},
|
||||||
|
{"unrelated event", map[string]string{"new_version_available": "0.65.0"}},
|
||||||
|
{"flag not 'true'", map[string]string{"session_warning": "1"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if w, ok := WarningFromMetadata(tc.meta); ok {
|
||||||
|
t.Fatalf("expected (nil, false), got (%+v, %v)", w, ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarningFromMetadata_FullPayload(t *testing.T) {
|
||||||
|
ts := "2026-05-18T13:30:00Z"
|
||||||
|
meta := map[string]string{
|
||||||
|
"session_warning": "true",
|
||||||
|
"session_expires_at": ts,
|
||||||
|
"lead_minutes": "10",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := WarningFromMetadata(meta)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected the warning to be recognised, got ok=false")
|
||||||
|
}
|
||||||
|
want, _ := time.Parse(time.RFC3339, ts)
|
||||||
|
if !got.ExpiresAt.Equal(want.UTC()) {
|
||||||
|
t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, want.UTC())
|
||||||
|
}
|
||||||
|
if got.LeadMinutes != 10 {
|
||||||
|
t.Errorf("LeadMinutes = %d, want 10", got.LeadMinutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarningFromMetadata_BadFieldsStillEmits(t *testing.T) {
|
||||||
|
// Older or buggy daemon: the flag is set but the timestamp/lead are
|
||||||
|
// missing or malformed. The UI should still get a warning so it can
|
||||||
|
// at least surface "session expires soon"; field zero-values are fine.
|
||||||
|
meta := map[string]string{
|
||||||
|
"session_warning": "true",
|
||||||
|
"session_expires_at": "not-a-timestamp",
|
||||||
|
"lead_minutes": "abc",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := WarningFromMetadata(meta)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("warning should still be recognised even with malformed fields")
|
||||||
|
}
|
||||||
|
if !got.ExpiresAt.IsZero() {
|
||||||
|
t.Errorf("malformed timestamp should leave field zero, got %v", got.ExpiresAt)
|
||||||
|
}
|
||||||
|
if got.LeadMinutes != 0 {
|
||||||
|
t.Errorf("malformed lead_minutes should leave field 0, got %d", got.LeadMinutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarningFromMetadata_MissingFieldsStillEmits(t *testing.T) {
|
||||||
|
// Only the flag is present (e.g. future-trimmed event). Still emit.
|
||||||
|
meta := map[string]string{"session_warning": "true"}
|
||||||
|
got, ok := WarningFromMetadata(meta)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("warning should still be recognised when only flag is present")
|
||||||
|
}
|
||||||
|
if got.ExpiresAt.IsZero() != true || got.LeadMinutes != 0 {
|
||||||
|
t.Errorf("missing fields should be zero-valued, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
295
client/ui/build/Taskfile.yml
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
go:mod:tidy:
|
||||||
|
summary: Runs `go mod tidy`
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
install:frontend:deps:
|
||||||
|
summary: Install frontend dependencies
|
||||||
|
dir: frontend
|
||||||
|
sources:
|
||||||
|
- package.json
|
||||||
|
- pnpm-lock.yaml
|
||||||
|
generates:
|
||||||
|
- node_modules
|
||||||
|
preconditions:
|
||||||
|
- sh: pnpm --version
|
||||||
|
msg: "Looks like pnpm isn't installed. Install with: corepack enable && corepack prepare pnpm@latest --activate"
|
||||||
|
cmds:
|
||||||
|
- pnpm install
|
||||||
|
|
||||||
|
build:frontend:
|
||||||
|
label: build:frontend (DEV={{.DEV}})
|
||||||
|
summary: Build the frontend project
|
||||||
|
dir: frontend
|
||||||
|
sources:
|
||||||
|
- "**/*"
|
||||||
|
- exclude: node_modules/**/*
|
||||||
|
generates:
|
||||||
|
- dist/**/*
|
||||||
|
deps:
|
||||||
|
- task: install:frontend:deps
|
||||||
|
- task: generate:bindings
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
cmds:
|
||||||
|
- pnpm run {{.BUILD_COMMAND}}
|
||||||
|
env:
|
||||||
|
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
|
||||||
|
vars:
|
||||||
|
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
|
||||||
|
|
||||||
|
|
||||||
|
frontend:vendor:puppertino:
|
||||||
|
summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
|
||||||
|
sources:
|
||||||
|
- frontend/public/puppertino/puppertino.css
|
||||||
|
generates:
|
||||||
|
- frontend/public/puppertino/puppertino.css
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p frontend/public/puppertino
|
||||||
|
# If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
|
||||||
|
if [ ! -f frontend/public/puppertino/puppertino.css ]; then
|
||||||
|
echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
|
||||||
|
if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
|
||||||
|
echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
|
||||||
|
else
|
||||||
|
echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
|
||||||
|
fi
|
||||||
|
# Ensure index.html includes Puppertino CSS and button classes
|
||||||
|
INDEX_HTML=frontend/index.html
|
||||||
|
if [ -f "$INDEX_HTML" ]; then
|
||||||
|
if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
|
||||||
|
# Insert Puppertino link tag after style.css link
|
||||||
|
awk '
|
||||||
|
/href="\/style.css"\/?/ && !x { print; print " <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; x=1; next }1
|
||||||
|
' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
|
||||||
|
fi
|
||||||
|
# Replace default .btn with Puppertino primary button classes if present
|
||||||
|
sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
generate:bindings:
|
||||||
|
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
|
||||||
|
summary: Generates bindings for the frontend
|
||||||
|
deps:
|
||||||
|
- task: go:mod:tidy
|
||||||
|
sources:
|
||||||
|
- "**/*.[jt]s"
|
||||||
|
- exclude: frontend/**/*
|
||||||
|
- frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
|
||||||
|
- "**/*.go"
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
generates:
|
||||||
|
- frontend/bindings/**/*
|
||||||
|
cmds:
|
||||||
|
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -ts
|
||||||
|
|
||||||
|
generate:icons:
|
||||||
|
summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
|
||||||
|
dir: build
|
||||||
|
sources:
|
||||||
|
- "appicon.png"
|
||||||
|
- "appicon.icon"
|
||||||
|
generates:
|
||||||
|
- "darwin/icons.icns"
|
||||||
|
- "windows/icon.ico"
|
||||||
|
cmds:
|
||||||
|
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
|
||||||
|
|
||||||
|
generate:tray:icons:
|
||||||
|
summary: Rebuild Windows multi-res .ico files from the per-state PNGs.
|
||||||
|
desc: |
|
||||||
|
The colored tray PNGs (assets/netbird-systemtray-<state>.png) and the
|
||||||
|
macOS template variants are committed to the repo as the canonical
|
||||||
|
source. This task only regenerates the Windows multi-resolution .ico
|
||||||
|
files from those PNGs by downscaling each to 16/24/32/48 px and
|
||||||
|
packing them with icotool, so Shell_NotifyIcon picks the frame
|
||||||
|
matching the user's DPI instead of downscaling a single large PNG.
|
||||||
|
|
||||||
|
Run after replacing any of the colored PNGs (e.g. when copying a new
|
||||||
|
version of the icons from client/ui/assets). The SVG sources in
|
||||||
|
assets/svg/ are kept for reference but are not built by default.
|
||||||
|
dir: assets
|
||||||
|
sources:
|
||||||
|
- "netbird-systemtray-connected.png"
|
||||||
|
- "netbird-systemtray-disconnected.png"
|
||||||
|
- "netbird-systemtray-connecting.png"
|
||||||
|
- "netbird-systemtray-error.png"
|
||||||
|
- "netbird-systemtray-update-connected.png"
|
||||||
|
- "netbird-systemtray-update-disconnected.png"
|
||||||
|
generates:
|
||||||
|
- "netbird-systemtray-*.ico"
|
||||||
|
preconditions:
|
||||||
|
- sh: command -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1
|
||||||
|
msg: "ImageMagick is required to downscale PNGs (apt install imagemagick)"
|
||||||
|
- sh: command -v icotool >/dev/null 2>&1
|
||||||
|
msg: "icotool is required to pack tray .ico files (apt install icoutils)"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
resize=$(command -v magick || echo convert)
|
||||||
|
for state in connected disconnected connecting error update-connected update-disconnected; do
|
||||||
|
for sz in 16 24 32 48; do
|
||||||
|
"$resize" "netbird-systemtray-$state.png" -resize ${sz}x${sz} "$tmp/$state-$sz.png"
|
||||||
|
done
|
||||||
|
icotool -c -o "netbird-systemtray-$state.ico" \
|
||||||
|
"$tmp/$state-16.png" "$tmp/$state-24.png" "$tmp/$state-32.png" "$tmp/$state-48.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
dev:frontend:
|
||||||
|
summary: Runs the frontend in development mode
|
||||||
|
dir: frontend
|
||||||
|
deps:
|
||||||
|
- task: install:frontend:deps
|
||||||
|
cmds:
|
||||||
|
- pnpm exec vite --port {{.VITE_PORT}} --strictPort
|
||||||
|
|
||||||
|
update:build-assets:
|
||||||
|
summary: Updates the build assets
|
||||||
|
dir: build
|
||||||
|
cmds:
|
||||||
|
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
|
||||||
|
|
||||||
|
build:server:
|
||||||
|
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||||
|
desc: |
|
||||||
|
Builds the application with the server build tag enabled.
|
||||||
|
Server mode runs as a pure HTTP server without native GUI dependencies.
|
||||||
|
Usage: task build:server
|
||||||
|
deps:
|
||||||
|
- task: build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
cmds:
|
||||||
|
- go build -tags server {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: "{{.BUILD_FLAGS}}"
|
||||||
|
|
||||||
|
run:server:
|
||||||
|
summary: Builds and runs the application in server mode
|
||||||
|
deps:
|
||||||
|
- task: build:server
|
||||||
|
cmds:
|
||||||
|
- ./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Builds a Docker image for server mode deployment
|
||||||
|
desc: |
|
||||||
|
Creates a minimal Docker image containing the server mode binary.
|
||||||
|
The image is based on distroless for security and small size.
|
||||||
|
Usage: task build:docker [TAG=myapp:latest]
|
||||||
|
cmds:
|
||||||
|
- docker build -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} -f build/docker/Dockerfile.server .
|
||||||
|
vars:
|
||||||
|
TAG: "{{.TAG}}"
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required. Please install Docker first."
|
||||||
|
- sh: test -f build/docker/Dockerfile.server
|
||||||
|
msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
|
||||||
|
|
||||||
|
run:docker:
|
||||||
|
summary: Builds and runs the Docker image
|
||||||
|
desc: |
|
||||||
|
Builds the Docker image and runs it, exposing port 8080.
|
||||||
|
Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
|
||||||
|
Note: The internal container port is always 8080. The PORT variable
|
||||||
|
only changes the host port mapping. Ensure your app uses port 8080
|
||||||
|
or modify the Dockerfile to match your ServerOptions.Port setting.
|
||||||
|
deps:
|
||||||
|
- task: build:docker
|
||||||
|
vars:
|
||||||
|
TAG:
|
||||||
|
ref: .TAG
|
||||||
|
cmds:
|
||||||
|
- docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
|
||||||
|
vars:
|
||||||
|
TAG: "{{.TAG}}"
|
||||||
|
PORT: "{{.PORT}}"
|
||||||
|
|
||||||
|
setup:docker:
|
||||||
|
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||||
|
desc: |
|
||||||
|
Builds the Docker image needed for cross-compiling to any platform.
|
||||||
|
Run this once to enable cross-platform builds from any OS.
|
||||||
|
cmds:
|
||||||
|
- docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required. Please install Docker first."
|
||||||
|
|
||||||
|
ios:device:list:
|
||||||
|
summary: Lists connected iOS devices (UDIDs)
|
||||||
|
cmds:
|
||||||
|
- xcrun xcdevice list
|
||||||
|
|
||||||
|
ios:run:device:
|
||||||
|
summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
|
||||||
|
vars:
|
||||||
|
PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/<YourProject>.xcodeproj
|
||||||
|
SCHEME: '{{.SCHEME}}' # e.g., ios.dev
|
||||||
|
CONFIG: '{{.CONFIG | default "Debug"}}'
|
||||||
|
DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
|
||||||
|
UDID: '{{.UDID}}' # from `task ios:device:list`
|
||||||
|
BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
|
||||||
|
TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
|
||||||
|
preconditions:
|
||||||
|
- sh: xcrun -f xcodebuild
|
||||||
|
msg: "xcodebuild not found. Please install Xcode."
|
||||||
|
- sh: xcrun -f devicectl
|
||||||
|
msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
|
||||||
|
- sh: test -n '{{.PROJECT}}'
|
||||||
|
msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
|
||||||
|
- sh: test -n '{{.SCHEME}}'
|
||||||
|
msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
|
||||||
|
- sh: test -n '{{.UDID}}'
|
||||||
|
msg: "Set UDID to your device UDID (see: task ios:device:list)."
|
||||||
|
- sh: test -n '{{.BUNDLE_ID}}'
|
||||||
|
msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
|
||||||
|
XCB_ARGS=(
|
||||||
|
-project "{{.PROJECT}}"
|
||||||
|
-scheme "{{.SCHEME}}"
|
||||||
|
-configuration "{{.CONFIG}}"
|
||||||
|
-destination "id={{.UDID}}"
|
||||||
|
-derivedDataPath "{{.DERIVED}}"
|
||||||
|
-allowProvisioningUpdates
|
||||||
|
-allowProvisioningDeviceRegistration
|
||||||
|
)
|
||||||
|
# Optionally inject signing identifiers if provided
|
||||||
|
if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
|
||||||
|
if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
|
||||||
|
xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
|
||||||
|
# If xcpretty isn't installed, run without it
|
||||||
|
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
|
||||||
|
xcodebuild "${XCB_ARGS[@]}" build
|
||||||
|
fi
|
||||||
|
# Find built .app
|
||||||
|
APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
|
||||||
|
if [ -z "$APP_PATH" ]; then
|
||||||
|
echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Installing: $APP_PATH"
|
||||||
|
xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
|
||||||
|
echo "Launching: {{.BUNDLE_ID}}"
|
||||||
|
xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"
|
||||||
13
client/ui/build/appicon.icon/Assets/wails_icon_vector.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!--
|
||||||
|
macOS Icon Composer source. Designed on a 1024x1024 canvas with the bird
|
||||||
|
glyph centered and sized to ~75% of canvas width, leaving padding for
|
||||||
|
the system squircle treatment.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<g transform="translate(128, 227) scale(24.77)">
|
||||||
|
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||||
|
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||||
|
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 810 B |
26
client/ui/build/appicon.icon/icon.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"image-name" : "wails_icon_vector.svg",
|
||||||
|
"name" : "wails_icon_vector"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"specular" : true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||