Compare commits
252 Commits
v0.71.3
...
ui-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d8ac6489 | ||
|
|
558769e671 | ||
|
|
fb6138a3ba | ||
|
|
b111c38b7c | ||
|
|
f54121ebfa | ||
|
|
122d172f33 | ||
|
|
0b19a99693 | ||
|
|
0309f992ad | ||
|
|
1ad2d90d3b | ||
|
|
5a9e9e7bc9 | ||
|
|
43e041cf9f | ||
|
|
77e5693200 | ||
|
|
93a1547871 | ||
|
|
04ab9b5bad | ||
|
|
61431801ea | ||
|
|
02e3cb9987 | ||
|
|
7a78b9df8a | ||
|
|
1416a2e160 | ||
|
|
88db1724bf | ||
|
|
d0d7252c24 | ||
|
|
9dc9e7184e | ||
|
|
1985caf993 | ||
|
|
16570b3223 | ||
|
|
967235e964 | ||
|
|
7d876571da | ||
|
|
e6a624dcee | ||
|
|
bee92f5fcd | ||
|
|
f4914fdfcc | ||
|
|
2cdc6ef1c6 | ||
|
|
3279b705fe | ||
|
|
e94a4cbce5 | ||
|
|
174dc24867 | ||
|
|
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 | ||
|
|
14af179556 | ||
|
|
1fbb5e6d5d | ||
|
|
6771e35d57 | ||
|
|
91e0520f27 | ||
|
|
67a1f3c4fe | ||
|
|
e89b1e0596 | ||
|
|
b6d20edfeb | ||
|
|
18d0019332 | ||
|
|
ecee7df5d8 | ||
|
|
1d783c33d9 | ||
|
|
b14feef1d7 | ||
|
|
0935a5675d | ||
|
|
4818599a93 | ||
|
|
f8c107b087 | ||
|
|
d542c60e21 | ||
|
|
4983b5cf17 | ||
|
|
b3b0feb3b8 | ||
|
|
7aebdd69dd | ||
|
|
d624c2db74 | ||
|
|
513ecd456c | ||
|
|
8f957ff41a | ||
|
|
598fcbd817 | ||
|
|
17a365926d | ||
|
|
577ce6deb5 | ||
|
|
580cfa0dc5 | ||
|
|
8d4f35352f | ||
|
|
85029898a5 | ||
|
|
0358be2313 | ||
|
|
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 |
45
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 15
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
ignore:
|
||||||
|
# git-town/action v1.3.x crashes on cyclic PR graphs (self-loop main->main
|
||||||
|
# fork PRs) via its topological-sort visualization. Pinned to v1.2.1 in
|
||||||
|
# git-town.yml; block v1.3.x until upstream tolerates cyclic edges.
|
||||||
|
- dependency-name: "git-town/action"
|
||||||
|
update-types:
|
||||||
|
- "version-update:semver-minor"
|
||||||
|
- "version-update:semver-major"
|
||||||
|
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directories:
|
||||||
|
- "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 15
|
||||||
|
groups:
|
||||||
|
aws-sdk:
|
||||||
|
patterns:
|
||||||
|
- "github.com/aws/aws-sdk-go-v2/*"
|
||||||
|
pion:
|
||||||
|
patterns:
|
||||||
|
- "github.com/pion/*"
|
||||||
|
gorm:
|
||||||
|
patterns:
|
||||||
|
- "gorm.io/*"
|
||||||
|
otel:
|
||||||
|
patterns:
|
||||||
|
- "go.opentelemetry.io/*"
|
||||||
|
testcontainers:
|
||||||
|
patterns:
|
||||||
|
- "github.com/testcontainers/testcontainers-go/*"
|
||||||
|
wireguard:
|
||||||
|
patterns:
|
||||||
|
- "golang.zx2c4.com/wireguard*"
|
||||||
109
.github/workflows/check-license-dependencies.yml
vendored
@@ -2,16 +2,16 @@ name: Check License Dependencies
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'go.mod'
|
- "go.mod"
|
||||||
- 'go.sum'
|
- "go.sum"
|
||||||
- '.github/workflows/check-license-dependencies.yml'
|
- ".github/workflows/check-license-dependencies.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'go.mod'
|
- "go.mod"
|
||||||
- 'go.sum'
|
- "go.sum"
|
||||||
- '.github/workflows/check-license-dependencies.yml'
|
- ".github/workflows/check-license-dependencies.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-internal-dependencies:
|
check-internal-dependencies:
|
||||||
@@ -19,7 +19,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for problematic license dependencies
|
- name: Check for problematic license dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -56,55 +59,57 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: "go.mod"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Install go-licenses
|
- name: Install go-licenses
|
||||||
run: go install github.com/google/go-licenses@v1.6.0
|
run: go install github.com/google/go-licenses@v1.6.0
|
||||||
|
|
||||||
- name: Check for GPL/AGPL licensed dependencies
|
- name: Check for GPL/AGPL licensed dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
|
echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check all Go packages for copyleft licenses, excluding internal netbird packages
|
|
||||||
COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
|
|
||||||
|
|
||||||
if [ -n "$COPYLEFT_DEPS" ]; then
|
|
||||||
echo "Found copyleft licensed dependencies:"
|
|
||||||
echo "$COPYLEFT_DEPS"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Filter out dependencies that are only pulled in by internal AGPL packages
|
# Check all Go packages for copyleft licenses, excluding internal netbird packages
|
||||||
INCOMPATIBLE=""
|
COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
|
||||||
while IFS=',' read -r package url license; do
|
|
||||||
if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
|
|
||||||
# Find ALL packages that import this GPL package using go list
|
|
||||||
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
|
||||||
|
|
||||||
# Check if any importer is NOT in management/signal/relay
|
if [ -n "$COPYLEFT_DEPS" ]; then
|
||||||
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
echo "Found copyleft licensed dependencies:"
|
||||||
|
echo "$COPYLEFT_DEPS"
|
||||||
if [ -n "$BSD_IMPORTER" ]; then
|
|
||||||
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
|
||||||
INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
|
|
||||||
else
|
|
||||||
echo "✓ $package ($license) is only used by internal AGPL packages - OK"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<< "$COPYLEFT_DEPS"
|
|
||||||
|
|
||||||
if [ -n "$INCOMPATIBLE" ]; then
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
|
|
||||||
echo -e "$INCOMPATIBLE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ All external license dependencies are compatible with BSD-3-Clause"
|
# Filter out dependencies that are only pulled in by internal AGPL packages
|
||||||
|
INCOMPATIBLE=""
|
||||||
|
while IFS=',' read -r package url license; do
|
||||||
|
if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
|
||||||
|
# Find ALL packages that import this GPL package using go list
|
||||||
|
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
||||||
|
|
||||||
|
# Check if any importer is NOT in management/signal/relay
|
||||||
|
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
||||||
|
|
||||||
|
if [ -n "$BSD_IMPORTER" ]; then
|
||||||
|
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
||||||
|
INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
|
||||||
|
else
|
||||||
|
echo "✓ $package ($license) is only used by internal AGPL packages - OK"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<< "$COPYLEFT_DEPS"
|
||||||
|
|
||||||
|
if [ -n "$INCOMPATIBLE" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
|
||||||
|
echo -e "$INCOMPATIBLE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ All external license dependencies are compatible with BSD-3-Clause"
|
||||||
|
|||||||
2
.github/workflows/docs-ack.yml
vendored
@@ -83,7 +83,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Verify docs PR exists (and is open or merged)
|
- name: Verify docs PR exists (and is open or merged)
|
||||||
if: steps.validate.outputs.mode == 'added'
|
if: steps.validate.outputs.mode == 'added'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
id: verify
|
id: verify
|
||||||
with:
|
with:
|
||||||
pr_number: ${{ steps.extract.outputs.pr_number }}
|
pr_number: ${{ steps.extract.outputs.pr_number }}
|
||||||
|
|||||||
5
.github/workflows/forum.yml
vendored
@@ -8,11 +8,10 @@ jobs:
|
|||||||
post:
|
post:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: roots/discourse-topic-github-release-action@main
|
- uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
|
||||||
with:
|
with:
|
||||||
discourse-api-key: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
|
discourse-api-key: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
|
||||||
discourse-base-url: https://forum.netbird.io
|
discourse-base-url: https://forum.netbird.io
|
||||||
discourse-author-username: NetBird
|
discourse-author-username: NetBird
|
||||||
discourse-category: 17
|
discourse-category: 17
|
||||||
discourse-tags:
|
discourse-tags: releases
|
||||||
releases
|
|
||||||
|
|||||||
8
.github/workflows/git-town.yml
vendored
@@ -3,7 +3,7 @@ name: Git Town
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- '**'
|
- "**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
git-town:
|
git-town:
|
||||||
@@ -15,7 +15,9 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: git-town/action@v1.2.1
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1
|
||||||
with:
|
with:
|
||||||
skip-single-stacks: true
|
skip-single-stacks: true
|
||||||
|
|||||||
18
.github/workflows/golang-test-darwin.yml
vendored
@@ -16,16 +16,18 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: macos-gotest-${{ hashFiles('**/go.sum') }}
|
key: macos-gotest-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -43,5 +45,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)
|
||||||
|
|
||||||
|
|||||||
21
.github/workflows/golang-test-freebsd.yml
vendored
@@ -15,20 +15,31 @@ jobs:
|
|||||||
name: "Client / Unit"
|
name: "Client / Unit"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Read Go version from go.mod
|
||||||
|
id: goversion
|
||||||
|
run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Test in FreeBSD
|
- name: Test in FreeBSD
|
||||||
id: test
|
id: test
|
||||||
uses: vmactions/freebsd-vm@v1
|
env:
|
||||||
|
GO_VERSION: ${{ steps.goversion.outputs.version }}
|
||||||
|
uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
|
||||||
with:
|
with:
|
||||||
usesh: true
|
usesh: true
|
||||||
copyback: false
|
copyback: false
|
||||||
release: "14.2"
|
release: "15.0"
|
||||||
|
envs: "GO_VERSION"
|
||||||
prepare: |
|
prepare: |
|
||||||
pkg install -y curl pkgconf xorg
|
pkg install -y curl pkgconf xorg
|
||||||
GO_TARBALL="go1.25.3.freebsd-amd64.tar.gz"
|
GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
|
||||||
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
||||||
curl -vLO "$GO_URL"
|
curl -vLO "$GO_URL"
|
||||||
tar -C /usr/local -vxzf "$GO_TARBALL"
|
tar -C /usr/local -vxzf "$GO_TARBALL"
|
||||||
|
|
||||||
# -x - to print all executed commands
|
# -x - to print all executed commands
|
||||||
# -e - to faile on first error
|
# -e - to faile on first error
|
||||||
|
|||||||
154
.github/workflows/golang-test-linux.yml
vendored
@@ -18,9 +18,11 @@ jobs:
|
|||||||
management: ${{ steps.filter.outputs.management }}
|
management: ${{ steps.filter.outputs.management }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||||
id: filter
|
id: filter
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
@@ -28,7 +30,7 @@ jobs:
|
|||||||
- 'management/**'
|
- 'management/**'
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -36,10 +38,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
id: cache
|
id: cache
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -51,7 +53,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-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'
|
||||||
@@ -113,14 +115,16 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ '386','amd64' ]
|
arch: ["386", "amd64"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -128,10 +132,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -141,7 +145,7 @@ jobs:
|
|||||||
${{ runner.os }}-gotest-cache-
|
${{ runner.os }}-gotest-cache-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-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,18 +158,28 @@ 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"
|
||||||
needs: [ build-cache ]
|
needs: [build-cache]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -177,7 +191,7 @@ jobs:
|
|||||||
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
id: cache-restore
|
id: cache-restore
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -214,7 +228,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:
|
||||||
@@ -231,10 +245,12 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -246,10 +262,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -277,14 +293,16 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ '386','amd64' ]
|
arch: ["386", "amd64"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -298,7 +316,7 @@ jobs:
|
|||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -324,14 +342,16 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ '386','amd64' ]
|
arch: ["386", "amd64"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -343,10 +363,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -370,19 +390,21 @@ jobs:
|
|||||||
|
|
||||||
test_management:
|
test_management:
|
||||||
name: "Management / Unit"
|
name: "Management / Unit"
|
||||||
needs: [ build-cache ]
|
needs: [build-cache]
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ 'amd64' ]
|
arch: ["amd64"]
|
||||||
store: [ 'sqlite', 'postgres', 'mysql' ]
|
store: ["sqlite", "postgres", "mysql"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -390,10 +412,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -410,7 +432,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -427,7 +449,7 @@ jobs:
|
|||||||
run: docker pull mlsmaycon/warmed-mysql:8
|
run: docker pull mlsmaycon/warmed-mysql:8
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||||
CI=true \
|
CI=true \
|
||||||
@@ -437,13 +459,13 @@ jobs:
|
|||||||
|
|
||||||
benchmark:
|
benchmark:
|
||||||
name: "Management / Benchmark"
|
name: "Management / Benchmark"
|
||||||
needs: [ build-cache ]
|
needs: [build-cache]
|
||||||
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ 'amd64' ]
|
arch: ["amd64"]
|
||||||
store: [ 'sqlite', 'postgres' ]
|
store: ["sqlite", "postgres"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Create Docker network
|
- name: Create Docker network
|
||||||
@@ -474,10 +496,12 @@ jobs:
|
|||||||
prom/prometheus
|
prom/prometheus
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -485,10 +509,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -505,7 +529,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -529,13 +553,13 @@ jobs:
|
|||||||
|
|
||||||
api_benchmark:
|
api_benchmark:
|
||||||
name: "Management / Benchmark (API)"
|
name: "Management / Benchmark (API)"
|
||||||
needs: [ build-cache ]
|
needs: [build-cache]
|
||||||
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ 'amd64' ]
|
arch: ["amd64"]
|
||||||
store: [ 'sqlite', 'postgres' ]
|
store: ["sqlite", "postgres"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Create Docker network
|
- name: Create Docker network
|
||||||
@@ -566,10 +590,12 @@ jobs:
|
|||||||
prom/prometheus
|
prom/prometheus
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -577,10 +603,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -597,7 +623,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -623,20 +649,22 @@ jobs:
|
|||||||
|
|
||||||
api_integration_test:
|
api_integration_test:
|
||||||
name: "Management / Integration"
|
name: "Management / Integration"
|
||||||
needs: [ build-cache ]
|
needs: [build-cache]
|
||||||
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ 'amd64' ]
|
arch: ["amd64"]
|
||||||
store: [ 'sqlite', 'postgres']
|
store: ["sqlite", "postgres"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -644,10 +672,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
|
|||||||
28
.github/workflows/golang-test-windows.yml
vendored
@@ -18,10 +18,12 @@ jobs:
|
|||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
id: go
|
id: go
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
@@ -33,7 +35,7 @@ jobs:
|
|||||||
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -44,16 +46,15 @@ jobs:
|
|||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Download wintun
|
- name: Download wintun
|
||||||
uses: carlosperate/download-file-action@v2
|
|
||||||
id: download-wintun
|
id: download-wintun
|
||||||
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||||
file-name: wintun.zip
|
destination: ${{ env.downloadPath }}\wintun.zip
|
||||||
location: ${{ env.downloadPath }}
|
sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
|
||||||
sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
|
|
||||||
|
|
||||||
- name: Decompressing wintun files
|
- name: Decompressing wintun files
|
||||||
run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
run: tar -xvf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
||||||
|
|
||||||
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
|
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
|
||||||
|
|
||||||
@@ -64,8 +65,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
|
||||||
|
|||||||
31
.github/workflows/golangci-lint.yml
vendored
@@ -15,12 +15,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: codespell
|
- name: codespell
|
||||||
uses: codespell-project/actions-codespell@v2
|
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
||||||
skip: go.mod,go.sum,**/proxy/web/**
|
# Non-English UI translations trip codespell on real foreign words
|
||||||
|
# (de: "Sie", "oder", "ist"). Only en/common.json is the source of
|
||||||
|
# truth that should be spell-checked. Add each new locale dir here
|
||||||
|
# when a language is added under client/ui/i18n/locales/.
|
||||||
|
skip: go.mod,go.sum,**/proxy/web/**,**/pnpm-lock.yaml,**/package-lock.json,client/ui/i18n/locales/de/**,client/ui/i18n/locales/hu/**
|
||||||
golangci:
|
golangci:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -38,21 +44,32 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Check for duplicate constants
|
- name: Check for duplicate constants
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
|
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
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@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
|
|||||||
4
.github/workflows/install-script-test.yml
vendored
@@ -22,7 +22,9 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: run install script
|
- name: run install script
|
||||||
env:
|
env:
|
||||||
|
|||||||
18
.github/workflows/mobile-build-validation.yml
vendored
@@ -16,23 +16,25 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||||
with:
|
with:
|
||||||
cmdline-tools-version: 8512546
|
cmdline-tools-version: 8512546
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
|
||||||
with:
|
with:
|
||||||
java-version: "11"
|
java-version: "11"
|
||||||
distribution: "adopt"
|
distribution: "adopt"
|
||||||
- name: NDK Cache
|
- name: NDK Cache
|
||||||
id: ndk-cache
|
id: ndk-cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: /usr/local/lib/android/sdk/ndk
|
path: /usr/local/lib/android/sdk/ndk
|
||||||
key: ndk-cache-23.1.7779620
|
key: ndk-cache-23.1.7779620
|
||||||
@@ -52,9 +54,11 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: install gomobile
|
- name: install gomobile
|
||||||
|
|||||||
2
.github/workflows/pr-title-check.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Validate PR title prefix
|
- name: Validate PR title prefix
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const title = context.payload.pull_request.title;
|
const title = context.payload.pull_request.title;
|
||||||
|
|||||||
68
.github/workflows/proto-version-check.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check for proto tool version changes
|
- name: Check for proto tool version changes
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||||
@@ -20,34 +20,66 @@ jobs:
|
|||||||
per_page: 100,
|
per_page: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pbFiles = files.filter(f => f.filename.endsWith('.pb.go'));
|
const modifiedPbFiles = files.filter(
|
||||||
const missingPatch = pbFiles.filter(f => !f.patch).map(f => f.filename);
|
f => f.filename.endsWith('.pb.go') && f.status === 'modified'
|
||||||
if (missingPatch.length > 0) {
|
);
|
||||||
core.setFailed(
|
if (modifiedPbFiles.length === 0) {
|
||||||
`Cannot inspect patch data for:\n` +
|
console.log('No modified .pb.go files to check');
|
||||||
missingPatch.map(f => `- ${f}`).join('\n') +
|
|
||||||
`\nThis can happen with very large PRs. Verify proto versions manually.`
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const versionPattern = /^[+-]\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
|
||||||
const violations = [];
|
|
||||||
|
|
||||||
for (const file of pbFiles) {
|
const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
||||||
const changed = file.patch
|
const baseSha = context.payload.pull_request.base.sha;
|
||||||
.split('\n')
|
const headSha = context.payload.pull_request.head.sha;
|
||||||
.filter(line => versionPattern.test(line));
|
|
||||||
if (changed.length > 0) {
|
async function getVersionHeader(path, ref) {
|
||||||
|
try {
|
||||||
|
const res = await github.rest.repos.getContent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
path,
|
||||||
|
ref,
|
||||||
|
});
|
||||||
|
if (!res.data.content) {
|
||||||
|
return { ok: false, reason: 'no inline content (file too large)' };
|
||||||
|
}
|
||||||
|
const content = Buffer.from(res.data.content, 'base64').toString('utf8');
|
||||||
|
const lines = content
|
||||||
|
.split('\n')
|
||||||
|
.slice(0, 20)
|
||||||
|
.filter(line => versionPattern.test(line));
|
||||||
|
return { ok: true, lines };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, reason: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations = [];
|
||||||
|
for (const file of modifiedPbFiles) {
|
||||||
|
const [base, head] = await Promise.all([
|
||||||
|
getVersionHeader(file.filename, baseSha),
|
||||||
|
getVersionHeader(file.filename, headSha),
|
||||||
|
]);
|
||||||
|
if (!base.ok || !head.ok) {
|
||||||
|
core.warning(
|
||||||
|
`Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (base.lines.join('\n') !== head.lines.join('\n')) {
|
||||||
violations.push({
|
violations.push({
|
||||||
file: file.filename,
|
file: file.filename,
|
||||||
lines: changed,
|
base: base.lines,
|
||||||
|
head: head.lines,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (violations.length > 0) {
|
if (violations.length > 0) {
|
||||||
const details = violations.map(v =>
|
const details = violations.map(v =>
|
||||||
`${v.file}:\n${v.lines.map(l => ' ' + l).join('\n')}`
|
`${v.file}:\n` +
|
||||||
|
` base:\n${v.base.map(l => ' ' + l).join('\n') || ' (none)'}\n` +
|
||||||
|
` head:\n${v.head.map(l => ' ' + l).join('\n') || ' (none)'}`
|
||||||
).join('\n\n');
|
).join('\n\n');
|
||||||
|
|
||||||
core.setFailed(
|
core.setFailed(
|
||||||
|
|||||||
239
.github/workflows/release.yml
vendored
@@ -9,7 +9,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SIGN_PIPE_VER: "v0.1.4"
|
SIGN_PIPE_VER: "v0.1.5"
|
||||||
GORELEASER_VER: "v2.14.3"
|
GORELEASER_VER: "v2.14.3"
|
||||||
PRODUCT_NAME: "NetBird"
|
PRODUCT_NAME: "NetBird"
|
||||||
COPYRIGHT: "NetBird GmbH"
|
COPYRIGHT: "NetBird GmbH"
|
||||||
@@ -24,7 +24,9 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Generate FreeBSD port diff
|
- name: Generate FreeBSD port diff
|
||||||
run: bash release_files/freebsd-port-diff.sh
|
run: bash release_files/freebsd-port-diff.sh
|
||||||
@@ -51,19 +53,26 @@ jobs:
|
|||||||
echo "Generated files for version: $VERSION"
|
echo "Generated files for version: $VERSION"
|
||||||
cat netbird-*.diff
|
cat netbird-*.diff
|
||||||
|
|
||||||
|
- name: Read Go version from go.mod
|
||||||
|
id: goversion
|
||||||
|
run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Test FreeBSD port
|
- name: Test FreeBSD port
|
||||||
if: steps.check_diff.outputs.diff_exists == 'true'
|
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||||
uses: vmactions/freebsd-vm@v1
|
env:
|
||||||
|
GO_VERSION: ${{ steps.goversion.outputs.version }}
|
||||||
|
uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
|
||||||
with:
|
with:
|
||||||
usesh: true
|
usesh: true
|
||||||
copyback: false
|
copyback: false
|
||||||
release: "15.0"
|
release: "15.0"
|
||||||
|
envs: "GO_VERSION"
|
||||||
prepare: |
|
prepare: |
|
||||||
# Install required packages
|
# Install required packages
|
||||||
pkg install -y git curl portlint go
|
pkg install -y git curl portlint
|
||||||
|
|
||||||
# Install Go for building
|
# Install Go for building
|
||||||
GO_TARBALL="go1.25.5.freebsd-amd64.tar.gz"
|
GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
|
||||||
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
||||||
curl -LO "$GO_URL"
|
curl -LO "$GO_URL"
|
||||||
tar -C /usr/local -xzf "$GO_TARBALL"
|
tar -C /usr/local -xzf "$GO_TARBALL"
|
||||||
@@ -93,19 +102,19 @@ jobs:
|
|||||||
|
|
||||||
# Show patched Makefile
|
# Show patched Makefile
|
||||||
version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}')
|
version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}')
|
||||||
|
|
||||||
cd /usr/ports/security/netbird
|
cd /usr/ports/security/netbird
|
||||||
export BATCH=yes
|
export BATCH=yes
|
||||||
make package
|
make package
|
||||||
pkg add ./work/pkg/netbird-*.pkg
|
pkg add ./work/pkg/netbird-*.pkg
|
||||||
|
|
||||||
netbird version | grep "$version"
|
netbird version | grep "$version"
|
||||||
|
|
||||||
echo "FreeBSD port test completed successfully!"
|
echo "FreeBSD port test completed successfully!"
|
||||||
|
|
||||||
- name: Upload FreeBSD port files
|
- name: Upload FreeBSD port files
|
||||||
if: steps.check_diff.outputs.diff_exists == 'true'
|
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: freebsd-port-files
|
name: freebsd-port-files
|
||||||
path: |
|
path: |
|
||||||
@@ -124,26 +133,25 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
flags: ""
|
flags: ""
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Parse semver string
|
- name: Parse semver string
|
||||||
id: semver_parser
|
id: semver_parser
|
||||||
uses: booxmedialtd/ws-action-parse-semver@v1
|
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
|
||||||
input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
|
|
||||||
version_extractor_regex: '\/v(.*)$'
|
|
||||||
|
|
||||||
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -156,18 +164,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 QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
- name: Log in to the GitHub container registry
|
- name: Log in to the GitHub container registry
|
||||||
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
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -186,12 +194,12 @@ 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@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.GORELEASER_VER }}
|
version: ${{ env.GORELEASER_VER }}
|
||||||
args: release --clean ${{ env.flags }}
|
args: release --clean ${{ env.flags }}
|
||||||
@@ -282,28 +290,28 @@ jobs:
|
|||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
id: upload_release
|
id: upload_release
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: dist/
|
path: dist/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: upload linux packages
|
- name: upload linux packages
|
||||||
id: upload_linux_packages
|
id: upload_linux_packages
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: linux-packages
|
name: linux-packages
|
||||||
path: dist/netbird_linux**
|
path: dist/netbird_linux**
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: upload windows packages
|
- name: upload windows packages
|
||||||
id: upload_windows_packages
|
id: upload_windows_packages
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: windows-packages
|
name: windows-packages
|
||||||
path: dist/netbird_windows**
|
path: dist/netbird_windows**
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: upload macos packages
|
- name: upload macos packages
|
||||||
id: upload_macos_packages
|
id: upload_macos_packages
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: macos-packages
|
name: macos-packages
|
||||||
path: dist/netbird_darwin**
|
path: dist/netbird_darwin**
|
||||||
@@ -314,27 +322,26 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }}
|
release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Parse semver string
|
- name: Parse semver string
|
||||||
id: semver_parser
|
id: semver_parser
|
||||||
uses: booxmedialtd/ws-action-parse-semver@v1
|
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
|
||||||
input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
|
|
||||||
version_extractor_regex: '\/v(.*)$'
|
|
||||||
|
|
||||||
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -349,8 +356,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,13 +386,19 @@ 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@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.GORELEASER_VER }}
|
version: ${{ env.GORELEASER_VER }}
|
||||||
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
|
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
|
||||||
@@ -404,7 +427,7 @@ jobs:
|
|||||||
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
id: upload_release_ui
|
id: upload_release_ui
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: release-ui
|
name: release-ui
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -418,16 +441,17 @@ jobs:
|
|||||||
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -439,9 +463,23 @@ 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@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.GORELEASER_VER }}
|
version: ${{ env.GORELEASER_VER }}
|
||||||
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
|
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
|
||||||
@@ -449,7 +487,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
id: upload_release_ui_darwin
|
id: upload_release_ui_darwin
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: release-ui-darwin
|
name: release-ui-darwin
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -474,27 +512,26 @@ jobs:
|
|||||||
PackageWorkdir: netbird_windows_${{ matrix.arch }}
|
PackageWorkdir: netbird_windows_${{ matrix.arch }}
|
||||||
downloadPath: '${{ github.workspace }}\temp'
|
downloadPath: '${{ github.workspace }}\temp'
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Parse semver string
|
- name: Parse semver string
|
||||||
id: semver_parser
|
id: semver_parser
|
||||||
uses: booxmedialtd/ws-action-parse-semver@v1
|
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
|
||||||
input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
|
|
||||||
version_extractor_regex: '\/v(.*)$'
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Add 7-Zip to PATH
|
- name: Add 7-Zip to PATH
|
||||||
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
|
||||||
- name: Download release artifacts
|
- name: Download release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: release
|
path: release
|
||||||
|
|
||||||
- name: Download UI release artifacts
|
- name: Download UI release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: release-ui
|
name: release-ui
|
||||||
path: release-ui
|
path: release-ui
|
||||||
@@ -514,68 +551,74 @@ jobs:
|
|||||||
Get-ChildItem $workdir
|
Get-ChildItem $workdir
|
||||||
|
|
||||||
- name: Download wintun
|
- name: Download wintun
|
||||||
uses: carlosperate/download-file-action@v2
|
|
||||||
id: download-wintun
|
id: download-wintun
|
||||||
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||||
file-name: wintun.zip
|
destination: ${{ env.downloadPath }}\wintun.zip
|
||||||
location: ${{ env.downloadPath }}
|
sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
|
||||||
sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
|
|
||||||
|
|
||||||
- name: Decompress wintun files
|
- name: Decompress wintun files
|
||||||
run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
run: tar -xvf "${{ env.downloadPath }}\wintun.zip" -C ${{ env.downloadPath }}
|
||||||
|
|
||||||
- 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: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
file-url: https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip
|
url: https://pkgs.netbird.io/nsis/EnVar_plugin.zip
|
||||||
file-name: envar_plugin.zip
|
destination: ${{ github.workspace }}\envar_plugin.zip
|
||||||
location: ${{ github.workspace }}
|
sha256: e9aa92de351345ed82795251d838f1ae9041ba35af9d381a5780c7843b01f56a
|
||||||
|
|
||||||
- name: Extract EnVar plugin
|
- name: Extract EnVar plugin
|
||||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip"
|
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip"
|
||||||
|
|
||||||
- name: Download ShellExecAsUser plugin for NSIS (amd64 only)
|
- name: Download ShellExecAsUser plugin for NSIS (amd64 only)
|
||||||
uses: carlosperate/download-file-action@v2
|
|
||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
file-url: https://nsis.sourceforge.io/mediawiki/images/6/68/ShellExecAsUser_amd64-Unicode.7z
|
url: https://pkgs.netbird.io/nsis/ShellExecAsUser_amd64-Unicode.7z
|
||||||
file-name: ShellExecAsUser_amd64-Unicode.7z
|
destination: ${{ github.workspace }}\ShellExecAsUser_amd64-Unicode.7z
|
||||||
location: ${{ github.workspace }}
|
sha256: 0a55ea25c7330a92cec028eda8afcaf1b1a7092e0dfb77c21c8f654564b4ff9d
|
||||||
|
|
||||||
- name: Extract ShellExecAsUser plugin (amd64 only)
|
- name: Extract ShellExecAsUser plugin (amd64 only)
|
||||||
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: Build NSIS installer
|
- name: Set up Go for wails3 CLI
|
||||||
uses: joncloud/makensis-action@v3.3
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
additional-plugin-paths: ${{ github.workspace }}/NSIS_Plugins/Plugins
|
go-version-file: "go.mod"
|
||||||
script-file: client/installer.nsis
|
cache: false
|
||||||
arguments: "/V4 /DARCH=${{ matrix.arch }}"
|
|
||||||
|
- name: Install wails3 CLI
|
||||||
|
# Version derived from go.mod so the bootstrapper payload always
|
||||||
|
# matches the wails runtime the binary links against.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||||
|
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||||
|
|
||||||
|
- name: Stage WebView2 bootstrapper for installers
|
||||||
|
# Both client/installer.nsis and client/netbird.wxs reference
|
||||||
|
# client/MicrosoftEdgeWebview2Setup.exe. wails3 writes it there.
|
||||||
|
# The signing pipeline (netbirdio/sign-pipelines) does the same
|
||||||
|
# step for release builds; this mirrors it for PR sanity testing.
|
||||||
|
shell: bash
|
||||||
|
run: wails3 generate webview2bootstrapper -dir client
|
||||||
|
|
||||||
|
- name: Build NSIS installer
|
||||||
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
APPVER: ${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}.${{ steps.semver_parser.outputs.patch }}.${{ github.run_id }}
|
APPVER: ${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}.${{ steps.semver_parser.outputs.patch }}.${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
$nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode"
|
||||||
|
$srcPlugins = "${{ github.workspace }}\NSIS_Plugins\Plugins"
|
||||||
|
Get-ChildItem -Path $srcPlugins -Recurse -Filter *.dll |
|
||||||
|
Copy-Item -Destination $nsisPluginDir -Force
|
||||||
|
& "C:\Program Files (x86)\NSIS\makensis.exe" /V4 "/DARCH=${{ matrix.arch }}" client\installer.nsis
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" }
|
||||||
|
|
||||||
- name: Rename NSIS installer
|
- name: Rename NSIS installer
|
||||||
run: mv netbird-installer.exe netbird_installer_test_windows_${{ matrix.arch }}.exe
|
run: mv netbird-installer.exe netbird_installer_test_windows_${{ matrix.arch }}.exe
|
||||||
@@ -592,7 +635,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload installer artifacts
|
- name: Upload installer artifacts
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: windows-installer-test-${{ matrix.arch }}
|
name: windows-installer-test-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
@@ -611,7 +654,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Create or update PR comment
|
- name: Create or update PR comment
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
RELEASE_RESULT: ${{ needs.release.result }}
|
RELEASE_RESULT: ${{ needs.release.result }}
|
||||||
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
||||||
@@ -703,7 +746,7 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger binaries sign pipelines
|
- name: Trigger binaries sign pipelines
|
||||||
uses: benc-uk/workflow-dispatch@v1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: Sign bin and installer
|
workflow: Sign bin and installer
|
||||||
repo: netbirdio/sign-pipelines
|
repo: netbirdio/sign-pipelines
|
||||||
|
|||||||
4
.github/workflows/sync-main.yml
vendored
@@ -14,9 +14,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger main branch sync
|
- name: Trigger main branch sync
|
||||||
uses: benc-uk/workflow-dispatch@v1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: sync-main.yml
|
workflow: sync-main.yml
|
||||||
repo: ${{ secrets.UPSTREAM_REPO }}
|
repo: ${{ secrets.UPSTREAM_REPO }}
|
||||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
inputs: '{ "sha": "${{ github.sha }}" }'
|
inputs: '{ "sha": "${{ github.sha }}" }'
|
||||||
|
|||||||
10
.github/workflows/sync-tag.yml
vendored
@@ -3,7 +3,7 @@ name: sync tag
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger release tag sync
|
- name: Trigger release tag sync
|
||||||
uses: benc-uk/workflow-dispatch@v1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: sync-tag.yml
|
workflow: sync-tag.yml
|
||||||
ref: main
|
ref: main
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger android-client submodule bump
|
- name: Trigger android-client submodule bump
|
||||||
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: bump-netbird.yml
|
workflow: bump-netbird.yml
|
||||||
ref: main
|
ref: main
|
||||||
@@ -42,10 +42,10 @@ jobs:
|
|||||||
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger ios-client submodule bump
|
- name: Trigger ios-client submodule bump
|
||||||
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: bump-netbird.yml
|
workflow: bump-netbird.yml
|
||||||
ref: main
|
ref: main
|
||||||
repo: netbirdio/ios-client
|
repo: netbirdio/ios-client
|
||||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||||
|
|||||||
26
.github/workflows/test-infrastructure-files.yml
vendored
@@ -6,10 +6,10 @@ on:
|
|||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'infrastructure_files/**'
|
- "infrastructure_files/**"
|
||||||
- '.github/workflows/test-infrastructure-files.yml'
|
- ".github/workflows/test-infrastructure-files.yml"
|
||||||
- 'management/cmd/**'
|
- "management/cmd/**"
|
||||||
- 'signal/cmd/**'
|
- "signal/cmd/**"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
store: [ 'sqlite', 'postgres', 'mysql' ]
|
store: ["sqlite", "postgres", "mysql"]
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
||||||
@@ -68,15 +68,17 @@ jobs:
|
|||||||
run: sudo apt-get install -y curl
|
run: sudo apt-get install -y curl
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -139,8 +141,8 @@ jobs:
|
|||||||
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
|
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
|
||||||
CI_NETBIRD_SIGNAL_PORT: 12345
|
CI_NETBIRD_SIGNAL_PORT: 12345
|
||||||
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
||||||
NETBIRD_STORE_ENGINE_POSTGRES_DSN: '${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$'
|
NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
|
||||||
NETBIRD_STORE_ENGINE_MYSQL_DSN: '${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$'
|
NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
|
||||||
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
||||||
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
|
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
|
||||||
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
|
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
|
||||||
@@ -254,7 +256,9 @@ jobs:
|
|||||||
run: sudo apt-get install -y jq
|
run: sudo apt-get install -y jq
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: run script with Zitadel PostgreSQL
|
- name: run script with Zitadel PostgreSQL
|
||||||
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
|
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
|
||||||
|
|||||||
8
.github/workflows/update-docs.yml
vendored
@@ -3,9 +3,9 @@ name: update docs
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
paths:
|
paths:
|
||||||
- 'shared/management/http/api/openapi.yml'
|
- "shared/management/http/api/openapi.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
trigger_docs_api_update:
|
trigger_docs_api_update:
|
||||||
@@ -13,10 +13,10 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger API pages generation
|
- name: Trigger API pages generation
|
||||||
uses: benc-uk/workflow-dispatch@v1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: generate api pages
|
workflow: generate api pages
|
||||||
repo: netbirdio/docs
|
repo: netbirdio/docs
|
||||||
ref: "refs/heads/main"
|
ref: "refs/heads/main"
|
||||||
token: ${{ secrets.SIGN_GITHUB_TOKEN }}
|
token: ${{ secrets.SIGN_GITHUB_TOKEN }}
|
||||||
inputs: '{ "tag": "${{ github.ref }}" }'
|
inputs: '{ "tag": "${{ github.ref }}" }'
|
||||||
|
|||||||
17
.github/workflows/wasm-build-validation.yml
vendored
@@ -19,15 +19,17 @@ jobs:
|
|||||||
GOARCH: wasm
|
GOARCH: wasm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
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@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
install-mode: binary
|
install-mode: binary
|
||||||
@@ -42,9 +44,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Build Wasm client
|
- name: Build Wasm client
|
||||||
@@ -65,4 +69,3 @@ jobs:
|
|||||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ linters:
|
|||||||
- linters:
|
- linters:
|
||||||
- staticcheck
|
- staticcheck
|
||||||
text: "QF1012"
|
text: "QF1012"
|
||||||
|
# client/ui/main.go uses //go:embed all:frontend/dist; the
|
||||||
|
# directory is populated by `pnpm build` in the release pipeline
|
||||||
|
# and missing at lint time, so the embed parses to "no matching
|
||||||
|
# files found" — surfaced by golangci-lint's typecheck pre-pass.
|
||||||
|
# Suppress just that one diagnostic; the rest of the package
|
||||||
|
# (services/, tray.go, grpc.go, ...) still gets linted normally.
|
||||||
|
- linters:
|
||||||
|
- typecheck
|
||||||
|
path: client/ui/main\.go
|
||||||
|
text: "pattern all:frontend/dist"
|
||||||
paths:
|
paths:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
project_name: netbird-ui
|
project_name: netbird-ui
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
# Bindings are gitignored; regenerate before the frontend build so
|
||||||
|
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
||||||
|
# build without them).
|
||||||
|
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts'
|
||||||
|
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: netbird-ui
|
- id: netbird-ui
|
||||||
dir: client/ui
|
dir: client/ui
|
||||||
@@ -70,12 +79,15 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/netbird.desktop
|
- src: client/ui/build/linux/netbird.desktop
|
||||||
dst: /usr/share/applications/netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/assets/netbird.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
|
- libgtk-3-0
|
||||||
|
- libwebkit2gtk-4.1-0
|
||||||
|
- libayatana-appindicator3-1
|
||||||
|
|
||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
@@ -89,12 +101,15 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/netbird.desktop
|
- src: client/ui/build/linux/netbird.desktop
|
||||||
dst: /usr/share/applications/netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/assets/netbird.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
|
- gtk3
|
||||||
|
- webkit2gtk4.1
|
||||||
|
- libayatana-appindicator-gtk3
|
||||||
rpm:
|
rpm:
|
||||||
signature:
|
signature:
|
||||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
project_name: netbird-ui
|
project_name: netbird-ui
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
# Bindings are gitignored; regenerate before the frontend build so
|
||||||
|
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
||||||
|
# build without them).
|
||||||
|
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts'
|
||||||
|
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: netbird-ui-darwin
|
- id: netbird-ui-darwin
|
||||||
dir: client/ui
|
dir: client/ui
|
||||||
@@ -20,8 +29,6 @@ builds:
|
|||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
tags:
|
|
||||||
- load_wgnt_from_rsrc
|
|
||||||
|
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
- id: netbird-ui-darwin
|
- id: netbird-ui-darwin
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
"github.com/netbirdio/management-integrations/integrations"
|
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||||
|
|
||||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
iv, _ := integrations.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
|
iv, _ := validator.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
|
||||||
|
|
||||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||||
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
@@ -84,6 +85,12 @@ type Options struct {
|
|||||||
DisableIPv6 bool
|
DisableIPv6 bool
|
||||||
// BlockInbound blocks all inbound connections from peers
|
// BlockInbound blocks all inbound connections from peers
|
||||||
BlockInbound bool
|
BlockInbound bool
|
||||||
|
// BlockLANAccess blocks the embedded peer from reaching the host's
|
||||||
|
// LAN (RFC 1918, link-local, loopback) when it's used as a routing
|
||||||
|
// peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
|
||||||
|
// when the embedded client must never act as a stepping stone into
|
||||||
|
// the host's local network (e.g. the proxy's overlay peer).
|
||||||
|
BlockLANAccess bool
|
||||||
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
||||||
WireguardPort *int
|
WireguardPort *int
|
||||||
// MTU is the MTU for the tunnel interface.
|
// MTU is the MTU for the tunnel interface.
|
||||||
@@ -94,6 +101,26 @@ type Options struct {
|
|||||||
MTU *uint16
|
MTU *uint16
|
||||||
// DNSLabels defines additional DNS labels configured in the peer.
|
// DNSLabels defines additional DNS labels configured in the peer.
|
||||||
DNSLabels []string
|
DNSLabels []string
|
||||||
|
// Performance configures the tunnel's buffer pool cap and batch size.
|
||||||
|
Performance Performance
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance configures the embedded client's tunnel memory/throughput knobs.
|
||||||
|
//
|
||||||
|
// These settings are process-global: any non-nil field also becomes the
|
||||||
|
// default for Clients constructed by later embed.New calls in the same
|
||||||
|
// process. Nil fields are ignored.
|
||||||
|
type Performance struct {
|
||||||
|
// PreallocatedBuffersPerPool caps the per-tunnel buffer pool. Zero
|
||||||
|
// leaves the pool unbounded. Lower values trade throughput for a
|
||||||
|
// tighter memory ceiling. May also be changed on a running Client via
|
||||||
|
// Client.SetPerformance, provided this field was nonzero at construction.
|
||||||
|
PreallocatedBuffersPerPool *uint32
|
||||||
|
// MaxBatchSize overrides the number of packets the tunnel reads or
|
||||||
|
// writes per syscall, which also bounds eager buffer allocation per
|
||||||
|
// worker. Zero uses the platform default. Applied at construction
|
||||||
|
// only; ignored by Client.SetPerformance.
|
||||||
|
MaxBatchSize *uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCredentials checks that exactly one credential type is provided
|
// validateCredentials checks that exactly one credential type is provided
|
||||||
@@ -175,6 +202,7 @@ func New(opts Options) (*Client, error) {
|
|||||||
DisableClientRoutes: &opts.DisableClientRoutes,
|
DisableClientRoutes: &opts.DisableClientRoutes,
|
||||||
DisableIPv6: &opts.DisableIPv6,
|
DisableIPv6: &opts.DisableIPv6,
|
||||||
BlockInbound: &opts.BlockInbound,
|
BlockInbound: &opts.BlockInbound,
|
||||||
|
BlockLANAccess: &opts.BlockLANAccess,
|
||||||
WireguardPort: opts.WireguardPort,
|
WireguardPort: opts.WireguardPort,
|
||||||
MTU: opts.MTU,
|
MTU: opts.MTU,
|
||||||
DNSLabels: parsedLabels,
|
DNSLabels: parsedLabels,
|
||||||
@@ -192,6 +220,13 @@ func New(opts Options) (*Client, error) {
|
|||||||
config.PrivateKey = opts.PrivateKey
|
config.PrivateKey = opts.PrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.Performance.PreallocatedBuffersPerPool != nil {
|
||||||
|
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
|
||||||
|
}
|
||||||
|
if opts.Performance.MaxBatchSize != nil {
|
||||||
|
wgdevice.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize)
|
||||||
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
deviceName: opts.DeviceName,
|
deviceName: opts.DeviceName,
|
||||||
setupKey: opts.SetupKey,
|
setupKey: opts.SetupKey,
|
||||||
@@ -405,6 +440,21 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IdentityForIP looks up a remote peer by its tunnel IP using the
|
||||||
|
// embedded client's status recorder. Returns the peer's WireGuard public
|
||||||
|
// key and FQDN. ok=false means the IP isn't in this client's peer
|
||||||
|
// roster — callers should treat that as "unknown peer".
|
||||||
|
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
|
||||||
|
if !ip.IsValid() || c.recorder == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
state, found := c.recorder.PeerStateByIP(ip.String())
|
||||||
|
if !found {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return state.PubKey, state.FQDN, true
|
||||||
|
}
|
||||||
|
|
||||||
// Status returns the current status of the client.
|
// Status returns the current status of the client.
|
||||||
func (c *Client) Status() (peer.FullStatus, error) {
|
func (c *Client) Status() (peer.FullStatus, error) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -473,6 +523,25 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
|
|||||||
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
|
||||||
|
// takes effect, and only when it was nonzero at construction;
|
||||||
|
// MaxBatchSize is construction-only and returns an error if set here.
|
||||||
|
//
|
||||||
|
// Returns ErrClientNotStarted / ErrEngineNotStarted if the Client is not
|
||||||
|
// running yet.
|
||||||
|
func (c *Client) SetPerformance(t Performance) error {
|
||||||
|
if t.MaxBatchSize != nil {
|
||||||
|
return errors.New("MaxBatchSize is construction-only and cannot be changed at runtime")
|
||||||
|
}
|
||||||
|
engine, err := c.getEngine()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return engine.SetPerformance(internal.Performance{
|
||||||
|
PreallocatedBuffersPerPool: t.PreallocatedBuffersPerPool,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// StartCapture begins capturing packets on this client's tunnel device.
|
// StartCapture begins capturing packets on this client's tunnel device.
|
||||||
// Only one capture can be active at a time; starting a new one stops the previous.
|
// Only one capture can be active at a time; starting a new one stops the previous.
|
||||||
// Call StopCapture (or CaptureSession.Stop) to end it.
|
// Call StopCapture (or CaptureSession.Stop) to end it.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -260,23 +260,15 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
|
|||||||
|
|
||||||
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
||||||
|
|
||||||
; Drop Run, App Paths and Uninstall entries left in the 32-bit registry view
|
; Create autostart registry entry based on checkbox
|
||||||
; or HKCU by legacy installers.
|
|
||||||
DetailPrint "Cleaning legacy 32-bit / HKCU entries..."
|
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
|
||||||
SetRegView 32
|
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
|
||||||
DeleteRegKey HKLM "${REG_APP_PATH}"
|
|
||||||
DeleteRegKey HKLM "${UI_REG_APP_PATH}"
|
|
||||||
DeleteRegKey HKLM "${UNINSTALL_PATH}"
|
|
||||||
SetRegView 64
|
|
||||||
|
|
||||||
DetailPrint "Autostart enabled: $AutostartEnabled"
|
DetailPrint "Autostart enabled: $AutostartEnabled"
|
||||||
${If} $AutostartEnabled == "1"
|
${If} $AutostartEnabled == "1"
|
||||||
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
||||||
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
||||||
${Else}
|
${Else}
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||||
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
DetailPrint "Autostart not enabled by user"
|
DetailPrint "Autostart not enabled by user"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
@@ -288,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'
|
||||||
@@ -307,16 +336,11 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
|
|||||||
DetailPrint "Terminating Netbird UI process..."
|
DetailPrint "Terminating Netbird UI process..."
|
||||||
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
||||||
|
|
||||||
; Remove autostart entries from every view a previous installer may have used.
|
; Remove autostart registry entry
|
||||||
DetailPrint "Removing autostart registry entry if exists..."
|
DetailPrint "Removing autostart registry entry if exists..."
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
SetRegView 32
|
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
|
||||||
DeleteRegKey HKLM "${REG_APP_PATH}"
|
|
||||||
DeleteRegKey HKLM "${UI_REG_APP_PATH}"
|
|
||||||
DeleteRegKey HKLM "${UNINSTALL_PATH}"
|
|
||||||
SetRegView 64
|
|
||||||
|
|
||||||
; Handle data deletion based on checkbox
|
; Handle data deletion based on checkbox
|
||||||
DetailPrint "Checking if user requested data deletion..."
|
DetailPrint "Checking if user requested data deletion..."
|
||||||
@@ -339,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
|
||||||
|
}
|
||||||
@@ -360,7 +360,13 @@ func isRedirectURLPortUsed(redirectURL string, excludedRanges []excludedPortRang
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%s", port)
|
// FreeBSD 15 disables connecting to INADDR_ANY (0.0.0.0) as a localhost
|
||||||
|
// alias by default, ensure explicit ip for localhost.
|
||||||
|
host := parsedURL.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(host, port)
|
||||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
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) {
|
||||||
|
|||||||
@@ -339,8 +339,7 @@ func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool {
|
|||||||
case entry.Pattern == ".":
|
case entry.Pattern == ".":
|
||||||
return true
|
return true
|
||||||
case entry.IsWildcard:
|
case entry.IsWildcard:
|
||||||
parts := strings.Split(strings.TrimSuffix(qname, entry.Pattern), ".")
|
return strings.HasSuffix(qname, "."+entry.Pattern)
|
||||||
return len(parts) >= 2 && strings.HasSuffix(qname, entry.Pattern)
|
|
||||||
default:
|
default:
|
||||||
// For non-wildcard patterns:
|
// For non-wildcard patterns:
|
||||||
// If handler wants subdomain matching, allow suffix match
|
// If handler wants subdomain matching, allow suffix match
|
||||||
|
|||||||
@@ -164,6 +164,54 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) {
|
|||||||
matchSubdomains: true,
|
matchSubdomains: true,
|
||||||
shouldMatch: true,
|
shouldMatch: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard label-boundary mismatch (suffix overlap)",
|
||||||
|
handlerDomain: "*.b.test.",
|
||||||
|
queryDomain: "x.ab.test.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard label-boundary match",
|
||||||
|
handlerDomain: "*.b.test.",
|
||||||
|
queryDomain: "x.b.test.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard multi-label match",
|
||||||
|
handlerDomain: "*.b.test.",
|
||||||
|
queryDomain: "x.y.b.test.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard no match on multi-label apex",
|
||||||
|
handlerDomain: "*.b.test.",
|
||||||
|
queryDomain: "b.test.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard no match on unrelated suffix containment",
|
||||||
|
handlerDomain: "*.example.com.",
|
||||||
|
queryDomain: "notexample.com.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard accepts pattern registered without trailing dot",
|
||||||
|
handlerDomain: "*.b.test",
|
||||||
|
queryDomain: "x.b.test.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -273,6 +321,19 @@ func TestHandlerChain_ServeDNS_OverlappingDomains(t *testing.T) {
|
|||||||
expectedCalls: 1,
|
expectedCalls: 1,
|
||||||
expectedHandler: 2, // highest priority matching handler should be called
|
expectedHandler: 2, // highest priority matching handler should be called
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "overlapping wildcard suffixes route to correct handler",
|
||||||
|
handlers: []struct {
|
||||||
|
pattern string
|
||||||
|
priority int
|
||||||
|
}{
|
||||||
|
{pattern: "*.b.test.", priority: nbdns.PriorityDNSRoute},
|
||||||
|
{pattern: "*.ab.test.", priority: nbdns.PriorityDNSRoute},
|
||||||
|
},
|
||||||
|
queryDomain: "app.ab.test.",
|
||||||
|
expectedCalls: 1,
|
||||||
|
expectedHandler: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "root zone with specific domain",
|
name: "root zone with specific domain",
|
||||||
handlers: []struct {
|
handlers: []struct {
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ type resolver interface {
|
|||||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PeerConnectivity reports whether a tunnel IP belongs to a peer the
|
||||||
|
// client knows about and whether that peer is currently connected. The
|
||||||
|
// local resolver uses this to suppress A/AAAA answers whose RDATA points
|
||||||
|
// at a disconnected peer (typical case: a synthesized private-service
|
||||||
|
// record pointing at an embedded proxy peer that just went offline).
|
||||||
|
//
|
||||||
|
// known=false means the IP isn't in the local peerstore at all — the
|
||||||
|
// record is left alone (it points at something outside our mesh, e.g.
|
||||||
|
// a non-peer upstream).
|
||||||
|
type PeerConnectivity interface {
|
||||||
|
IsConnectedByIP(ip string) (known, connected bool)
|
||||||
|
}
|
||||||
|
|
||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
records map[dns.Question][]dns.RR
|
records map[dns.Question][]dns.RR
|
||||||
@@ -33,6 +46,11 @@ type Resolver struct {
|
|||||||
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
|
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
|
||||||
zones map[domain.Domain]bool
|
zones map[domain.Domain]bool
|
||||||
resolver resolver
|
resolver resolver
|
||||||
|
// peerConn, when non-nil, is consulted on every A/AAAA answer to
|
||||||
|
// drop records pointing at disconnected peers. nil disables the
|
||||||
|
// filter and preserves the legacy "return whatever is registered"
|
||||||
|
// behaviour for callers that never wire a status source.
|
||||||
|
peerConn PeerConnectivity
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -49,6 +67,15 @@ func NewResolver() *Resolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPeerConnectivity wires the per-IP connectivity check used to filter
|
||||||
|
// out A/AAAA answers pointing at disconnected peers. Pass nil to disable.
|
||||||
|
// Safe to call multiple times; the latest value wins.
|
||||||
|
func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.peerConn = p
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Resolver) MatchSubdomains() bool {
|
func (d *Resolver) MatchSubdomains() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -95,6 +122,7 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
|||||||
replyMessage.RecursionAvailable = true
|
replyMessage.RecursionAvailable = true
|
||||||
|
|
||||||
result := d.lookupRecords(logger, question)
|
result := d.lookupRecords(logger, question)
|
||||||
|
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
|
||||||
replyMessage.Authoritative = !result.hasExternalData
|
replyMessage.Authoritative = !result.hasExternalData
|
||||||
replyMessage.Answer = result.records
|
replyMessage.Answer = result.records
|
||||||
replyMessage.Rcode = d.determineRcode(question, result)
|
replyMessage.Rcode = d.determineRcode(question, result)
|
||||||
@@ -436,6 +464,78 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterDisconnectedPeerAnswers drops A/AAAA records whose RDATA matches
|
||||||
|
// a known but disconnected peer. The synthesized private-service zones
|
||||||
|
// emit one A record per connected proxy peer in a cluster; when a peer
|
||||||
|
// goes offline, the server-side refresh removes the record from the
|
||||||
|
// next netmap, but the client may still hold the previous netmap for a
|
||||||
|
// short window. This filter is the local belt to that braces — even on
|
||||||
|
// the stale netmap, the resolver hides the offline target.
|
||||||
|
//
|
||||||
|
// Records pointing at unknown IPs (outside the local peerstore, e.g.
|
||||||
|
// non-mesh upstreams) are never dropped. Non-A/AAAA records pass
|
||||||
|
// through untouched.
|
||||||
|
//
|
||||||
|
// Escape hatch: if filtering would leave the answer empty AND at least
|
||||||
|
// one record was filtered, the original list is returned. Better to
|
||||||
|
// hand the client a record that may not respond than NXDOMAIN it
|
||||||
|
// completely when every proxy peer is offline (the upstream may still
|
||||||
|
// be reachable some other way, or the peerstore may be stale).
|
||||||
|
func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
d.mu.RLock()
|
||||||
|
checker := d.peerConn
|
||||||
|
d.mu.RUnlock()
|
||||||
|
if checker == nil {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
kept := make([]dns.RR, 0, len(records))
|
||||||
|
var dropped int
|
||||||
|
for _, rr := range records {
|
||||||
|
ip := extractRecordIP(rr)
|
||||||
|
if ip == "" {
|
||||||
|
kept = append(kept, rr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
known, connected := checker.IsConnectedByIP(ip)
|
||||||
|
if known && !connected {
|
||||||
|
dropped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, rr)
|
||||||
|
}
|
||||||
|
if dropped == 0 {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
logger.Debugf("all %d answers for %s point at disconnected peers; returning the original list", dropped, question.Name)
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
logger.Tracef("dropped %d disconnected-peer answer(s) for %s, returning %d", dropped, question.Name, len(kept))
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
|
||||||
|
// an A or AAAA record, or "" for any other record type.
|
||||||
|
func extractRecordIP(rr dns.RR) string {
|
||||||
|
switch r := rr.(type) {
|
||||||
|
case *dns.A:
|
||||||
|
if r.A == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.A.String()
|
||||||
|
case *dns.AAAA:
|
||||||
|
if r.AAAA == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.AAAA.String()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Update replaces all zones and their records
|
// Update replaces all zones and their records
|
||||||
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
|
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockPeerConnectivity returns canned (known, connected) results per IP.
|
||||||
|
// Used by the disconnected-peer filter tests below. IPs not in the map
|
||||||
|
// are reported as unknown so the filter leaves them alone.
|
||||||
|
type mockPeerConnectivity struct {
|
||||||
|
byIP map[string]struct{ known, connected bool }
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||||
|
v, ok := m.byIP[ip]
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return v.known, v.connected
|
||||||
|
}
|
||||||
|
|
||||||
func TestLocalResolver_ServeDNS(t *testing.T) {
|
func TestLocalResolver_ServeDNS(t *testing.T) {
|
||||||
recordA := nbdns.SimpleRecord{
|
recordA := nbdns.SimpleRecord{
|
||||||
Name: "peera.netbird.cloud.",
|
Name: "peera.netbird.cloud.",
|
||||||
@@ -2652,3 +2667,114 @@ func BenchmarkIsInManagedZone_ManyZones(b *testing.B) {
|
|||||||
resolver.isInManagedZone(qname)
|
resolver.isInManagedZone(qname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLocalResolver_FilterDisconnectedPeerAnswers verifies the
|
||||||
|
// connectivity-aware filtering layered on top of lookupRecords:
|
||||||
|
// when an A record's IP belongs to a known peer that's disconnected,
|
||||||
|
// the record is dropped from the answer. Records for unknown IPs pass
|
||||||
|
// through. If filtering would empty the answer entirely and at least
|
||||||
|
// one record was dropped, the original list is restored (escape hatch
|
||||||
|
// for the "all proxies offline" case).
|
||||||
|
func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
|
||||||
|
zone := "svc.cluster.netbird."
|
||||||
|
connectedRec := nbdns.SimpleRecord{
|
||||||
|
Name: zone,
|
||||||
|
Type: int(dns.TypeA),
|
||||||
|
Class: nbdns.DefaultClass,
|
||||||
|
TTL: 5,
|
||||||
|
RData: "100.64.0.10",
|
||||||
|
}
|
||||||
|
disconnectedRec := nbdns.SimpleRecord{
|
||||||
|
Name: zone,
|
||||||
|
Type: int(dns.TypeA),
|
||||||
|
Class: nbdns.DefaultClass,
|
||||||
|
TTL: 5,
|
||||||
|
RData: "100.64.0.11",
|
||||||
|
}
|
||||||
|
unknownRec := nbdns.SimpleRecord{
|
||||||
|
Name: zone,
|
||||||
|
Type: int(dns.TypeA),
|
||||||
|
Class: nbdns.DefaultClass,
|
||||||
|
TTL: 5,
|
||||||
|
RData: "203.0.113.5",
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipState struct{ known, connected bool }
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
records []nbdns.SimpleRecord
|
||||||
|
connByIP map[string]ipState
|
||||||
|
wantInOrder []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "drops disconnected peer, keeps connected",
|
||||||
|
records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
|
||||||
|
connByIP: map[string]ipState{
|
||||||
|
"100.64.0.10": {known: true, connected: true},
|
||||||
|
"100.64.0.11": {known: true, connected: false},
|
||||||
|
},
|
||||||
|
wantInOrder: []string{"100.64.0.10"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown IPs pass through untouched",
|
||||||
|
records: []nbdns.SimpleRecord{unknownRec, disconnectedRec},
|
||||||
|
connByIP: map[string]ipState{
|
||||||
|
"100.64.0.11": {known: true, connected: false},
|
||||||
|
},
|
||||||
|
wantInOrder: []string{"203.0.113.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all disconnected falls back to original list",
|
||||||
|
records: []nbdns.SimpleRecord{disconnectedRec, connectedRec},
|
||||||
|
connByIP: map[string]ipState{
|
||||||
|
"100.64.0.10": {known: true, connected: false},
|
||||||
|
"100.64.0.11": {known: true, connected: false},
|
||||||
|
},
|
||||||
|
wantInOrder: []string{"100.64.0.11", "100.64.0.10"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no checker wired returns all records",
|
||||||
|
records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
|
||||||
|
connByIP: nil,
|
||||||
|
wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
resolver := NewResolver()
|
||||||
|
if tc.connByIP != nil {
|
||||||
|
cm := mockPeerConnectivity{byIP: make(map[string]struct{ known, connected bool }, len(tc.connByIP))}
|
||||||
|
for ip, st := range tc.connByIP {
|
||||||
|
cm.byIP[ip] = struct{ known, connected bool }{st.known, st.connected}
|
||||||
|
}
|
||||||
|
resolver.SetPeerConnectivity(cm)
|
||||||
|
}
|
||||||
|
resolver.Update([]nbdns.CustomZone{{
|
||||||
|
Domain: strings.TrimSuffix(zone, "."),
|
||||||
|
Records: tc.records,
|
||||||
|
NonAuthoritative: true,
|
||||||
|
}})
|
||||||
|
|
||||||
|
var got *dns.Msg
|
||||||
|
writer := &test.MockResponseWriter{
|
||||||
|
WriteMsgFunc: func(m *dns.Msg) error {
|
||||||
|
got = m
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := new(dns.Msg).SetQuestion(zone, dns.TypeA)
|
||||||
|
resolver.ServeDNS(writer, req)
|
||||||
|
|
||||||
|
require.NotNil(t, got, "resolver must produce a response")
|
||||||
|
require.Len(t, got.Answer, len(tc.wantInOrder),
|
||||||
|
"answer count must match expected: %v", tc.wantInOrder)
|
||||||
|
for i, want := range tc.wantInOrder {
|
||||||
|
a, ok := got.Answer[i].(*dns.A)
|
||||||
|
require.True(t, ok, "answer[%d] must be an A record", i)
|
||||||
|
assert.Equal(t, want, a.A.String(),
|
||||||
|
"answer[%d] expected %s got %s", i, want, a.A.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -301,6 +301,11 @@ func newDefaultServer(
|
|||||||
warningDelayBase: defaultWarningDelayBase,
|
warningDelayBase: defaultWarningDelayBase,
|
||||||
healthRefresh: make(chan struct{}, 1),
|
healthRefresh: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
|
// Wire the local resolver against the peer status recorder so it can
|
||||||
|
// suppress A/AAAA answers that point at disconnected peers (typical
|
||||||
|
// case: synthesised private-service records pointing at an embedded
|
||||||
|
// proxy peer that just went offline).
|
||||||
|
defaultServer.localResolver.SetPeerConnectivity(localPeerConnectivity{statusRecorder})
|
||||||
|
|
||||||
// register with root zone, handler chain takes care of the routing
|
// register with root zone, handler chain takes care of the routing
|
||||||
dnsService.RegisterMux(".", handlerChain)
|
dnsService.RegisterMux(".", handlerChain)
|
||||||
@@ -1386,3 +1391,25 @@ func (s *DefaultServer) PopulateManagementDomain(mgmtURL *url.URL) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localPeerConnectivity adapts *peer.Status to local.PeerConnectivity so
|
||||||
|
// the local resolver can ask "is this IP a known peer and is it
|
||||||
|
// connected?" without taking on the peer package as a dependency.
|
||||||
|
// A nil status recorder always reports known=false so the resolver
|
||||||
|
// short-circuits to the legacy "return everything" path.
|
||||||
|
type localPeerConnectivity struct {
|
||||||
|
status *peer.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
|
||||||
|
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
|
||||||
|
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||||
|
if l.status == nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
state, ok := l.status.PeerStateByIP(ip)
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return true, state.ConnStatus == peer.StatusConnected
|
||||||
|
}
|
||||||
|
|||||||
@@ -250,6 +250,20 @@ type Engine struct {
|
|||||||
jobExecutorWG sync.WaitGroup
|
jobExecutorWG sync.WaitGroup
|
||||||
|
|
||||||
exposeManager *expose.Manager
|
exposeManager *expose.Manager
|
||||||
|
|
||||||
|
sessionWatcher sessionDeadlineWatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionDeadlineWatcher is the engine-facing surface of the SSO session
|
||||||
|
// expiry watcher. The concrete implementation (sessionwatch.Watcher) is wired
|
||||||
|
// in via newSessionWatcher, which is build-tagged so the js/wasm build links a
|
||||||
|
// no-op stub instead of pulling the full sessionwatch package (and its timer
|
||||||
|
// machinery) into the binary — the wasm client never runs the engine's
|
||||||
|
// session-warning flow.
|
||||||
|
type sessionDeadlineWatcher interface {
|
||||||
|
Update(deadline time.Time) error
|
||||||
|
Dismiss()
|
||||||
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer is an instance of the Connection Peer
|
// Peer is an instance of the Connection Peer
|
||||||
@@ -293,6 +307,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 = newSessionWatcher(engine.statusRecorder)
|
||||||
|
|
||||||
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
||||||
return engine
|
return engine
|
||||||
@@ -333,6 +358,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 +894,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)
|
||||||
}
|
}
|
||||||
@@ -1967,6 +1998,29 @@ func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
|
|||||||
return e.clientMetrics
|
return e.clientMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Performance bundles runtime-adjustable tunnel pool knobs.
|
||||||
|
// See Engine.SetPerformance. Nil fields are ignored.
|
||||||
|
type Performance struct {
|
||||||
|
PreallocatedBuffersPerPool *uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPerformance applies the given tuning to this engine's live Device.
|
||||||
|
func (e *Engine) SetPerformance(t Performance) error {
|
||||||
|
e.syncMsgMux.Lock()
|
||||||
|
defer e.syncMsgMux.Unlock()
|
||||||
|
if e.wgInterface == nil {
|
||||||
|
return fmt.Errorf("wg interface not initialized")
|
||||||
|
}
|
||||||
|
dev := e.wgInterface.GetWGDevice()
|
||||||
|
if dev == nil {
|
||||||
|
return fmt.Errorf("wg device not initialized")
|
||||||
|
}
|
||||||
|
if t.PreallocatedBuffersPerPool != nil {
|
||||||
|
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
||||||
iface, err := net.InterfaceByName(ifaceName)
|
iface, err := net.InterfaceByName(ifaceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
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")
|
||||||
|
})
|
||||||
|
}
|
||||||
16
client/internal/engine_sessionwatch.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !js
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newSessionWatcher returns the real SSO session expiry watcher for every
|
||||||
|
// non-wasm build. The js/wasm build gets a no-op stub from
|
||||||
|
// engine_sessionwatch_js.go so the sessionwatch package (and its timer
|
||||||
|
// machinery) never links into the wasm binary.
|
||||||
|
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
||||||
|
return sessionwatch.New(recorder)
|
||||||
|
}
|
||||||
39
client/internal/engine_sessionwatch_js.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//go:build js
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The
|
||||||
|
// wasm client never runs the engine's session-warning flow (the interactive
|
||||||
|
// T-WarningLead notification and the T-FinalWarningLead fallback dialog live
|
||||||
|
// in the desktop UI), so linking the full sessionwatch package (timers, event
|
||||||
|
// composition) would only bloat the binary.
|
||||||
|
//
|
||||||
|
// It still mirrors the deadline into the status recorder so the SubscribeStatus
|
||||||
|
// / Status snapshot the UI consumes stays correct — only the timer-driven
|
||||||
|
// warnings are dropped.
|
||||||
|
type noopSessionWatcher struct {
|
||||||
|
recorder *peer.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
||||||
|
return noopSessionWatcher{recorder: recorder}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mirrors the real watcher's recorder propagation without the timers or
|
||||||
|
// sanity-check sentinels: a valid deadline is exposed on the status snapshot,
|
||||||
|
// the zero time clears it.
|
||||||
|
func (w noopSessionWatcher) Update(deadline time.Time) error {
|
||||||
|
if w.recorder != nil {
|
||||||
|
w.recorder.SetSessionExpiresAt(deadline)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (noopSessionWatcher) Dismiss() {}
|
||||||
|
func (noopSessionWatcher) Close() {}
|
||||||
@@ -27,7 +27,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
"github.com/netbirdio/netbird/management/server/job"
|
"github.com/netbirdio/netbird/management/server/job"
|
||||||
|
|
||||||
"github.com/netbirdio/management-integrations/integrations"
|
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||||
@@ -66,8 +66,8 @@ import (
|
|||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
||||||
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
|
||||||
"github.com/netbirdio/netbird/shared/netiputil"
|
"github.com/netbirdio/netbird/shared/netiputil"
|
||||||
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||||
signal "github.com/netbirdio/netbird/shared/signal/client"
|
signal "github.com/netbirdio/netbird/shared/signal/client"
|
||||||
"github.com/netbirdio/netbird/shared/signal/proto"
|
"github.com/netbirdio/netbird/shared/signal/proto"
|
||||||
signalServer "github.com/netbirdio/netbird/signal/server"
|
signalServer "github.com/netbirdio/netbird/signal/server"
|
||||||
@@ -1641,7 +1641,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
||||||
|
|
||||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
|||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
// handle route changes
|
// handle route changes
|
||||||
case unix.RTM_ADD, syscall.RTM_DELETE:
|
case unix.RTM_ADD, syscall.RTM_DELETE:
|
||||||
route, err := parseRouteMessage(buf[:n])
|
route, flags, err := parseRouteMessage(buf[:n])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("Network monitor: error parsing routing message: %v", err)
|
log.Debugf("Network monitor: error parsing routing message: %v", err)
|
||||||
continue
|
continue
|
||||||
@@ -66,6 +66,10 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
|||||||
}
|
}
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case unix.RTM_ADD:
|
case unix.RTM_ADD:
|
||||||
|
if systemops.IgnoreAddedDefaultRoute(flags) {
|
||||||
|
log.Debugf("Network monitor: ignoring added default route via %s, interface %s, flags %#x", route.Gw, intf, flags)
|
||||||
|
continue
|
||||||
|
}
|
||||||
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
|
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
|
||||||
return nil
|
return nil
|
||||||
case unix.RTM_DELETE:
|
case unix.RTM_DELETE:
|
||||||
@@ -78,22 +82,26 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRouteMessage(buf []byte) (*systemops.Route, error) {
|
func parseRouteMessage(buf []byte) (*systemops.Route, int, error) {
|
||||||
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
|
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse RIB: %v", err)
|
return nil, 0, fmt.Errorf("parse RIB: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(msgs) != 1 {
|
if len(msgs) != 1 {
|
||||||
return nil, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
|
return nil, 0, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, ok := msgs[0].(*route.RouteMessage)
|
msg, ok := msgs[0].(*route.RouteMessage)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
|
return nil, 0, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return systemops.MsgToRoute(msg)
|
r, err := systemops.MsgToRoute(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return r, msg.Flags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitReadable blocks until fd has data to read, or ctx is cancelled.
|
// waitReadable blocks until fd has data to read, or ctx is cancelled.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -185,23 +186,32 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
return s.eventsChan
|
return s.eventsChan
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status holds a state of peers, signal, management connections and relays
|
// Status holds a state of peers, signal, management connections and relays.
|
||||||
|
// mux is an RWMutex so hot read paths (notably PeerStateByIP, called for
|
||||||
|
// every private-service request) don't contend against each other.
|
||||||
|
// Pure read methods take RLock; anything that mutates state takes Lock.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
mux sync.Mutex
|
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
|
||||||
@@ -217,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
|
||||||
@@ -230,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,
|
||||||
@@ -283,8 +309,8 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
|
|||||||
|
|
||||||
// GetPeer adds peer to Daemon status map
|
// GetPeer adds peer to Daemon status map
|
||||||
func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
state, ok := d.peers[peerPubKey]
|
state, ok := d.peers[peerPubKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -294,8 +320,8 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) PeerByIP(ip string) (string, bool) {
|
func (d *Status) PeerByIP(ip string) (string, bool) {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
for _, state := range d.peers {
|
for _, state := range d.peers {
|
||||||
if state.IP == ip {
|
if state.IP == ip {
|
||||||
@@ -305,6 +331,25 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PeerStateByIP returns the full peer State for the given tunnel IP.
|
||||||
|
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
||||||
|
// address so dual-stack peers are reachable on either family. Returns the
|
||||||
|
// zero State and false when no peer matches or the input is empty.
|
||||||
|
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||||
|
if ip == "" {
|
||||||
|
return State{}, false
|
||||||
|
}
|
||||||
|
d.mux.RLock()
|
||||||
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
|
for _, state := range d.peers {
|
||||||
|
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
|
||||||
|
return state, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return State{}, false
|
||||||
|
}
|
||||||
|
|
||||||
// RemovePeer removes peer from Daemon status map
|
// RemovePeer removes peer from Daemon status map
|
||||||
func (d *Status) RemovePeer(peerPubKey string) error {
|
func (d *Status) RemovePeer(peerPubKey string) error {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
@@ -360,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,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 {
|
||||||
@@ -702,8 +755,8 @@ func (d *Status) UnsubscribePeerStateChanges(subscription *StatusChangeSubscript
|
|||||||
|
|
||||||
// GetLocalPeerState returns the local peer state
|
// GetLocalPeerState returns the local peer state
|
||||||
func (d *Status) GetLocalPeerState() LocalPeerState {
|
func (d *Status) GetLocalPeerState() LocalPeerState {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return d.localPeer.Clone()
|
return d.localPeer.Clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,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
|
||||||
@@ -787,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
|
||||||
@@ -799,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
|
||||||
@@ -811,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
|
||||||
@@ -851,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
|
||||||
@@ -863,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) {
|
||||||
@@ -909,8 +1002,8 @@ func (d *Status) DeleteResolvedDomainsStates(domain domain.Domain) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetRosenpassState() RosenpassState {
|
func (d *Status) GetRosenpassState() RosenpassState {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return RosenpassState{
|
return RosenpassState{
|
||||||
d.rosenpassEnabled,
|
d.rosenpassEnabled,
|
||||||
d.rosenpassPermissive,
|
d.rosenpassPermissive,
|
||||||
@@ -918,14 +1011,14 @@ func (d *Status) GetRosenpassState() RosenpassState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetLazyConnection() bool {
|
func (d *Status) GetLazyConnection() bool {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return d.lazyConnectionEnabled
|
return d.lazyConnectionEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetManagementState() ManagementState {
|
func (d *Status) GetManagementState() ManagementState {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return ManagementState{
|
return ManagementState{
|
||||||
d.mgmAddress,
|
d.mgmAddress,
|
||||||
d.managementState,
|
d.managementState,
|
||||||
@@ -951,8 +1044,8 @@ func (d *Status) UpdateLatency(pubKey string, latency time.Duration) error {
|
|||||||
|
|
||||||
// IsLoginRequired determines if a peer's login has expired.
|
// IsLoginRequired determines if a peer's login has expired.
|
||||||
func (d *Status) IsLoginRequired() bool {
|
func (d *Status) IsLoginRequired() bool {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
// if peer is connected to the management then login is not expired
|
// if peer is connected to the management then login is not expired
|
||||||
if d.managementState {
|
if d.managementState {
|
||||||
@@ -967,8 +1060,8 @@ func (d *Status) IsLoginRequired() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetSignalState() SignalState {
|
func (d *Status) GetSignalState() SignalState {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return SignalState{
|
return SignalState{
|
||||||
d.signalAddress,
|
d.signalAddress,
|
||||||
d.signalState,
|
d.signalState,
|
||||||
@@ -978,8 +1071,8 @@ func (d *Status) GetSignalState() SignalState {
|
|||||||
|
|
||||||
// GetRelayStates returns the stun/turn/permanent relay states
|
// GetRelayStates returns the stun/turn/permanent relay states
|
||||||
func (d *Status) GetRelayStates() []relay.ProbeResult {
|
func (d *Status) GetRelayStates() []relay.ProbeResult {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
if d.relayMgr == nil {
|
if d.relayMgr == nil {
|
||||||
return d.relayStates
|
return d.relayStates
|
||||||
}
|
}
|
||||||
@@ -1008,8 +1101,8 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
if d.ingressGwMgr == nil {
|
if d.ingressGwMgr == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1018,16 +1111,16 @@ func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetDNSStates() []NSGroupState {
|
func (d *Status) GetDNSStates() []NSGroupState {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
// shallow copy is good enough, as slices fields are currently not updated
|
// shallow copy is good enough, as slices fields are currently not updated
|
||||||
return slices.Clone(d.nsGroupStates)
|
return slices.Clone(d.nsGroupStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
|
func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return maps.Clone(d.resolvedDomainsStates)
|
return maps.Clone(d.resolvedDomainsStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,8 +1136,8 @@ func (d *Status) GetFullStatus() FullStatus {
|
|||||||
LazyConnectionEnabled: d.GetLazyConnection(),
|
LazyConnectionEnabled: d.GetLazyConnection(),
|
||||||
}
|
}
|
||||||
|
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
fullStatus.LocalPeerState = d.localPeer
|
fullStatus.LocalPeerState = d.localPeer
|
||||||
|
|
||||||
@@ -1060,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
|
||||||
@@ -1211,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()
|
||||||
@@ -1219,8 +1388,8 @@ func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) PeersStatus() (*configurer.Stats, error) {
|
func (d *Status) PeersStatus() (*configurer.Stats, error) {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
if d.wgIface == nil {
|
if d.wgIface == nil {
|
||||||
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
|
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,33 @@ func TestUpdatePeerState(t *testing.T) {
|
|||||||
assert.Equal(t, ip, state.IP, "ip should be equal")
|
assert.Equal(t, ip, state.IP, "ip should be equal")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatus_PeerStateByIP(t *testing.T) {
|
||||||
|
status := NewRecorder("https://mgm")
|
||||||
|
req := require.New(t)
|
||||||
|
|
||||||
|
req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", ""))
|
||||||
|
req.NoError(status.AddPeer("pk-2", "peer-2.netbird", "100.64.0.11", ""))
|
||||||
|
|
||||||
|
state, ok := status.PeerStateByIP("100.64.0.10")
|
||||||
|
req.True(ok, "known tunnel IP should resolve to a peer state")
|
||||||
|
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
||||||
|
req.Equal("peer-1.netbird", state.FQDN, "matching state must carry the right FQDN")
|
||||||
|
|
||||||
|
_, ok = status.PeerStateByIP("100.64.0.99")
|
||||||
|
req.False(ok, "unknown IP must report ok=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
|
||||||
|
status := NewRecorder("https://mgm")
|
||||||
|
req := require.New(t)
|
||||||
|
|
||||||
|
req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
|
||||||
|
|
||||||
|
state, ok := status.PeerStateByIP("fd00::1")
|
||||||
|
req.True(ok, "IPv6-only match must resolve to the peer state")
|
||||||
|
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
||||||
|
}
|
||||||
|
|
||||||
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
||||||
key := "abc"
|
key := "abc"
|
||||||
fqdn := "peer-a.netbird.local"
|
fqdn := "peer-a.netbird.local"
|
||||||
|
|||||||
@@ -179,8 +179,10 @@ func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dst := net.IPv4zero
|
dst := net.IPv4zero
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
||||||
// go-netroute v0.4.0 rejects unspecified destinations client-side on Linux.
|
// go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
|
||||||
|
// TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties /
|
||||||
|
// NWPathMonitor) when netlink-based lookup is restricted or unavailable.
|
||||||
dst = net.IPv4(0, 0, 0, 1)
|
dst = net.IPv4(0, 0, 0, 1)
|
||||||
}
|
}
|
||||||
_, gateway, localIP, err = router.Route(dst)
|
_, gateway, localIP, err = router.Route(dst)
|
||||||
@@ -203,7 +205,7 @@ func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dst := net.IPv6zero
|
dst := net.IPv6zero
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
||||||
// ::2
|
// ::2
|
||||||
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
|
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build dragonfly || freebsd || netbsd || openbsd
|
||||||
|
|
||||||
|
package systemops
|
||||||
|
|
||||||
|
// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
|
||||||
|
// given flags should be ignored by the network monitor.
|
||||||
|
func IgnoreAddedDefaultRoute(flags int) bool {
|
||||||
|
return filterRoutesByFlags(flags)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package systemops
|
||||||
|
|
||||||
|
import "golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
|
||||||
|
// given flags should be ignored by the network monitor. Scoped routes
|
||||||
|
// (RTF_IFSCOPE) are tied to a specific interface index and cannot replace the
|
||||||
|
// unscoped default the kernel uses for general egress, so flapping ones (e.g.
|
||||||
|
// Wi-Fi calling IMS tunnels on ipsec0, Docker bridges, scoped utun defaults)
|
||||||
|
// must not trigger an engine restart.
|
||||||
|
func IgnoreAddedDefaultRoute(flags int) bool {
|
||||||
|
if filterRoutesByFlags(flags) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if flags&unix.RTF_IFSCOPE != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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,15 +59,16 @@
|
|||||||
<Component Id="NetbirdAumidRegistry" Guid="*">
|
<Component Id="NetbirdAumidRegistry" Guid="*">
|
||||||
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
||||||
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
<!-- Pre-seed the CLSID the Wails notifications service reads on
|
||||||
|
first startup (notifications_windows.go:getGUID looks for
|
||||||
|
the CustomActivator value under this key). Without this
|
||||||
|
the service generates a fresh per-install UUID, which
|
||||||
|
diverges from the ToastActivatorCLSID set on the Start
|
||||||
|
Menu / Desktop shortcuts above and the COM activator
|
||||||
|
never fires when a toast is clicked. -->
|
||||||
|
<RegistryValue Name="CustomActivator" Type="string" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
|
||||||
</RegistryKey>
|
</RegistryKey>
|
||||||
</Component>
|
</Component>
|
||||||
<!-- Drop the HKCU Run\Netbird value written by legacy NSIS installers. -->
|
|
||||||
<Component Id="NetbirdLegacyHKCUCleanup" Guid="*">
|
|
||||||
<RegistryValue Root="HKCU" Key="Software\NetBird GmbH\Installer"
|
|
||||||
Name="LegacyHKCUCleanup" Type="integer" Value="1" KeyPath="yes" />
|
|
||||||
<RemoveRegistryValue Root="HKCU"
|
|
||||||
Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="Netbird" />
|
|
||||||
</Component>
|
|
||||||
</StandardDirectory>
|
</StandardDirectory>
|
||||||
|
|
||||||
<StandardDirectory Id="CommonAppDataFolder">
|
<StandardDirectory Id="CommonAppDataFolder">
|
||||||
@@ -83,37 +81,49 @@
|
|||||||
</Directory>
|
</Directory>
|
||||||
</StandardDirectory>
|
</StandardDirectory>
|
||||||
|
|
||||||
<!-- Drop Run, App Paths and Uninstall entries written by legacy NSIS
|
|
||||||
installers into the 32-bit registry view (HKLM\Software\Wow6432Node). -->
|
|
||||||
<Component Id="NetbirdLegacyWow6432Cleanup" Directory="NetbirdInstallDir"
|
|
||||||
Guid="bda5d628-16bd-4086-b2c1-5099d8d51763" Bitness="always32">
|
|
||||||
<RegistryValue Root="HKLM" Key="Software\NetBird GmbH\Installer"
|
|
||||||
Name="LegacyWow6432Cleanup" Type="integer" Value="1" KeyPath="yes" />
|
|
||||||
<RemoveRegistryValue Root="HKLM"
|
|
||||||
Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="Netbird" />
|
|
||||||
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
|
||||||
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\Netbird" />
|
|
||||||
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
|
||||||
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\Netbird-ui" />
|
|
||||||
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
|
||||||
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Netbird" />
|
|
||||||
</Component>
|
|
||||||
|
|
||||||
<ComponentGroup Id="NetbirdFilesComponent">
|
<ComponentGroup Id="NetbirdFilesComponent">
|
||||||
<ComponentRef Id="NetbirdFiles" />
|
<ComponentRef Id="NetbirdFiles" />
|
||||||
<ComponentRef Id="NetbirdAumidRegistry" />
|
<ComponentRef Id="NetbirdAumidRegistry" />
|
||||||
<ComponentRef Id="NetbirdAutoStart" />
|
<ComponentRef Id="NetbirdAutoStart" />
|
||||||
<ComponentRef Id="NetbirdLegacyHKCUCleanup" />
|
|
||||||
<ComponentRef Id="NetbirdLegacyWow6432Cleanup" />
|
|
||||||
</ComponentGroup>
|
</ComponentGroup>
|
||||||
|
|
||||||
<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()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
|
|
||||||
"github.com/netbirdio/management-integrations/integrations"
|
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||||
@@ -315,7 +315,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
|
ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
|
||||||
|
|
||||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
57
client/server/status_stream.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubscribeStatus pushes a fresh StatusResponse on every connection state
|
||||||
|
// change. The first message is the current snapshot, so a re-subscribing
|
||||||
|
// client doesn't need to also call Status. Subsequent messages fire when
|
||||||
|
// the peer recorder reports any of: connected/disconnected/connecting,
|
||||||
|
// management or signal flip, address change, or peers list change.
|
||||||
|
//
|
||||||
|
// The change channel coalesces bursts to a single tick. If the consumer
|
||||||
|
// is slow the daemon drops extras (not blocks), and the next snapshot
|
||||||
|
// the consumer pulls already reflects everything.
|
||||||
|
func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
|
||||||
|
subID, ch := s.statusRecorder.SubscribeToStateChanges()
|
||||||
|
defer func() {
|
||||||
|
s.statusRecorder.UnsubscribeFromStateChanges(subID)
|
||||||
|
log.Debug("client unsubscribed from status updates")
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Debug("client subscribed to status updates")
|
||||||
|
|
||||||
|
if err := s.sendStatusSnapshot(req, stream); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case _, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.sendStatusSnapshot(req, stream); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case <-stream.Context().Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
|
||||||
|
resp, err := s.buildStatusResponse(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("build status snapshot for stream: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := stream.Send(resp); err != nil {
|
||||||
|
log.Warnf("send status snapshot to stream: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
// This file is intentionally named test.go (not test_test.go) so the exported
|
||||||
|
// StartTestServer helper is visible to the ssh/proxy and ssh/client external
|
||||||
|
// test packages, not just this package's own tests. The //go:build !js tag
|
||||||
|
// keeps its "testing" import — and the whole testing/flag/regexp transitive
|
||||||
|
// chain it drags in — out of the wasm client, which links ssh/server through
|
||||||
|
// the engine but never runs Go tests under GOOS=js.
|
||||||
|
//go:build !js
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
package system
|
package system
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/zcalusic/sysinfo"
|
"github.com/zcalusic/sysinfo"
|
||||||
|
|
||||||
@@ -29,19 +28,11 @@ func UpdateStaticInfoAsync() {
|
|||||||
|
|
||||||
// GetInfo retrieves and parses the system information
|
// GetInfo retrieves and parses the system information
|
||||||
func GetInfo(ctx context.Context) *Info {
|
func GetInfo(ctx context.Context) *Info {
|
||||||
info := _getInfo()
|
kernelName, kernelVersion, kernelPlatform := kernelInfo()
|
||||||
for strings.Contains(info, "broken pipe") {
|
|
||||||
info = _getInfo()
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
osStr := strings.ReplaceAll(info, "\n", "")
|
|
||||||
osStr = strings.ReplaceAll(osStr, "\r\n", "")
|
|
||||||
osInfo := strings.Split(osStr, " ")
|
|
||||||
|
|
||||||
osName, osVersion := readOsReleaseFile()
|
osName, osVersion := readOsReleaseFile()
|
||||||
if osName == "" {
|
if osName == "" {
|
||||||
osName = osInfo[3]
|
osName = kernelName
|
||||||
}
|
}
|
||||||
|
|
||||||
systemHostname, _ := os.Hostname()
|
systemHostname, _ := os.Hostname()
|
||||||
@@ -58,8 +49,8 @@ func GetInfo(ctx context.Context) *Info {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gio := &Info{
|
gio := &Info{
|
||||||
Kernel: osInfo[0],
|
Kernel: kernelName,
|
||||||
Platform: osInfo[2],
|
Platform: kernelPlatform,
|
||||||
OS: osName,
|
OS: osName,
|
||||||
OSVersion: osVersion,
|
OSVersion: osVersion,
|
||||||
Hostname: extractDeviceName(ctx, systemHostname),
|
Hostname: extractDeviceName(ctx, systemHostname),
|
||||||
@@ -67,7 +58,7 @@ func GetInfo(ctx context.Context) *Info {
|
|||||||
CPUs: runtime.NumCPU(),
|
CPUs: runtime.NumCPU(),
|
||||||
NetbirdVersion: version.NetbirdVersion(),
|
NetbirdVersion: version.NetbirdVersion(),
|
||||||
UIVersion: extractUserAgent(ctx),
|
UIVersion: extractUserAgent(ctx),
|
||||||
KernelVersion: osInfo[1],
|
KernelVersion: kernelVersion,
|
||||||
NetworkAddresses: addrs,
|
NetworkAddresses: addrs,
|
||||||
SystemSerialNumber: si.SystemSerialNumber,
|
SystemSerialNumber: si.SystemSerialNumber,
|
||||||
SystemProductName: si.SystemProductName,
|
SystemProductName: si.SystemProductName,
|
||||||
@@ -78,18 +69,12 @@ func GetInfo(ctx context.Context) *Info {
|
|||||||
return gio
|
return gio
|
||||||
}
|
}
|
||||||
|
|
||||||
func _getInfo() string {
|
func kernelInfo() (string, string, string) {
|
||||||
cmd := exec.Command("uname", "-srio")
|
var uts unix.Utsname
|
||||||
cmd.Stdin = strings.NewReader("some")
|
if err := unix.Uname(&uts); err != nil {
|
||||||
var out bytes.Buffer
|
return "", "", ""
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &out
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("getInfo: %s", err)
|
|
||||||
}
|
}
|
||||||
return out.String()
|
return unix.ByteSliceToString(uts.Sysname[:]), unix.ByteSliceToString(uts.Release[:]), unix.ByteSliceToString(uts.Machine[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func sysInfo() (string, string, string) {
|
func sysInfo() (string, string, string) {
|
||||||
|
|||||||
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 |