Compare commits
1 Commits
ui-refacto
...
feature/io
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ea0882975 |
45
.github/dependabot.yml
vendored
@@ -1,45 +0,0 @@
|
|||||||
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*"
|
|
||||||
105
.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,10 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- uses: actions/checkout@v4
|
||||||
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: |
|
||||||
@@ -59,57 +56,55 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
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 ""
|
||||||
|
|
||||||
# Check all Go packages for copyleft licenses, excluding internal netbird packages
|
# Filter out dependencies that are only pulled in by internal AGPL packages
|
||||||
COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
|
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")
|
||||||
|
|
||||||
if [ -n "$COPYLEFT_DEPS" ]; then
|
# Check if any importer is NOT in management/signal/relay
|
||||||
echo "Found copyleft licensed dependencies:"
|
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
||||||
echo "$COPYLEFT_DEPS"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Filter out dependencies that are only pulled in by internal AGPL packages
|
if [ -n "$BSD_IMPORTER" ]; then
|
||||||
INCOMPATIBLE=""
|
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
||||||
while IFS=',' read -r package url license; do
|
INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
|
||||||
if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
|
else
|
||||||
# Find ALL packages that import this GPL package using go list
|
echo "✓ $package ($license) is only used by internal AGPL packages - OK"
|
||||||
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
|
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
|
||||||
fi
|
done <<< "$COPYLEFT_DEPS"
|
||||||
|
|
||||||
echo "✅ All external license dependencies are compatible with BSD-3-Clause"
|
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@v7
|
||||||
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,10 +8,11 @@ jobs:
|
|||||||
post:
|
post:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
|
- uses: roots/discourse-topic-github-release-action@main
|
||||||
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: releases
|
discourse-tags:
|
||||||
|
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,9 +15,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@v4
|
||||||
with:
|
- uses: git-town/action@v1.2.1
|
||||||
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,18 +16,16 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: macos-gotest-${{ hashFiles('**/go.sum') }}
|
key: macos-gotest-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -45,13 +43,5 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
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)
|
||||||
# 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,31 +15,20 @@ jobs:
|
|||||||
name: "Client / Unit"
|
name: "Client / Unit"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- uses: actions/checkout@v4
|
||||||
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
|
||||||
env:
|
uses: vmactions/freebsd-vm@v1
|
||||||
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: "14.2"
|
||||||
envs: "GO_VERSION"
|
|
||||||
prepare: |
|
prepare: |
|
||||||
pkg install -y curl pkgconf xorg
|
pkg install -y curl pkgconf xorg
|
||||||
GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
|
GO_TARBALL="go1.25.3.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,11 +18,9 @@ jobs:
|
|||||||
management: ${{ steps.filter.outputs.management }}
|
management: ${{ steps.filter.outputs.management }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
- uses: dorny/paths-filter@v3
|
||||||
id: filter
|
id: filter
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
@@ -30,7 +28,7 @@ jobs:
|
|||||||
- 'management/**'
|
- 'management/**'
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -38,10 +36,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@v4
|
||||||
id: cache
|
id: cache
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -53,7 +51,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||||
|
|
||||||
- name: Install 32-bit libpcap
|
- name: Install 32-bit libpcap
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
@@ -115,16 +113,14 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -132,10 +128,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -145,7 +141,7 @@ jobs:
|
|||||||
${{ runner.os }}-gotest-cache-
|
${{ runner.os }}-gotest-cache-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev
|
||||||
|
|
||||||
- name: Install 32-bit libpcap
|
- name: Install 32-bit libpcap
|
||||||
if: matrix.arch == '386'
|
if: matrix.arch == '386'
|
||||||
@@ -158,28 +154,18 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
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)
|
||||||
# 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -191,7 +177,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@v4
|
||||||
id: cache-restore
|
id: cache-restore
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -228,7 +214,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 -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server)
|
go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server)
|
||||||
'
|
'
|
||||||
|
|
||||||
test_relay:
|
test_relay:
|
||||||
@@ -245,12 +231,10 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -262,10 +246,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -293,16 +277,14 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -316,7 +298,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -342,16 +324,14 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -363,10 +343,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -390,21 +370,19 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -412,10 +390,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -432,7 +410,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@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -449,7 +427,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 \
|
||||||
@@ -459,13 +437,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
|
||||||
@@ -496,12 +474,10 @@ jobs:
|
|||||||
prom/prometheus
|
prom/prometheus
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -509,10 +485,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -529,7 +505,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@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -553,13 +529,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
|
||||||
@@ -590,12 +566,10 @@ jobs:
|
|||||||
prom/prometheus
|
prom/prometheus
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -603,10 +577,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -623,7 +597,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@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -649,22 +623,20 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -672,10 +644,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
|
|||||||
28
.github/workflows/golang-test-windows.yml
vendored
@@ -18,12 +18,10 @@ jobs:
|
|||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
id: go
|
id: go
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
@@ -35,7 +33,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -46,15 +44,16 @@ 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:
|
||||||
url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||||
destination: ${{ env.downloadPath }}\wintun.zip
|
file-name: wintun.zip
|
||||||
sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
|
location: ${{ env.downloadPath }}
|
||||||
|
sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
|
||||||
|
|
||||||
- name: Decompressing wintun files
|
- name: Decompressing wintun files
|
||||||
run: tar -xvf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
run: tar -zvxf "${{ 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\'
|
||||||
|
|
||||||
@@ -65,15 +64,8 @@ jobs:
|
|||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe 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 -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' }
|
$packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
|
||||||
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
$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,18 +15,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: codespell
|
- name: codespell
|
||||||
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
uses: codespell-project/actions-codespell@v2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
||||||
# Non-English UI translations trip codespell on real foreign words
|
skip: go.mod,go.sum,**/proxy/web/**
|
||||||
# (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
|
||||||
@@ -44,32 +38,21 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
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-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
- name: 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@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
|
|||||||
4
.github/workflows/install-script-test.yml
vendored
@@ -22,9 +22,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: run install script
|
- name: run install script
|
||||||
env:
|
env:
|
||||||
|
|||||||
18
.github/workflows/mobile-build-validation.yml
vendored
@@ -16,25 +16,23 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
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@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
cmdline-tools-version: 8512546
|
cmdline-tools-version: 8512546
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
|
uses: actions/setup-java@v4
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@v4
|
||||||
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
|
||||||
@@ -54,11 +52,9 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const title = context.payload.pull_request.title;
|
const title = context.payload.pull_request.title;
|
||||||
|
|||||||
2
.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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||||
|
|||||||
241
.github/workflows/release.yml
vendored
@@ -9,7 +9,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SIGN_PIPE_VER: "v0.1.5"
|
SIGN_PIPE_VER: "v0.1.4"
|
||||||
GORELEASER_VER: "v2.14.3"
|
GORELEASER_VER: "v2.14.3"
|
||||||
PRODUCT_NAME: "NetBird"
|
PRODUCT_NAME: "NetBird"
|
||||||
COPYRIGHT: "NetBird GmbH"
|
COPYRIGHT: "NetBird GmbH"
|
||||||
@@ -24,9 +24,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
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
|
||||||
@@ -53,26 +51,19 @@ 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'
|
||||||
env:
|
uses: vmactions/freebsd-vm@v1
|
||||||
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
|
pkg install -y git curl portlint go
|
||||||
|
|
||||||
# Install Go for building
|
# Install Go for building
|
||||||
GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
|
GO_TARBALL="go1.25.5.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"
|
||||||
@@ -102,19 +93,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: freebsd-port-files
|
name: freebsd-port-files
|
||||||
path: |
|
path: |
|
||||||
@@ -133,25 +124,26 @@ 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: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
uses: booxmedialtd/ws-action-parse-semver@v1
|
||||||
|
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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
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/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -164,18 +156,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@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
|
uses: docker/setup-buildx-action@v2
|
||||||
- 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@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@v1
|
||||||
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@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -194,12 +186,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/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
run: goversioninfo -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
uses: goreleaser/goreleaser-action@v4
|
||||||
with:
|
with:
|
||||||
version: ${{ env.GORELEASER_VER }}
|
version: ${{ env.GORELEASER_VER }}
|
||||||
args: release --clean ${{ env.flags }}
|
args: release --clean ${{ env.flags }}
|
||||||
@@ -290,28 +282,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
uses: actions/upload-artifact@v4
|
||||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
uses: actions/upload-artifact@v4
|
||||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
uses: actions/upload-artifact@v4
|
||||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-packages
|
name: macos-packages
|
||||||
path: dist/netbird_darwin**
|
path: dist/netbird_darwin**
|
||||||
@@ -322,26 +314,27 @@ 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: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
uses: booxmedialtd/ws-action-parse-semver@v1
|
||||||
|
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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -356,18 +349,8 @@ 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 libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev gcc-mingw-w64-x86-64
|
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
- name: Decode GPG signing key
|
- 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
|
||||||
@@ -386,19 +369,13 @@ 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/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso
|
run: goversioninfo -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
uses: goreleaser/goreleaser-action@v4
|
||||||
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 }}
|
||||||
@@ -427,7 +404,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-ui
|
name: release-ui
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -441,17 +418,16 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -463,23 +439,9 @@ 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@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
uses: goreleaser/goreleaser-action@v4
|
||||||
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 }}
|
||||||
@@ -487,7 +449,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-ui-darwin
|
name: release-ui-darwin
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -512,26 +474,27 @@ 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: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
uses: booxmedialtd/ws-action-parse-semver@v1
|
||||||
|
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@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: release
|
path: release
|
||||||
|
|
||||||
- name: Download UI release artifacts
|
- name: Download UI release artifacts
|
||||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-ui
|
name: release-ui
|
||||||
path: release-ui
|
path: release-ui
|
||||||
@@ -551,74 +514,68 @@ 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:
|
||||||
url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||||
destination: ${{ env.downloadPath }}\wintun.zip
|
file-name: wintun.zip
|
||||||
sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
|
location: ${{ env.downloadPath }}
|
||||||
|
sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
|
||||||
|
|
||||||
- name: Decompress wintun files
|
- name: Decompress wintun files
|
||||||
run: tar -xvf "${{ env.downloadPath }}\wintun.zip" -C ${{ env.downloadPath }}
|
run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -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 EnVar plugin for NSIS
|
- name: Download Mesa3D (amd64 only)
|
||||||
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
uses: carlosperate/download-file-action@v2
|
||||||
|
id: download-mesa3d
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
with:
|
with:
|
||||||
url: https://pkgs.netbird.io/nsis/EnVar_plugin.zip
|
file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z
|
||||||
destination: ${{ github.workspace }}\envar_plugin.zip
|
file-name: mesa3d.7z
|
||||||
sha256: e9aa92de351345ed82795251d838f1ae9041ba35af9d381a5780c7843b01f56a
|
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
|
||||||
|
uses: carlosperate/download-file-action@v2
|
||||||
|
with:
|
||||||
|
file-url: https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip
|
||||||
|
file-name: envar_plugin.zip
|
||||||
|
location: ${{ github.workspace }}
|
||||||
|
|
||||||
- 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:
|
||||||
url: https://pkgs.netbird.io/nsis/ShellExecAsUser_amd64-Unicode.7z
|
file-url: https://nsis.sourceforge.io/mediawiki/images/6/68/ShellExecAsUser_amd64-Unicode.7z
|
||||||
destination: ${{ github.workspace }}\ShellExecAsUser_amd64-Unicode.7z
|
file-name: ShellExecAsUser_amd64-Unicode.7z
|
||||||
sha256: 0a55ea25c7330a92cec028eda8afcaf1b1a7092e0dfb77c21c8f654564b4ff9d
|
location: ${{ github.workspace }}
|
||||||
|
|
||||||
- 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: Set up Go for wails3 CLI
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: "go.mod"
|
|
||||||
cache: false
|
|
||||||
|
|
||||||
- name: Install wails3 CLI
|
|
||||||
# Version derived from go.mod so the bootstrapper payload always
|
|
||||||
# matches the wails runtime the binary links against.
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
|
||||||
go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
|
||||||
|
|
||||||
- name: Stage WebView2 bootstrapper for installers
|
|
||||||
# Both client/installer.nsis and client/netbird.wxs reference
|
|
||||||
# client/MicrosoftEdgeWebview2Setup.exe. wails3 writes it there.
|
|
||||||
# The signing pipeline (netbirdio/sign-pipelines) does the same
|
|
||||||
# step for release builds; this mirrors it for PR sanity testing.
|
|
||||||
shell: bash
|
|
||||||
run: wails3 generate webview2bootstrapper -dir client
|
|
||||||
|
|
||||||
- name: Build NSIS installer
|
- name: Build NSIS installer
|
||||||
shell: pwsh
|
uses: joncloud/makensis-action@v3.3
|
||||||
|
with:
|
||||||
|
additional-plugin-paths: ${{ github.workspace }}/NSIS_Plugins/Plugins
|
||||||
|
script-file: client/installer.nsis
|
||||||
|
arguments: "/V4 /DARCH=${{ matrix.arch }}"
|
||||||
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
|
||||||
@@ -635,7 +592,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload installer artifacts
|
- name: Upload installer artifacts
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-installer-test-${{ matrix.arch }}
|
name: windows-installer-test-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
@@ -654,7 +611,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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@v7
|
||||||
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 }}
|
||||||
@@ -746,7 +703,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@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
uses: benc-uk/workflow-dispatch@v1
|
||||||
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@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
uses: benc-uk/workflow-dispatch@v1
|
||||||
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@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
uses: benc-uk/workflow-dispatch@v1
|
||||||
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@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
||||||
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@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
||||||
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,17 +68,15 @@ jobs:
|
|||||||
run: sudo apt-get install -y curl
|
run: sudo apt-get install -y curl
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -141,8 +139,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
|
||||||
@@ -256,9 +254,7 @@ jobs:
|
|||||||
run: sudo apt-get install -y jq
|
run: sudo apt-get install -y jq
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
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@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
uses: benc-uk/workflow-dispatch@v1
|
||||||
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,17 +19,15 @@ jobs:
|
|||||||
GOARCH: wasm
|
GOARCH: wasm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
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-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
- name: Install golangci-lint
|
- name: Install golangci-lint
|
||||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
install-mode: binary
|
install-mode: binary
|
||||||
@@ -44,11 +42,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Build Wasm client
|
- name: Build Wasm client
|
||||||
@@ -69,3 +65,4 @@ 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,16 +114,6 @@ 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,15 +1,6 @@
|
|||||||
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
|
||||||
@@ -79,15 +70,12 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/linux/netbird.desktop
|
- src: client/ui/build/netbird.desktop
|
||||||
dst: /usr/share/applications/netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/assets/netbird.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
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.
|
||||||
@@ -101,15 +89,12 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/linux/netbird.desktop
|
- src: client/ui/build/netbird.desktop
|
||||||
dst: /usr/share/applications/netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/assets/netbird.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
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,15 +1,6 @@
|
|||||||
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
|
||||||
@@ -29,6 +20,8 @@ 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,19 +22,11 @@ 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{
|
||||||
@@ -69,16 +61,6 @@ 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 {
|
||||||
@@ -168,65 +150,6 @@ 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,7 +6,6 @@ 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"
|
||||||
@@ -118,11 +117,6 @@ 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(),
|
||||||
@@ -133,7 +127,6 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
IPsFilter: ipsFilterMap,
|
IPsFilter: ipsFilterMap,
|
||||||
ConnectionTypeFilter: connectionTypeFilter,
|
ConnectionTypeFilter: connectionTypeFilter,
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
SessionExpiresAt: sessionExpiresAt,
|
|
||||||
})
|
})
|
||||||
var statusOutputString string
|
var statusOutputString string
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
!define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls."
|
!define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls."
|
||||||
!define INSTALLER_NAME "netbird-installer.exe"
|
!define INSTALLER_NAME "netbird-installer.exe"
|
||||||
!define MAIN_APP_EXE "Netbird"
|
!define MAIN_APP_EXE "Netbird"
|
||||||
!define ICON "ui\\build\\windows\\icon.ico"
|
!define ICON "ui\\assets\\netbird.ico"
|
||||||
!define BANNER "ui\\build\\banner.bmp"
|
!define BANNER "ui\\build\\banner.bmp"
|
||||||
!define LICENSE_DATA "..\\LICENSE"
|
!define LICENSE_DATA "..\\LICENSE"
|
||||||
|
|
||||||
@@ -280,43 +280,6 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
|||||||
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
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'
|
||||||
@@ -363,9 +326,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"
|
||||||
# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove
|
!if ${ARCH} == "amd64"
|
||||||
# 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,7 +3,6 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,25 +21,6 @@ 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 {
|
||||||
@@ -204,15 +184,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -503,16 +474,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIsPeerLoginExpired(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil",
|
|
||||||
err: nil,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plain error (not a gRPC status)",
|
|
||||||
err: errors.New("network read: connection reset"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PermissionDenied with different message",
|
|
||||||
err: status.Error(codes.PermissionDenied, "user is blocked"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Unauthenticated with the expected phrase",
|
|
||||||
// Wrong status code — must still return false.
|
|
||||||
err: status.Error(codes.Unauthenticated, "peer login has expired, please log in once more"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact server message",
|
|
||||||
err: status.Error(codes.PermissionDenied, "peer login has expired, please log in once more"),
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "phrase as substring",
|
|
||||||
// Future-proofing: if mgm reworords but keeps the phrase,
|
|
||||||
// the friendly fallback must still kick in.
|
|
||||||
err: status.Error(codes.PermissionDenied, "session refused: peer login has expired (account=foo)"),
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := isPeerLoginExpired(tc.err); got != tc.want {
|
|
||||||
t.Fatalf("isPeerLoginExpired(%v) = %v, want %v", tc.err, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrSetupKeyOnSSOExpiredPeer(t *testing.T) {
|
|
||||||
// Sentinel must surface as PermissionDenied so the upstream
|
|
||||||
// isPermissionDenied / isAuthError checks classify it correctly
|
|
||||||
// (short-circuit retry backoff, set StatusNeedsLogin).
|
|
||||||
if !isPermissionDenied(errSetupKeyOnSSOExpiredPeer) {
|
|
||||||
t.Fatalf("errSetupKeyOnSSOExpiredPeer must be a PermissionDenied gRPC error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message must actually mention SSO and `netbird up` so it is
|
|
||||||
// actionable for the end user. Loose substring checks keep the
|
|
||||||
// test resilient to copy edits.
|
|
||||||
s, _ := status.FromError(errSetupKeyOnSSOExpiredPeer)
|
|
||||||
msg := strings.ToLower(s.Message())
|
|
||||||
for _, want := range []string{"sso", "netbird up"} {
|
|
||||||
if !strings.Contains(msg, want) {
|
|
||||||
t.Errorf("sentinel message should contain %q, got %q", want, s.Message())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PendingFlow stores an in-progress OAuth flow between the RPC that
|
|
||||||
// initiates it (returns the verification URI to the UI) and the RPC
|
|
||||||
// that waits for the user to complete it. The flow handle, the
|
|
||||||
// device-code info, and the absolute expiry are kept together so the
|
|
||||||
// waiting RPC can validate the device code and reuse the same flow.
|
|
||||||
//
|
|
||||||
// PendingFlow is safe for concurrent use; callers must not access the
|
|
||||||
// stored fields directly.
|
|
||||||
type PendingFlow struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
flow OAuthFlow
|
|
||||||
info AuthFlowInfo
|
|
||||||
expiresAt time.Time
|
|
||||||
waitCancel context.CancelFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPendingFlow returns an empty PendingFlow ready to be populated by Set.
|
|
||||||
func NewPendingFlow() *PendingFlow {
|
|
||||||
return &PendingFlow{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set stores the flow and its authorization info, computing the absolute
|
|
||||||
// expiry from info.ExpiresIn (seconds, as returned by the IdP).
|
|
||||||
func (p *PendingFlow) Set(flow OAuthFlow, info AuthFlowInfo) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
p.flow = flow
|
|
||||||
p.info = info
|
|
||||||
p.expiresAt = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns the stored flow, info, and whether a flow is currently
|
|
||||||
// pending. Returns (nil, zero, false) after Clear or before Set.
|
|
||||||
func (p *PendingFlow) Get() (OAuthFlow, AuthFlowInfo, bool) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
if p.flow == nil {
|
|
||||||
return nil, AuthFlowInfo{}, false
|
|
||||||
}
|
|
||||||
return p.flow, p.info, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExpiresAt returns the absolute expiry of the pending flow. Returns
|
|
||||||
// the zero time when no flow is pending.
|
|
||||||
func (p *PendingFlow) ExpiresAt() time.Time {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
return p.expiresAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetWaitCancel records the cancel function for the goroutine currently
|
|
||||||
// blocked in WaitToken so a new RequestAuth can preempt it.
|
|
||||||
func (p *PendingFlow) SetWaitCancel(cancel context.CancelFunc) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
p.waitCancel = cancel
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelWait invokes and clears the stored wait-cancel, if any. Safe to
|
|
||||||
// call when no wait is in progress.
|
|
||||||
func (p *PendingFlow) CancelWait() {
|
|
||||||
p.mu.Lock()
|
|
||||||
cancel := p.waitCancel
|
|
||||||
p.waitCancel = nil
|
|
||||||
p.mu.Unlock()
|
|
||||||
if cancel != nil {
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear resets the pending flow to empty. Any stored wait-cancel is
|
|
||||||
// dropped without being invoked — call CancelWait first if the waiting
|
|
||||||
// goroutine must be stopped.
|
|
||||||
func (p *PendingFlow) Clear() {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
p.flow = nil
|
|
||||||
p.info = AuthFlowInfo{}
|
|
||||||
p.expiresAt = time.Time{}
|
|
||||||
p.waitCancel = nil
|
|
||||||
}
|
|
||||||
@@ -360,13 +360,7 @@ func isRedirectURLPortUsed(redirectURL string, excludedRanges []excludedPortRang
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// FreeBSD 15 disables connecting to INADDR_ANY (0.0.0.0) as a localhost
|
addr := fmt.Sprintf(":%s", port)
|
||||||
// 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
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package sessionwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// internal event kinds are no longer exposed: the watcher drives the Sink
|
|
||||||
// directly (NotifyStateChange on deadline change/clear, PublishEvent at
|
|
||||||
// each warning lead). Tests use a mock Sink to observe what the watcher
|
|
||||||
// emits.
|
|
||||||
|
|
||||||
// Metadata keys attached by the daemon to session-warning SystemEvents.
|
|
||||||
// The UI tray reads these to build a locale-aware notification without
|
|
||||||
// relying on the daemon's locale-less UserMessage string, and to
|
|
||||||
// disambiguate the T-WarningLead notification from the T-FinalWarningLead
|
|
||||||
// fallback that auto-opens the SessionAboutToExpire dialog.
|
|
||||||
const (
|
|
||||||
// MetaSessionWarning is set to "true" on both warning events (T-10 and
|
|
||||||
// T-2) so the UI can detect a session-warning SystemEvent without
|
|
||||||
// matching on the message text. Use MetaSessionFinal to distinguish
|
|
||||||
// the two.
|
|
||||||
MetaSessionWarning = "session_warning"
|
|
||||||
// MetaSessionFinal is set to "true" on the T-FinalWarningLead event
|
|
||||||
// only. Consumers that need to auto-open the SessionAboutToExpire
|
|
||||||
// dialog gate on this; T-WarningLead events leave the field unset.
|
|
||||||
MetaSessionFinal = "session_final_warning"
|
|
||||||
// MetaSessionExpiresAt carries the absolute UTC deadline encoded with
|
|
||||||
// FormatExpiresAt; consumers must decode with ParseExpiresAt so a
|
|
||||||
// future format change stays a single edit.
|
|
||||||
MetaSessionExpiresAt = "session_expires_at"
|
|
||||||
// MetaSessionLeadMinutes carries the lead in whole minutes (WarningLead
|
|
||||||
// for the T-10 event, FinalWarningLead for the T-2 event) so the UI
|
|
||||||
// can show "expires in ~N minutes" without hardcoding either constant.
|
|
||||||
MetaSessionLeadMinutes = "lead_minutes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// expiresAtLayout is the wire format used for MetaSessionExpiresAt.
|
|
||||||
// Producer and consumers both go through FormatExpiresAt/ParseExpiresAt
|
|
||||||
// so this layout stays a single source of truth.
|
|
||||||
const expiresAtLayout = time.RFC3339
|
|
||||||
|
|
||||||
// FormatExpiresAt encodes a deadline for MetaSessionExpiresAt. Always
|
|
||||||
// emits UTC so a consumer in another timezone reads the same wall-clock
|
|
||||||
// deadline.
|
|
||||||
func FormatExpiresAt(t time.Time) string {
|
|
||||||
return t.UTC().Format(expiresAtLayout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseExpiresAt decodes the MetaSessionExpiresAt value back to a UTC
|
|
||||||
// time. Returns an error when the field is empty or malformed; the
|
|
||||||
// caller decides whether to fall back (zero value) or propagate.
|
|
||||||
func ParseExpiresAt(s string) (time.Time, error) {
|
|
||||||
t, err := time.Parse(expiresAtLayout, s)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, err
|
|
||||||
}
|
|
||||||
return t.UTC(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatLeadMinutes encodes a lead duration for MetaSessionLeadMinutes
|
|
||||||
// as the integer count of whole minutes. Sub-minute residuals are
|
|
||||||
// truncated — the field is informational ("expires in ~N minutes") and
|
|
||||||
// fractional minutes don't change what the UI displays.
|
|
||||||
func FormatLeadMinutes(d time.Duration) string {
|
|
||||||
return strconv.Itoa(int(d / time.Minute))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseLeadMinutes decodes a MetaSessionLeadMinutes value. Returns 0
|
|
||||||
// and the parse error for malformed input; consumers that prefer a
|
|
||||||
// silent fallback can simply ignore the error.
|
|
||||||
func ParseLeadMinutes(s string) (int, error) {
|
|
||||||
return strconv.Atoi(s)
|
|
||||||
}
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
// Package sessionwatch tracks the SSO session expiry deadline that the
|
|
||||||
// management server publishes via LoginResponse / SyncResponse and fires
|
|
||||||
// two warning events at fixed lead times before expiry: an interactive
|
|
||||||
// T-WarningLead notification and a dismiss-gated T-FinalWarningLead
|
|
||||||
// fallback dialog.
|
|
||||||
//
|
|
||||||
// The watcher is idempotent: Update may be called as often as the network
|
|
||||||
// map snapshots arrive. Repeating the same deadline is a no-op; a new
|
|
||||||
// deadline reschedules the timers and arms a fresh warning cycle.
|
|
||||||
//
|
|
||||||
// Warning firing is edge-detected. Each unique deadline value fires each
|
|
||||||
// warning callback at most once.
|
|
||||||
package sessionwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Skew tolerates a small clock difference between the management
|
|
||||||
// server and this peer before treating a deadline as "in the past".
|
|
||||||
// Slightly above typical NTP drift; tight enough that the UI doesn't
|
|
||||||
// paint a stale expiry as if it were valid.
|
|
||||||
Skew = 30 * time.Second
|
|
||||||
|
|
||||||
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
|
||||||
// can sit. A timestamp beyond this is almost certainly a protocol
|
|
||||||
// glitch, and silently arming a 100-year timer would hide the bug.
|
|
||||||
maxDeadlineHorizon = 10 * 365 * 24 * time.Hour
|
|
||||||
|
|
||||||
// WarningLead is how far before expiry the first (interactive)
|
|
||||||
// warning fires. Drives the T-10 OS notification with
|
|
||||||
// Extend/Dismiss actions.
|
|
||||||
WarningLead = 10 * time.Minute
|
|
||||||
|
|
||||||
// FinalWarningLead is how far before expiry the fallback final
|
|
||||||
// warning fires. Drives the auto-opened SessionAboutToExpire dialog,
|
|
||||||
// but only when the user has not dismissed the T-WarningLead warning
|
|
||||||
// for the same deadline. Must be strictly less than WarningLead.
|
|
||||||
FinalWarningLead = 2 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ErrDeadlineBeforeEpoch is returned by Update when the supplied
|
|
||||||
// deadline pre-dates 1970-01-01.
|
|
||||||
ErrDeadlineBeforeEpoch = errors.New("session deadline before unix epoch")
|
|
||||||
|
|
||||||
// ErrDeadlineTooFarFuture is returned by Update when the supplied
|
|
||||||
// deadline is more than maxDeadlineHorizon in the future.
|
|
||||||
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
|
|
||||||
|
|
||||||
// ErrDeadlineInPast is returned by Update when the supplied deadline
|
|
||||||
// is more than Skew in the past.
|
|
||||||
ErrDeadlineInPast = errors.New("session deadline in the past")
|
|
||||||
)
|
|
||||||
|
|
||||||
// StatusRecorder is the side-effect surface the watcher drives on every
|
|
||||||
// state transition. Production wires this to peer.Status (SetSessionExpiresAt
|
|
||||||
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
|
||||||
// a fake recorder so the same surface is observable without an engine.
|
|
||||||
//
|
|
||||||
// The watcher is the single owner of the deadline propagated to the
|
|
||||||
// recorder: every set, clear, sanity-check rejection and Close routes the
|
|
||||||
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
|
|
||||||
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
|
|
||||||
// fans out its own state-change notification, so no separate notify is
|
|
||||||
// needed.) The recorder is server-scoped and outlives this engine-scoped
|
|
||||||
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
|
|
||||||
// a profile switch) would leave the next session showing the previous one's
|
|
||||||
// stale "expires in" value.
|
|
||||||
//
|
|
||||||
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
|
|
||||||
// composes the metadata internally so the wire format (MetaSession*) is
|
|
||||||
// owned by sessionwatch, not the caller.
|
|
||||||
type StatusRecorder interface {
|
|
||||||
SetSessionExpiresAt(deadline time.Time)
|
|
||||||
PublishEvent(
|
|
||||||
severity cProto.SystemEvent_Severity,
|
|
||||||
category cProto.SystemEvent_Category,
|
|
||||||
message string,
|
|
||||||
userMessage string,
|
|
||||||
metadata map[string]string,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watcher observes the latest session deadline and fires two warnings
|
|
||||||
// before it expires: the interactive T-WarningLead notification, and the
|
|
||||||
// fallback T-FinalWarningLead dialog (suppressed when the user dismissed
|
|
||||||
// the first one for the same deadline). Safe for concurrent use.
|
|
||||||
type Watcher struct {
|
|
||||||
lead time.Duration
|
|
||||||
finalLead time.Duration
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
current time.Time
|
|
||||||
timer *time.Timer
|
|
||||||
finalTimer *time.Timer
|
|
||||||
firedAt time.Time // deadline value the T-WarningLead callback last fired against
|
|
||||||
finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against
|
|
||||||
dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal
|
|
||||||
closed bool
|
|
||||||
recorder StatusRecorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// New returns a watcher with the package defaults WarningLead and
|
|
||||||
// FinalWarningLead. Pass nil for recorder to silence side effects (handy
|
|
||||||
// in unit tests that exercise sanity checks without observing the publish
|
|
||||||
// path).
|
|
||||||
func New(recorder StatusRecorder) *Watcher {
|
|
||||||
return NewWithLeads(WarningLead, FinalWarningLead, recorder)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWithLeads returns a watcher with custom lead times. Useful for tests.
|
|
||||||
// final must be strictly less than lead; otherwise both timers fire in the
|
|
||||||
// wrong order or simultaneously and the UI flow breaks. A zero final lead
|
|
||||||
// disables the final-warning timer entirely (see armTimerLocked) so a
|
|
||||||
// millisecond-scale deadline doesn't flush both timers in one tick.
|
|
||||||
func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
|
||||||
return &Watcher{
|
|
||||||
lead: lead,
|
|
||||||
finalLead: final,
|
|
||||||
recorder: recorder,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update sets the latest deadline. Pass the zero time to clear (e.g. when
|
|
||||||
// a Sync push from the server omits the field because login expiration
|
|
||||||
// was disabled).
|
|
||||||
//
|
|
||||||
// Same-value updates are no-ops. A different non-zero value cancels any
|
|
||||||
// pending timer, resets the "already fired" guard, and arms a new one.
|
|
||||||
//
|
|
||||||
// Returns one of the sentinel Err* values when the deadline fails the
|
|
||||||
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
|
|
||||||
// In every error case the watcher first clears its state so it stays
|
|
||||||
// consistent with what the caller will push into its other sinks (e.g.
|
|
||||||
// applySessionDeadline forces a zero deadline into the status recorder
|
|
||||||
// after a non-nil error).
|
|
||||||
func (w *Watcher) Update(deadline time.Time) error {
|
|
||||||
w.mu.Lock()
|
|
||||||
if w.closed {
|
|
||||||
w.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if deadline.IsZero() {
|
|
||||||
w.clearLocked()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
switch {
|
|
||||||
case deadline.Before(time.Unix(0, 0)):
|
|
||||||
w.clearLocked()
|
|
||||||
return fmt.Errorf("%w: %v", ErrDeadlineBeforeEpoch, deadline)
|
|
||||||
case deadline.After(now.Add(maxDeadlineHorizon)):
|
|
||||||
w.clearLocked()
|
|
||||||
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
|
|
||||||
case deadline.Before(now.Add(-Skew)):
|
|
||||||
w.clearLocked()
|
|
||||||
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
if deadline.Equal(w.current) {
|
|
||||||
w.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
w.stopTimerLocked()
|
|
||||||
w.current = deadline
|
|
||||||
// Reset every per-deadline guard so a refreshed deadline arms a fresh
|
|
||||||
// warning cycle: both edge triggers and the user Dismiss decision
|
|
||||||
// (the user agreed to the old deadline expiring; a new deadline
|
|
||||||
// restarts the contract).
|
|
||||||
w.firedAt = time.Time{}
|
|
||||||
w.finalFiredAt = time.Time{}
|
|
||||||
w.dismissedAt = time.Time{}
|
|
||||||
|
|
||||||
w.armTimerLocked(deadline)
|
|
||||||
recorder := w.recorder
|
|
||||||
w.mu.Unlock()
|
|
||||||
if recorder != nil {
|
|
||||||
recorder.SetSessionExpiresAt(deadline)
|
|
||||||
}
|
|
||||||
log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deadline returns the most recently observed deadline. Zero when no
|
|
||||||
// deadline is currently tracked.
|
|
||||||
func (w *Watcher) Deadline() time.Time {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
return w.current
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dismiss records the user's "Dismiss" action against the current deadline
|
|
||||||
// and suppresses the upcoming final-warning callback for that deadline.
|
|
||||||
// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh
|
|
||||||
// deadline resets the dismissal so the final-warning cycle re-arms.
|
|
||||||
//
|
|
||||||
// No-op when the watcher holds no deadline or has been closed.
|
|
||||||
func (w *Watcher) Dismiss() {
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
if w.closed || w.current.IsZero() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if w.dismissedAt.Equal(w.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.dismissedAt = w.current
|
|
||||||
// Cancel the armed final-warning timer eagerly. fireFinal would also
|
|
||||||
// gate on dismissedAt, but stopping the timer avoids a wakeup with
|
|
||||||
// nothing to do and makes the intent visible.
|
|
||||||
if w.finalTimer != nil {
|
|
||||||
w.finalTimer.Stop()
|
|
||||||
w.finalTimer = nil
|
|
||||||
}
|
|
||||||
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close stops any pending timer and drops the deadline on the status
|
|
||||||
// recorder. Update calls after Close are ignored. Clearing the recorder
|
|
||||||
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
|
|
||||||
// from leaving the next session showing this one's stale "expires in"
|
|
||||||
// value — the recorder is server-scoped and outlives this engine-scoped
|
|
||||||
// watcher, so nothing else drops the anchor on teardown.
|
|
||||||
func (w *Watcher) Close() {
|
|
||||||
w.mu.Lock()
|
|
||||||
if w.closed {
|
|
||||||
w.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.closed = true
|
|
||||||
w.stopTimerLocked()
|
|
||||||
hadDeadline := !w.current.IsZero()
|
|
||||||
w.current = time.Time{}
|
|
||||||
w.firedAt = time.Time{}
|
|
||||||
w.finalFiredAt = time.Time{}
|
|
||||||
w.dismissedAt = time.Time{}
|
|
||||||
recorder := w.recorder
|
|
||||||
w.mu.Unlock()
|
|
||||||
if recorder != nil && hadDeadline {
|
|
||||||
recorder.SetSessionExpiresAt(time.Time{})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearLocked drops the tracked deadline and notifies the recorder so
|
|
||||||
// downstream consumers (SubscribeStatus stream, UI) drop their anchor.
|
|
||||||
// The caller must hold w.mu; this helper releases it before invoking
|
|
||||||
// the recorder.
|
|
||||||
func (w *Watcher) clearLocked() {
|
|
||||||
if w.current.IsZero() {
|
|
||||||
w.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.stopTimerLocked()
|
|
||||||
w.current = time.Time{}
|
|
||||||
w.firedAt = time.Time{}
|
|
||||||
w.finalFiredAt = time.Time{}
|
|
||||||
w.dismissedAt = time.Time{}
|
|
||||||
recorder := w.recorder
|
|
||||||
w.mu.Unlock()
|
|
||||||
if recorder != nil {
|
|
||||||
recorder.SetSessionExpiresAt(time.Time{})
|
|
||||||
}
|
|
||||||
log.Infof("auth session deadline cleared")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Watcher) stopTimerLocked() {
|
|
||||||
if w.timer != nil {
|
|
||||||
w.timer.Stop()
|
|
||||||
w.timer = nil
|
|
||||||
}
|
|
||||||
if w.finalTimer != nil {
|
|
||||||
w.finalTimer.Stop()
|
|
||||||
w.finalTimer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Watcher) armTimerLocked(deadline time.Time) {
|
|
||||||
w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) })
|
|
||||||
// finalLead <= 0 disables the final-warning timer entirely. Used by
|
|
||||||
// tests that predate the final-warning fallback so a millisecond-scale
|
|
||||||
// deadline does not flush both timers at once.
|
|
||||||
if w.finalLead > 0 {
|
|
||||||
w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Watcher) fire(armedFor time.Time) {
|
|
||||||
w.mu.Lock()
|
|
||||||
if w.closed || !w.current.Equal(armedFor) {
|
|
||||||
// Deadline moved while we were waiting (e.g. a successful extend).
|
|
||||||
// The reschedule path armed a fresh timer; this one is stale.
|
|
||||||
w.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) {
|
|
||||||
w.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.firedAt = armedFor
|
|
||||||
recorder := w.recorder
|
|
||||||
w.mu.Unlock()
|
|
||||||
if recorder == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Infof("auth session expiry soon warning fired")
|
|
||||||
publishWarning(recorder, armedFor, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra
|
|
||||||
// dismiss-gate: if the user dismissed the T-WarningLead notification for
|
|
||||||
// this deadline, the final warning is suppressed entirely.
|
|
||||||
func (w *Watcher) fireFinal(armedFor time.Time) {
|
|
||||||
w.mu.Lock()
|
|
||||||
if w.closed || !w.current.Equal(armedFor) {
|
|
||||||
w.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) {
|
|
||||||
w.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if w.dismissedAt.Equal(armedFor) {
|
|
||||||
w.mu.Unlock()
|
|
||||||
log.Infof("auth session final-warning skipped (dismissed by user)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.finalFiredAt = armedFor
|
|
||||||
recorder := w.recorder
|
|
||||||
w.mu.Unlock()
|
|
||||||
if recorder == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Infof("auth session final-warning fired")
|
|
||||||
publishWarning(recorder, armedFor, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// armOneShotLocked schedules cb at fireAt. When fireAt is already in the
|
|
||||||
// past it dispatches on the next scheduler tick so a state-change recorder
|
|
||||||
// notification (invoked after w.mu is released) lands first. Caller must
|
|
||||||
// hold w.mu.
|
|
||||||
func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer {
|
|
||||||
delay := time.Until(fireAt)
|
|
||||||
if delay <= 0 {
|
|
||||||
return time.AfterFunc(0, cb)
|
|
||||||
}
|
|
||||||
return time.AfterFunc(delay, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
// publishWarning composes the SystemEvent for a watcher-fired warning and
|
|
||||||
// pushes it through the recorder. Severity is CRITICAL on both — bypassing
|
|
||||||
// the user's Notifications toggle is deliberate: missing the warning
|
|
||||||
// window forces the post-mortem SessionExpired flow (tunnel torn down,
|
|
||||||
// lock icon, manual re-login), which is the UX we are trying to avoid.
|
|
||||||
func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
|
|
||||||
lead := WarningLead
|
|
||||||
message := "session expiry warning"
|
|
||||||
meta := map[string]string{
|
|
||||||
MetaSessionWarning: "true",
|
|
||||||
MetaSessionExpiresAt: FormatExpiresAt(deadline),
|
|
||||||
}
|
|
||||||
if final {
|
|
||||||
lead = FinalWarningLead
|
|
||||||
message = "session expiry final warning"
|
|
||||||
meta[MetaSessionFinal] = "true"
|
|
||||||
}
|
|
||||||
meta[MetaSessionLeadMinutes] = FormatLeadMinutes(lead)
|
|
||||||
|
|
||||||
recorder.PublishEvent(
|
|
||||||
cProto.SystemEvent_CRITICAL,
|
|
||||||
cProto.SystemEvent_AUTHENTICATION,
|
|
||||||
message,
|
|
||||||
"",
|
|
||||||
meta,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,519 +0,0 @@
|
|||||||
package sessionwatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeRecorder satisfies StatusRecorder and records every call so tests
|
|
||||||
// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent
|
|
||||||
// land in the same ordered events slice (with the Kind distinguishing
|
|
||||||
// them) so tests that care about ordering still work. lastDeadline holds
|
|
||||||
// the most recent value passed to SetSessionExpiresAt so tests can assert
|
|
||||||
// the recorder ended up cleared/set as expected.
|
|
||||||
type fakeRecorder struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
events []event
|
|
||||||
lastDeadline time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type eventKind int
|
|
||||||
|
|
||||||
const (
|
|
||||||
stateChange eventKind = iota
|
|
||||||
publish
|
|
||||||
)
|
|
||||||
|
|
||||||
type event struct {
|
|
||||||
kind eventKind
|
|
||||||
// Set only for publish events.
|
|
||||||
severity cProto.SystemEvent_Severity
|
|
||||||
category cProto.SystemEvent_Category
|
|
||||||
message string
|
|
||||||
meta map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op,
|
|
||||||
// a real change records the new value and fans out a state-change (the
|
|
||||||
// production recorder calls notifyStateChange internally). The baseline
|
|
||||||
// is the zero time, so an initial clear before any deadline is set emits
|
|
||||||
// nothing — matching the real recorder.
|
|
||||||
func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if r.lastDeadline.Equal(deadline) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.lastDeadline = deadline
|
|
||||||
r.events = append(r.events, event{kind: stateChange})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *fakeRecorder) deadline() time.Time {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
return r.lastDeadline
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *fakeRecorder) PublishEvent(
|
|
||||||
severity cProto.SystemEvent_Severity,
|
|
||||||
category cProto.SystemEvent_Category,
|
|
||||||
message string,
|
|
||||||
_ string,
|
|
||||||
metadata map[string]string,
|
|
||||||
) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
r.events = append(r.events, event{
|
|
||||||
kind: publish,
|
|
||||||
severity: severity,
|
|
||||||
category: category,
|
|
||||||
message: message,
|
|
||||||
meta: metadata,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *fakeRecorder) snapshot() []event {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
out := make([]event, len(r.events))
|
|
||||||
copy(out, r.events)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e event) isFinalWarning() bool {
|
|
||||||
return e.kind == publish && e.meta[MetaSessionFinal] == "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e event) isWarning() bool {
|
|
||||||
return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
func countWhere(events []event, pred func(event) bool) int {
|
|
||||||
n := 0
|
|
||||||
for _, e := range events {
|
|
||||||
if pred(e) {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(500 * time.Millisecond)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if got := r.snapshot(); len(got) >= want {
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
got := r.snapshot()
|
|
||||||
t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newWatcher builds a watcher with the final timer disabled (finalLead=0),
|
|
||||||
// matching the lead-only behaviour the pre-final-warning tests assume.
|
|
||||||
func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher {
|
|
||||||
return NewWithLeads(lead, 0, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
_ = w.Update(time.Time{})
|
|
||||||
|
|
||||||
if got := r.snapshot(); len(got) != 0 {
|
|
||||||
t.Fatalf("expected no events on initial zero, got %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateNonZeroFiresStateChange(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
d := time.Now().Add(time.Hour)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
events := waitForEvents(t, r, 1)
|
|
||||||
if events[0].kind != stateChange {
|
|
||||||
t.Fatalf("expected stateChange, got %+v", events[0])
|
|
||||||
}
|
|
||||||
if !w.Deadline().Equal(d) {
|
|
||||||
t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSameDeadlineIsNoop(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
d := time.Now().Add(time.Hour)
|
|
||||||
_ = w.Update(d)
|
|
||||||
_ = w.Update(d)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
events := waitForEvents(t, r, 1)
|
|
||||||
if len(events) != 1 {
|
|
||||||
t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
lead := 50 * time.Millisecond
|
|
||||||
w := newWatcher(lead, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
// Deadline 80ms out — warning should fire after ~30ms.
|
|
||||||
d := time.Now().Add(80 * time.Millisecond)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
events := waitForEvents(t, r, 2)
|
|
||||||
if events[0].kind != stateChange {
|
|
||||||
t.Fatalf("event[0] should be stateChange, got %+v", events[0])
|
|
||||||
}
|
|
||||||
if !events[1].isWarning() {
|
|
||||||
t.Fatalf("event[1] should be a warning publish, got %+v", events[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(time.Hour, r) // lead > delta => fire immediately
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
d := time.Now().Add(10 * time.Millisecond)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
events := waitForEvents(t, r, 2)
|
|
||||||
if !events[1].isWarning() {
|
|
||||||
t.Fatalf("expected immediate warning publish, got %+v", events[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
lead := 50 * time.Millisecond
|
|
||||||
w := newWatcher(lead, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in
|
|
||||||
_ = w.Update(first)
|
|
||||||
|
|
||||||
// Replace with a far-future deadline before the warning fires.
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
second := time.Now().Add(time.Hour)
|
|
||||||
_ = w.Update(second)
|
|
||||||
|
|
||||||
// Wait past when first's warning would have fired.
|
|
||||||
time.Sleep(80 * time.Millisecond)
|
|
||||||
|
|
||||||
if n := countWhere(r.snapshot(), event.isWarning); n != 0 {
|
|
||||||
t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
lead := 30 * time.Millisecond
|
|
||||||
w := newWatcher(lead, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
first := time.Now().Add(50 * time.Millisecond)
|
|
||||||
_ = w.Update(first)
|
|
||||||
|
|
||||||
// Wait for stateChange + warning of the first cycle.
|
|
||||||
waitForEvents(t, r, 2)
|
|
||||||
|
|
||||||
// Simulate a successful extend: brand new deadline.
|
|
||||||
second := time.Now().Add(60 * time.Millisecond)
|
|
||||||
_ = w.Update(second)
|
|
||||||
|
|
||||||
// 4 events total: stateChange, warning (first), stateChange, warning (second).
|
|
||||||
events := waitForEvents(t, r, 4)
|
|
||||||
if events[2].kind != stateChange {
|
|
||||||
t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2])
|
|
||||||
}
|
|
||||||
if !events[3].isWarning() {
|
|
||||||
t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(time.Hour, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
d := time.Now().Add(2 * time.Hour)
|
|
||||||
_ = w.Update(d)
|
|
||||||
waitForEvents(t, r, 1)
|
|
||||||
|
|
||||||
_ = w.Update(time.Time{})
|
|
||||||
|
|
||||||
events := waitForEvents(t, r, 2)
|
|
||||||
if events[1].kind != stateChange {
|
|
||||||
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
|
||||||
}
|
|
||||||
if !w.Deadline().IsZero() {
|
|
||||||
t.Fatalf("Deadline should be zero after clear")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRejectsBeforeEpoch(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
good := time.Now().Add(time.Hour)
|
|
||||||
if err := w.Update(good); err != nil {
|
|
||||||
t.Fatalf("seed Update: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := w.Update(time.Unix(-100, 0))
|
|
||||||
if !errors.Is(err, ErrDeadlineBeforeEpoch) {
|
|
||||||
t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err)
|
|
||||||
}
|
|
||||||
if !w.Deadline().IsZero() {
|
|
||||||
t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
good := time.Now().Add(time.Hour)
|
|
||||||
if err := w.Update(good); err != nil {
|
|
||||||
t.Fatalf("seed Update: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour))
|
|
||||||
if !errors.Is(err, ErrDeadlineTooFarFuture) {
|
|
||||||
t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err)
|
|
||||||
}
|
|
||||||
if !w.Deadline().IsZero() {
|
|
||||||
t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateInPastClearsDeadline(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
good := time.Now().Add(time.Hour)
|
|
||||||
if err := w.Update(good); err != nil {
|
|
||||||
t.Fatalf("seed Update: %v", err)
|
|
||||||
}
|
|
||||||
// Drain the stateChange from the seed.
|
|
||||||
waitForEvents(t, r, 1)
|
|
||||||
|
|
||||||
err := w.Update(time.Now().Add(-1 * time.Hour))
|
|
||||||
if !errors.Is(err, ErrDeadlineInPast) {
|
|
||||||
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
|
||||||
}
|
|
||||||
if !w.Deadline().IsZero() {
|
|
||||||
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
|
|
||||||
}
|
|
||||||
events := waitForEvents(t, r, 2)
|
|
||||||
if events[1].kind != stateChange {
|
|
||||||
t.Fatalf("expected stateChange on clear, got %+v", events[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateWithinSkewAccepted(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
|
|
||||||
d := time.Now().Add(-5 * time.Second)
|
|
||||||
if err := w.Update(d); err != nil {
|
|
||||||
t.Fatalf("within-skew Update should succeed, got %v", err)
|
|
||||||
}
|
|
||||||
if !w.Deadline().Equal(d) {
|
|
||||||
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCloseSilencesUpdates(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(50*time.Millisecond, r)
|
|
||||||
w.Close()
|
|
||||||
|
|
||||||
_ = w.Update(time.Now().Add(time.Hour))
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
if got := r.snapshot(); len(got) != 0 {
|
|
||||||
t.Fatalf("expected no events after Close, got %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
|
|
||||||
// holding a live deadline must zero the recorder on Close so the next
|
|
||||||
// engine's watcher (and the UI reading the shared server-scoped recorder)
|
|
||||||
// doesn't start out showing the previous session's stale "expires in".
|
|
||||||
func TestCloseClearsRecorderDeadline(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(time.Hour, r)
|
|
||||||
|
|
||||||
d := time.Now().Add(2 * time.Hour)
|
|
||||||
if err := w.Update(d); err != nil {
|
|
||||||
t.Fatalf("seed Update: %v", err)
|
|
||||||
}
|
|
||||||
if got := r.deadline(); !got.Equal(d) {
|
|
||||||
t.Fatalf("recorder deadline after Update = %v, want %v", got, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Close()
|
|
||||||
|
|
||||||
if got := r.deadline(); !got.IsZero() {
|
|
||||||
t.Fatalf("recorder deadline after Close = %v, want zero", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric
|
|
||||||
// case: closing a watcher that never held a deadline must not emit a
|
|
||||||
// redundant clear (the recorder may legitimately hold a value written by
|
|
||||||
// some other path; the watcher only owns what it set).
|
|
||||||
func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := newWatcher(time.Hour, r)
|
|
||||||
|
|
||||||
w.Close()
|
|
||||||
|
|
||||||
if got := r.snapshot(); len(got) != 0 {
|
|
||||||
t.Fatalf("expected no events from Close on an empty watcher, got %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFinalWarningFiresAfterRegularWarning(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
// Warning fires at deadline-80ms, final at deadline-30ms.
|
|
||||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
d := time.Now().Add(100 * time.Millisecond)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
// Expect stateChange + warning + final-warning.
|
|
||||||
events := waitForEvents(t, r, 3)
|
|
||||||
|
|
||||||
if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 {
|
|
||||||
t.Fatalf("expected exactly 1 stateChange, got %+v", events)
|
|
||||||
}
|
|
||||||
if countWhere(events, event.isWarning) != 1 {
|
|
||||||
t.Fatalf("expected exactly 1 warning publish, got %+v", events)
|
|
||||||
}
|
|
||||||
if countWhere(events, event.isFinalWarning) != 1 {
|
|
||||||
t.Fatalf("expected exactly 1 final-warning publish, got %+v", events)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warning must precede final (same deadline, longer lead fires first).
|
|
||||||
var wIdx, fIdx int
|
|
||||||
for i, e := range events {
|
|
||||||
switch {
|
|
||||||
case e.isWarning():
|
|
||||||
wIdx = i
|
|
||||||
case e.isFinalWarning():
|
|
||||||
fIdx = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if wIdx > fIdx {
|
|
||||||
t.Fatalf("warning must publish before final-warning, got order %+v", events)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDismissSuppressesFinalWarning(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
d := time.Now().Add(100 * time.Millisecond)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
// Wait for the warning publish so we know we're inside the warning
|
|
||||||
// window, then dismiss before the final timer would fire.
|
|
||||||
deadline := time.Now().Add(500 * time.Millisecond)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if countWhere(r.snapshot(), event.isWarning) >= 1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if countWhere(r.snapshot(), event.isWarning) < 1 {
|
|
||||||
t.Fatalf("warning did not publish in time, events=%+v", r.snapshot())
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Dismiss()
|
|
||||||
|
|
||||||
// Now wait past when the final would have fired.
|
|
||||||
time.Sleep(120 * time.Millisecond)
|
|
||||||
|
|
||||||
if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 {
|
|
||||||
t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDismissResetByNewDeadline(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
first := time.Now().Add(100 * time.Millisecond)
|
|
||||||
_ = w.Update(first)
|
|
||||||
|
|
||||||
// Dismiss against the first deadline.
|
|
||||||
w.Dismiss()
|
|
||||||
|
|
||||||
// Replace with a fresh deadline before the first's timers complete.
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
second := time.Now().Add(100 * time.Millisecond)
|
|
||||||
_ = w.Update(second)
|
|
||||||
|
|
||||||
// The second cycle must publish a final-warning (the dismiss state
|
|
||||||
// did not carry over).
|
|
||||||
deadline := time.Now().Add(500 * time.Millisecond)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if countWhere(r.snapshot(), event.isFinalWarning) < 1 {
|
|
||||||
t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDismissBeforeUpdateIsNoop(t *testing.T) {
|
|
||||||
r := &fakeRecorder{}
|
|
||||||
w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r)
|
|
||||||
defer w.Close()
|
|
||||||
|
|
||||||
// No deadline tracked yet; Dismiss must be a no-op (no panic, no state).
|
|
||||||
w.Dismiss()
|
|
||||||
|
|
||||||
d := time.Now().Add(100 * time.Millisecond)
|
|
||||||
_ = w.Update(d)
|
|
||||||
|
|
||||||
// Final warning should still publish — Dismiss only acts on the current
|
|
||||||
// deadline, and there was none at the time of the call.
|
|
||||||
deadline := time.Now().Add(500 * time.Millisecond)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if countWhere(r.snapshot(), event.isFinalWarning) >= 1 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot())
|
|
||||||
}
|
|
||||||
@@ -256,15 +256,6 @@ 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)
|
||||||
@@ -393,10 +384,6 @@ 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)
|
||||||
|
|
||||||
@@ -437,11 +424,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.statusRecorder.ClientStart()
|
c.statusRecorder.ClientStart()
|
||||||
// Wrap the backoff with c.ctx so Down()/actCancel propagates into the
|
err = backoff.Retry(operation, backOff)
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -250,20 +250,6 @@ 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
|
||||||
@@ -307,17 +293,6 @@ 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
|
||||||
@@ -358,10 +333,6 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -894,8 +865,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ApplySessionDeadline propagates the absolute SSO session deadline carried on
|
|
||||||
// LoginResponse / SyncResponse to both the watcher (for the edge-triggered
|
|
||||||
// warning) and the status recorder (for the SubscribeStatus / Status RPC
|
|
||||||
// snapshot the UI consumes).
|
|
||||||
//
|
|
||||||
// The wire field is 3-state:
|
|
||||||
// - nil → snapshot carries no info; keep the
|
|
||||||
// previously-anchored deadline (no-op)
|
|
||||||
// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is
|
|
||||||
// disabled; clear both sinks
|
|
||||||
// - valid timestamp → new deadline; arm watcher, expose on
|
|
||||||
// status recorder
|
|
||||||
//
|
|
||||||
// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected
|
|
||||||
// value is treated as a clear on both sinks: the alternative — leaving the
|
|
||||||
// previously-known deadline in place — risks the UI confidently displaying
|
|
||||||
// a stale "expires in X" while the server has actually invalidated it.
|
|
||||||
func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
|
|
||||||
if ts == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var deadline time.Time
|
|
||||||
// Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled".
|
|
||||||
// Everything else flows through Watcher.Update, whose sanity-checks
|
|
||||||
// reject out-of-range / pre-epoch / far-future / too-stale values and
|
|
||||||
// clear on rejection.
|
|
||||||
if ts.GetSeconds() != 0 || ts.GetNanos() != 0 {
|
|
||||||
deadline = ts.AsTime().UTC()
|
|
||||||
}
|
|
||||||
if e.sessionWatcher == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Watcher.Update owns the propagation to the status recorder (the
|
|
||||||
// SubscribeStatus / Status snapshot the UI reads): a set writes the
|
|
||||||
// deadline, a clear or a sanity-check rejection writes the zero value.
|
|
||||||
// Keeping a single writer is what stops the recorder from drifting out
|
|
||||||
// of sync with the warning timers.
|
|
||||||
if err := e.sessionWatcher.Update(deadline); err != nil {
|
|
||||||
log.Errorf("auth session deadline rejected: %v, clearing", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DismissSessionWarning records the user's "Dismiss" click on the
|
|
||||||
// T-WarningLead interactive notification and suppresses the upcoming
|
|
||||||
// T-FinalWarningLead fallback for the current deadline. No-op when the
|
|
||||||
// watcher is not running or holds no deadline.
|
|
||||||
func (e *Engine) DismissSessionWarning() {
|
|
||||||
if e.sessionWatcher == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.sessionWatcher.Dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtendAuthSession asks the management server to refresh the SSO session
|
|
||||||
// expiry deadline using the supplied JWT, then mirrors the new deadline into
|
|
||||||
// the daemon's state. The tunnel is untouched; no resync, no reconnect.
|
|
||||||
//
|
|
||||||
// Returns the new absolute UTC deadline (or zero time when the server
|
|
||||||
// reports the peer is not eligible for extension).
|
|
||||||
func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) {
|
|
||||||
if jwtToken == "" {
|
|
||||||
return time.Time{}, errors.New("jwt token is required")
|
|
||||||
}
|
|
||||||
if e.mgmClient == nil {
|
|
||||||
return time.Time{}, errors.New("management client is not initialised")
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := system.GetInfoWithChecks(ctx, e.checks)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("failed to collect system info for session extend: %v", err)
|
|
||||||
info = system.GetInfo(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf("extend auth session on management: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
e.ApplySessionDeadline(resp.GetSessionExpiresAt())
|
|
||||||
|
|
||||||
if resp.GetSessionExpiresAt().IsValid() {
|
|
||||||
return resp.GetSessionExpiresAt().AsTime().UTC(), nil
|
|
||||||
}
|
|
||||||
return time.Time{}, nil
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the
|
|
||||||
// wire field carried on LoginResponse / SyncResponse:
|
|
||||||
//
|
|
||||||
// - nil pointer → no info; previously-anchored deadline survives
|
|
||||||
// - explicit zero value → "expiry disabled" sentinel; both sinks cleared
|
|
||||||
// - valid future timestamp → new deadline propagated to both sinks
|
|
||||||
func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
|
||||||
newEngine := func() *Engine {
|
|
||||||
recorder := peer.NewRecorder("")
|
|
||||||
return &Engine{
|
|
||||||
statusRecorder: recorder,
|
|
||||||
sessionWatcher: sessionwatch.New(recorder),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) {
|
|
||||||
e := newEngine()
|
|
||||||
deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
|
||||||
|
|
||||||
e.ApplySessionDeadline(timestamppb.New(deadline))
|
|
||||||
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline),
|
|
||||||
"status recorder should hold the new deadline")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) {
|
|
||||||
e := newEngine()
|
|
||||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
|
||||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
|
||||||
|
|
||||||
e.ApplySessionDeadline(nil)
|
|
||||||
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded),
|
|
||||||
"nil snapshot must not disturb the existing deadline")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) {
|
|
||||||
e := newEngine()
|
|
||||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
|
||||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
|
||||||
|
|
||||||
// Explicit zero Timestamp{} (seconds=0, nanos=0) is the
|
|
||||||
// "expiry disabled / not SSO" sentinel.
|
|
||||||
e.ApplySessionDeadline(×tamppb.Timestamp{})
|
|
||||||
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
|
||||||
"explicit zero sentinel must clear the deadline")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("invalid timestamp clears the deadline", func(t *testing.T) {
|
|
||||||
e := newEngine()
|
|
||||||
seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
|
||||||
e.ApplySessionDeadline(timestamppb.New(seeded))
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded))
|
|
||||||
|
|
||||||
// Out-of-range nanos → IsValid()==false; same-meaning as the
|
|
||||||
// disabled sentinel for downstream sinks.
|
|
||||||
e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1})
|
|
||||||
|
|
||||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
|
||||||
"invalid timestamp must clear the deadline")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//go:build !js
|
|
||||||
|
|
||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newSessionWatcher returns the real SSO session expiry watcher for every
|
|
||||||
// non-wasm build. The js/wasm build gets a no-op stub from
|
|
||||||
// engine_sessionwatch_js.go so the sessionwatch package (and its timer
|
|
||||||
// machinery) never links into the wasm binary.
|
|
||||||
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
|
||||||
return sessionwatch.New(recorder)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
//go:build js
|
|
||||||
|
|
||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The
|
|
||||||
// wasm client never runs the engine's session-warning flow (the interactive
|
|
||||||
// T-WarningLead notification and the T-FinalWarningLead fallback dialog live
|
|
||||||
// in the desktop UI), so linking the full sessionwatch package (timers, event
|
|
||||||
// composition) would only bloat the binary.
|
|
||||||
//
|
|
||||||
// It still mirrors the deadline into the status recorder so the SubscribeStatus
|
|
||||||
// / Status snapshot the UI consumes stays correct — only the timer-driven
|
|
||||||
// warnings are dropped.
|
|
||||||
type noopSessionWatcher struct {
|
|
||||||
recorder *peer.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher {
|
|
||||||
return noopSessionWatcher{recorder: recorder}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update mirrors the real watcher's recorder propagation without the timers or
|
|
||||||
// sanity-check sentinels: a valid deadline is exposed on the status snapshot,
|
|
||||||
// the zero time clears it.
|
|
||||||
func (w noopSessionWatcher) Update(deadline time.Time) error {
|
|
||||||
if w.recorder != nil {
|
|
||||||
w.recorder.SetSessionExpiresAt(deadline)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (noopSessionWatcher) Dismiss() {}
|
|
||||||
func (noopSessionWatcher) Close() {}
|
|
||||||
@@ -23,7 +23,6 @@ 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"
|
||||||
@@ -900,7 +899,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 := rosenpass.DeterministicSeedKey(conn.config.LocalKey, conn.config.Key)
|
determKey, err := conn.rosenpassDetermKey()
|
||||||
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
|
||||||
@@ -909,6 +908,26 @@ 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,7 +7,6 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -191,27 +190,21 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
// every private-service request) don't contend against each other.
|
// every private-service request) don't contend against each other.
|
||||||
// Pure read methods take RLock; anything that mutates state takes Lock.
|
// Pure read methods take RLock; anything that mutates state takes Lock.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
mux sync.RWMutex
|
mux sync.RWMutex
|
||||||
peers map[string]State
|
peers map[string]State
|
||||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||||
signalState bool
|
signalState bool
|
||||||
signalError error
|
signalError error
|
||||||
managementState bool
|
managementState bool
|
||||||
managementError error
|
managementError error
|
||||||
relayStates []relay.ProbeResult
|
relayStates []relay.ProbeResult
|
||||||
localPeer LocalPeerState
|
localPeer LocalPeerState
|
||||||
offlinePeers []State
|
offlinePeers []State
|
||||||
mgmAddress string
|
mgmAddress string
|
||||||
signalAddress string
|
signalAddress string
|
||||||
notifier *notifier
|
notifier *notifier
|
||||||
rosenpassEnabled bool
|
rosenpassEnabled bool
|
||||||
rosenpassPermissive bool
|
rosenpassPermissive bool
|
||||||
// sessionExpiresAt is the absolute UTC instant at which the peer's SSO
|
|
||||||
// session expires. Zero when the peer is not SSO-tracked or login
|
|
||||||
// expiration is disabled. Populated from management LoginResponse /
|
|
||||||
// SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC
|
|
||||||
// so the UI can show remaining time without itself talking to mgm.
|
|
||||||
sessionExpiresAt time.Time
|
|
||||||
nsGroupStates []NSGroupState
|
nsGroupStates []NSGroupState
|
||||||
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
||||||
lazyConnectionEnabled bool
|
lazyConnectionEnabled bool
|
||||||
@@ -227,21 +220,6 @@ 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
|
||||||
@@ -255,7 +233,6 @@ 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,
|
||||||
@@ -405,7 +382,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +407,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +432,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,7 +481,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +517,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +552,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,7 +590,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +683,6 @@ 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 {
|
||||||
@@ -772,41 +741,6 @@ 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
|
||||||
@@ -875,7 +809,6 @@ 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
|
||||||
@@ -888,7 +821,6 @@ 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
|
||||||
@@ -901,7 +833,6 @@ 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
|
||||||
@@ -942,7 +873,6 @@ 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
|
||||||
@@ -955,7 +885,6 @@ 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) {
|
||||||
@@ -1153,19 +1082,16 @@ 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
|
||||||
@@ -1307,79 +1233,6 @@ 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()
|
||||||
|
|||||||
@@ -179,10 +179,8 @@ func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dst := net.IPv4zero
|
dst := net.IPv4zero
|
||||||
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
if runtime.GOOS == "linux" {
|
||||||
// go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
|
// go-netroute v0.4.0 rejects unspecified destinations client-side on Linux.
|
||||||
// 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)
|
||||||
@@ -205,7 +203,7 @@ func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dst := net.IPv6zero
|
dst := net.IPv6zero
|
||||||
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
if runtime.GOOS == "linux" {
|
||||||
// ::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,15 +28,6 @@ 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
|
||||||
@@ -45,7 +36,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 rpServer
|
server *rp.Server
|
||||||
lock sync.Mutex
|
lock sync.Mutex
|
||||||
port int
|
port int
|
||||||
wgIface PresharedKeySetter
|
wgIface PresharedKeySetter
|
||||||
@@ -60,22 +51,7 @@ 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{
|
return &Manager{ifaceName: wgIfaceName, rpKeyHash: rpKeyHash, spk: public, ssk: secret, preSharedKey: (*[32]byte)(preSharedKey), rpPeerIDs: make(map[string]*rp.PeerID), lock: sync.Mutex{}}, nil
|
||||||
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 {
|
||||||
@@ -89,16 +65,6 @@ 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 {
|
||||||
@@ -113,16 +79,6 @@ 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 {
|
||||||
@@ -226,31 +182,24 @@ func (m *Manager) Run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := rp.NewUDPServer(conf)
|
m.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 server.Run()
|
return m.server.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the Rosenpass server
|
// Close closes the Rosenpass server
|
||||||
func (m *Manager) Close() error {
|
func (m *Manager) Close() error {
|
||||||
m.lock.Lock()
|
if m.server != nil {
|
||||||
server := m.server
|
err := m.server.Close()
|
||||||
m.server = nil
|
if err != nil {
|
||||||
m.lock.Unlock()
|
log.Errorf("failed closing local rosenpass server")
|
||||||
if server == nil {
|
}
|
||||||
return nil
|
m.server = nil
|
||||||
}
|
|
||||||
if err := server.Close(); err != nil {
|
|
||||||
log.Errorf("failed closing local rosenpass server: %v", err)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,412 +1,14 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package routemanager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/routeselector"
|
|
||||||
"github.com/netbirdio/netbird/route"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newExitNodeTestManager() *DefaultManager {
|
|
||||||
return &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func exitRoute(netID, peer string, skipAutoApply bool) *route.Route {
|
|
||||||
return &route.Route{
|
|
||||||
NetID: route.NetID(netID),
|
|
||||||
Network: netip.MustParsePrefix("0.0.0.0/0"),
|
|
||||||
Peer: peer,
|
|
||||||
SkipAutoApply: skipAutoApply,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPickPreferredExitNode(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
info exitNodeInfo
|
|
||||||
want route.NetID
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "persisted user selection wins over management",
|
|
||||||
info: exitNodeInfo{
|
|
||||||
allIDs: []route.NetID{"a", "b", "c"},
|
|
||||||
userSelected: []route.NetID{"b"},
|
|
||||||
selectedByManagement: []route.NetID{"a"},
|
|
||||||
},
|
|
||||||
want: "b",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple user-selected self-heal to deterministic min",
|
|
||||||
info: exitNodeInfo{
|
|
||||||
allIDs: []route.NetID{"a", "b", "c"},
|
|
||||||
userSelected: []route.NetID{"c", "a"},
|
|
||||||
},
|
|
||||||
want: "a",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "explicit opt-out keeps none",
|
|
||||||
info: exitNodeInfo{
|
|
||||||
allIDs: []route.NetID{"a", "b"},
|
|
||||||
userDeselected: []route.NetID{"a", "b"},
|
|
||||||
},
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fresh defaults to management auto-apply pick",
|
|
||||||
info: exitNodeInfo{
|
|
||||||
allIDs: []route.NetID{"a", "b", "c"},
|
|
||||||
selectedByManagement: []route.NetID{"b"},
|
|
||||||
},
|
|
||||||
want: "b",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no user pick and no management auto-apply selects none",
|
|
||||||
info: exitNodeInfo{
|
|
||||||
allIDs: []route.NetID{"c", "a", "b"},
|
|
||||||
},
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "user-deselect does not block a management auto-apply sibling",
|
|
||||||
info: exitNodeInfo{
|
|
||||||
allIDs: []route.NetID{"a", "b"},
|
|
||||||
userDeselected: []route.NetID{"a"},
|
|
||||||
selectedByManagement: []route.NetID{"b"},
|
|
||||||
},
|
|
||||||
want: "b",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.want, pickPreferredExitNode(tt.info), "preferred exit node")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnforceSingleExitNode(t *testing.T) {
|
|
||||||
m := newExitNodeTestManager()
|
|
||||||
all := []route.NetID{"a", "b", "c"}
|
|
||||||
|
|
||||||
m.enforceSingleExitNode("b", all)
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("a"), "a should be deselected")
|
|
||||||
assert.True(t, m.routeSelector.IsSelected("b"), "b should be the only selected exit node")
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("c"), "c should be deselected")
|
|
||||||
|
|
||||||
// Switching the preferred node moves the single selection.
|
|
||||||
m.enforceSingleExitNode("c", all)
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("a"), "a stays deselected")
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("b"), "b should now be deselected")
|
|
||||||
assert.True(t, m.routeSelector.IsSelected("c"), "c should now be selected")
|
|
||||||
|
|
||||||
// Empty preferred turns every exit node off.
|
|
||||||
m.enforceSingleExitNode("", all)
|
|
||||||
for _, id := range all {
|
|
||||||
assert.False(t, m.routeSelector.IsSelected(id), "no exit node should be selected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnforceSingleExitNode_RespectsDeselectAll(t *testing.T) {
|
|
||||||
m := newExitNodeTestManager()
|
|
||||||
m.routeSelector.DeselectAllRoutes()
|
|
||||||
|
|
||||||
m.enforceSingleExitNode("b", []route.NetID{"a", "b"})
|
|
||||||
|
|
||||||
assert.True(t, m.routeSelector.IsDeselectAllActive(), "global deselect-all must stay in effect")
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("b"), "no exit node should be forced on while deselect-all is set")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRouteSelectorFromManagement_FreshSelectsOne(t *testing.T) {
|
|
||||||
m := newExitNodeTestManager()
|
|
||||||
routes := route.HAMap{
|
|
||||||
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
|
||||||
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
|
||||||
"lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
|
|
||||||
"exitC|0.0.0.0/0": {exitRoute("exitC", "p4", false)},
|
|
||||||
}
|
|
||||||
|
|
||||||
m.updateRouteSelectorFromManagement(routes)
|
|
||||||
|
|
||||||
// Exactly one exit node (the deterministic first) is selected.
|
|
||||||
assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA is the deterministic default")
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB must not also be selected")
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("exitC"), "exitC must not also be selected")
|
|
||||||
// Non-exit routes are left at their default-on state.
|
|
||||||
assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRouteSelectorFromManagement_HonorsPersistedPick(t *testing.T) {
|
|
||||||
m := newExitNodeTestManager()
|
|
||||||
routes := route.HAMap{
|
|
||||||
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
|
||||||
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
|
||||||
}
|
|
||||||
all := []route.NetID{"exitA", "exitB"}
|
|
||||||
|
|
||||||
// Simulate the state the runtime select path leaves behind: exactly one
|
|
||||||
// exit node explicitly selected, its sibling deselected.
|
|
||||||
require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exitB"}, true, all))
|
|
||||||
require.NoError(t, m.routeSelector.DeselectRoutes([]route.NetID{"exitA"}, all))
|
|
||||||
|
|
||||||
m.updateRouteSelectorFromManagement(routes)
|
|
||||||
|
|
||||||
assert.True(t, m.routeSelector.IsSelected("exitB"), "persisted pick must stay selected")
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("exitA"), "the other exit node stays deselected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRouteSelectorFromManagement_OptOutKeepsNone(t *testing.T) {
|
|
||||||
m := newExitNodeTestManager()
|
|
||||||
routes := route.HAMap{
|
|
||||||
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
|
|
||||||
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
|
|
||||||
}
|
|
||||||
all := []route.NetID{"exitA", "exitB"}
|
|
||||||
|
|
||||||
// User deselected exit nodes and selected none.
|
|
||||||
require.NoError(t, m.routeSelector.DeselectRoutes(all, all))
|
|
||||||
|
|
||||||
m.updateRouteSelectorFromManagement(routes)
|
|
||||||
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("exitA"), "opt-out keeps exitA off")
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("exitB"), "opt-out keeps exitB off")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRouteSelectorFromManagement_NoAutoApplySelectsNone(t *testing.T) {
|
|
||||||
m := newExitNodeTestManager()
|
|
||||||
// SkipAutoApply=true: management offers the exit nodes but doesn't request
|
|
||||||
// auto-activation, so none should be selected until the user picks one.
|
|
||||||
routes := route.HAMap{
|
|
||||||
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)},
|
|
||||||
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)},
|
|
||||||
}
|
|
||||||
|
|
||||||
m.updateRouteSelectorFromManagement(routes)
|
|
||||||
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("exitA"), "no auto-apply keeps exitA off")
|
|
||||||
assert.False(t, m.routeSelector.IsSelected("exitB"), "no auto-apply keeps exitB off")
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -440,11 +439,6 @@ 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
|
||||||
|
|
||||||
@@ -585,10 +579,6 @@ 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
|
||||||
@@ -708,22 +698,15 @@ func resolveURLsToIPs(urls []string) []net.IP {
|
|||||||
return ips
|
return ips
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateRouteSelectorFromManagement reconciles exit-node selection on every
|
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
|
||||||
// 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) {
|
||||||
info := m.collectExitNodeInfo(clientRoutes)
|
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
|
||||||
if len(info.allIDs) == 0 {
|
if len(exitNodeInfo.allIDs) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
preferred := pickPreferredExitNode(info)
|
m.updateExitNodeSelections(exitNodeInfo)
|
||||||
m.enforceSingleExitNode(preferred, info.allIDs)
|
m.logExitNodeUpdate(exitNodeInfo)
|
||||||
m.logExitNodeUpdate(info, preferred)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type exitNodeInfo struct {
|
type exitNodeInfo struct {
|
||||||
@@ -733,10 +716,6 @@ 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
|
||||||
|
|
||||||
@@ -746,9 +725,6 @@ 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) {
|
||||||
@@ -785,69 +761,45 @@ func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickPreferredExitNode chooses the single exit node to keep selected. In order:
|
func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) {
|
||||||
// - a persisted user selection wins (deterministic if several survive from
|
routesToDeselect := m.getRoutesToDeselect(info.allIDs)
|
||||||
// legacy state, so the set self-heals down to one);
|
m.deselectExitNodes(routesToDeselect)
|
||||||
// - otherwise activate only what management marks for auto-apply
|
m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs)
|
||||||
// (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 ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// enforceSingleExitNode makes preferred the only selected exit node: every other
|
func (m *DefaultManager) getRoutesToDeselect(allIDs []route.NetID) []route.NetID {
|
||||||
// available exit node is deselected and preferred (if any) is selected, without
|
var routesToDeselect []route.NetID
|
||||||
// disturbing non-exit route selections. A global deselect-all is left untouched
|
for _, netID := range allIDs {
|
||||||
// so the user's "all off" stays in effect.
|
if !m.routeSelector.HasUserSelectionForRoute(netID) {
|
||||||
func (m *DefaultManager) enforceSingleExitNode(preferred route.NetID, allIDs []route.NetID) {
|
routesToDeselect = append(routesToDeselect, netID)
|
||||||
if m.routeSelector.IsDeselectAllActive() {
|
}
|
||||||
|
}
|
||||||
|
return routesToDeselect
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DefaultManager) deselectExitNodes(routesToDeselect []route.NetID) {
|
||||||
|
if len(routesToDeselect) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
others := make([]route.NetID, 0, len(allIDs))
|
err := m.routeSelector.DeselectRoutes(routesToDeselect, routesToDeselect)
|
||||||
for _, id := range allIDs {
|
if err != nil {
|
||||||
if id != preferred {
|
log.Warnf("Failed to deselect exit nodes: %v", err)
|
||||||
others = append(others, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(others) > 0 {
|
|
||||||
if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil {
|
|
||||||
log.Warnf("deselect other exit nodes: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if preferred != "" {
|
|
||||||
if err := m.routeSelector.SelectRoutes([]route.NetID{preferred}, true, allIDs); err != nil {
|
|
||||||
log.Warnf("select preferred exit node %q: %v", preferred, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.NetID) {
|
func (m *DefaultManager) selectExitNodesByManagement(selectedByManagement []route.NetID, allIDs []route.NetID) {
|
||||||
log.Debugf("Exit node selection: %d available, preferred=%q (%d user-selected, %d user-deselected, %d management-selected)",
|
if len(selectedByManagement) == 0 {
|
||||||
len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement))
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := m.routeSelector.SelectRoutes(selectedByManagement, true, allIDs)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("Failed to select exit nodes: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// minNetID returns the lexicographically smallest NetID, for a deterministic
|
func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo) {
|
||||||
// default pick that stays stable across restarts.
|
log.Debugf("Updated route selector: %d exit nodes available, %d selected by management, %d user-selected, %d user-deselected",
|
||||||
func minNetID(ids []route.NetID) route.NetID {
|
len(info.allIDs), len(info.selectedByManagement), len(info.userSelected), len(info.userDeselected))
|
||||||
if len(ids) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
best := ids[0]
|
|
||||||
for _, id := range ids[1:] {
|
|
||||||
if id < best {
|
|
||||||
best = id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,16 +124,6 @@ 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,34 +33,17 @@ 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) {
|
||||||
@@ -74,17 +57,6 @@ 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,19 +96,17 @@ func (m *Manager) Stop(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
cancel := m.cancel
|
defer m.mu.Unlock()
|
||||||
done := m.done
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
if cancel == nil {
|
if m.cancel == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cancel()
|
m.cancel()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-done:
|
case <-m.done:
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ type Client struct {
|
|||||||
dnsManager dns.IosDnsManager
|
dnsManager dns.IosDnsManager
|
||||||
loginComplete bool
|
loginComplete bool
|
||||||
connectClient *internal.ConnectClient
|
connectClient *internal.ConnectClient
|
||||||
|
// config holds the active configuration once Run has loaded it. Consumed by
|
||||||
|
// the in-app SSH client for the NetBird SSH key and the OAuth flow.
|
||||||
|
config *profilemanager.Config
|
||||||
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
|
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
|
||||||
preloadedConfig *profilemanager.Config
|
preloadedConfig *profilemanager.Config
|
||||||
}
|
}
|
||||||
@@ -160,6 +163,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
|||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
c.onHostDnsFn = func([]string) {}
|
c.onHostDnsFn = func([]string) {}
|
||||||
cfg.WgIface = interfaceName
|
cfg.WgIface = interfaceName
|
||||||
|
c.config = cfg
|
||||||
|
|
||||||
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
|
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile)
|
return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile)
|
||||||
@@ -527,6 +531,13 @@ func (c *Client) DeselectRoute(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sshState returns the active config and the running connect client for the
|
||||||
|
// in-app SSH client. Both are nil until Run has loaded the config and started
|
||||||
|
// the tunnel.
|
||||||
|
func (c *Client) sshState() (*profilemanager.Config, *internal.ConnectClient) {
|
||||||
|
return c.config, c.connectClient
|
||||||
|
}
|
||||||
|
|
||||||
func formatDuration(d time.Duration) string {
|
func formatDuration(d time.Duration) string {
|
||||||
ds := d.String()
|
ds := d.String()
|
||||||
dotIndex := strings.Index(ds, ".")
|
dotIndex := strings.Index(ds, ".")
|
||||||
|
|||||||
431
client/ios/NetBirdSDK/ssh_client.go
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
//go:build ios
|
||||||
|
|
||||||
|
package NetBirdSDK
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||||
|
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sshDialTimeout = 30 * time.Second
|
||||||
|
sshDetectionTimeout = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHTerminalListener receives SSH session events. It is implemented in Swift.
|
||||||
|
//
|
||||||
|
// All callbacks are invoked from goroutines and may run concurrently with each
|
||||||
|
// other; the implementation must be safe to call from any thread.
|
||||||
|
type SSHTerminalListener interface {
|
||||||
|
OnConnected()
|
||||||
|
OnData(data []byte)
|
||||||
|
OnClose(reason string)
|
||||||
|
OnError(message string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHClient is a NetBird-aware SSH client exposed to Swift via gomobile.
|
||||||
|
//
|
||||||
|
// It dials through the running NetBird tunnel and runs a standard SSH session
|
||||||
|
// on top with PTY enabled. Host-key verification uses the NetBird-provided
|
||||||
|
// peer SSH host keys, identical to the desktop client.
|
||||||
|
type SSHClient struct {
|
||||||
|
nb *Client
|
||||||
|
mu sync.Mutex
|
||||||
|
listener SSHTerminalListener
|
||||||
|
urlOpener URLOpener
|
||||||
|
|
||||||
|
sshClient *gossh.Client
|
||||||
|
session *gossh.Session
|
||||||
|
stdin io.WriteCloser
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
|
||||||
|
func NewSSHClient(c *Client) *SSHClient {
|
||||||
|
return &SSHClient{nb: c}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetListener registers the Swift listener. Must be called before Connect to
|
||||||
|
// receive any events.
|
||||||
|
func (s *SSHClient) SetListener(l SSHTerminalListener) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.listener = l
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetURLOpener registers the Swift URL opener used to display the device-code
|
||||||
|
// authorization page in an in-app browser when the target peer requires JWT
|
||||||
|
// authentication. Must be set before Connect to be effective.
|
||||||
|
func (s *SSHClient) SetURLOpener(opener URLOpener) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.urlOpener = opener
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect dials the SSH server through the NetBird tunnel and performs the
|
||||||
|
// SSH handshake. It auto-detects the server type via SSH banner inspection
|
||||||
|
// and selects the appropriate authentication path:
|
||||||
|
//
|
||||||
|
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
|
||||||
|
// flow, opens the verification URL through the registered URLOpener, and
|
||||||
|
// uses the resulting token as the SSH password. Host-key verification
|
||||||
|
// uses the NetBird peer registry.
|
||||||
|
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
|
||||||
|
// private key. Host-key verification uses the NetBird peer registry.
|
||||||
|
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
|
||||||
|
// first (so a user-installed NetBird public key works), then falls back
|
||||||
|
// to the supplied password if non-empty. Host-key verification is
|
||||||
|
// disabled (TOFU pending).
|
||||||
|
//
|
||||||
|
// The password parameter is only consulted for regular SSH servers.
|
||||||
|
func (s *SSHClient) Connect(host string, port int, user, password string) error {
|
||||||
|
cfg, cc := s.nb.sshState()
|
||||||
|
if cc == nil {
|
||||||
|
return errors.New("netbird client not running")
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return errors.New("netbird config not loaded")
|
||||||
|
}
|
||||||
|
engine := cc.Engine()
|
||||||
|
if engine == nil {
|
||||||
|
return errors.New("netbird engine not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
serverType := detectServerType(host, port)
|
||||||
|
log.Infof("SSH server type for %s:%d: %s", host, port, serverType)
|
||||||
|
|
||||||
|
authMethods, hostKeyCallback, err := s.buildAuth(cfg, engine, serverType, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
clientConfig := &gossh.ClientConfig{
|
||||||
|
User: user,
|
||||||
|
Auth: authMethods,
|
||||||
|
HostKeyCallback: hostKeyCallback,
|
||||||
|
Timeout: sshDialTimeout,
|
||||||
|
}
|
||||||
|
return s.dialAndHandshake(host, port, clientConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSession requests a PTY and starts an interactive shell. Output from
|
||||||
|
// the session is forwarded to the listener via OnData.
|
||||||
|
func (s *SSHClient) StartSession(cols, rows int) error {
|
||||||
|
log.Debugf("SSH: starting session %dx%d", cols, rows)
|
||||||
|
s.mu.Lock()
|
||||||
|
sshClient := s.sshClient
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if sshClient == nil {
|
||||||
|
return errors.New("ssh client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modes := gossh.TerminalModes{
|
||||||
|
gossh.ECHO: 1,
|
||||||
|
gossh.TTY_OP_ISPEED: 14400,
|
||||||
|
gossh.TTY_OP_OSPEED: 14400,
|
||||||
|
gossh.VINTR: 3,
|
||||||
|
gossh.VQUIT: 28,
|
||||||
|
gossh.VERASE: 127,
|
||||||
|
}
|
||||||
|
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
||||||
|
closeQuiet(session, "session after pty error")
|
||||||
|
return fmt.Errorf("request pty: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdin, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
closeQuiet(session, "session after stdin error")
|
||||||
|
return fmt.Errorf("stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
stdout, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
closeQuiet(session, "session after stdout error")
|
||||||
|
return fmt.Errorf("stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
stderr, err := session.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
closeQuiet(session, "session after stderr error")
|
||||||
|
return fmt.Errorf("stderr pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Shell(); err != nil {
|
||||||
|
closeQuiet(session, "session after shell error")
|
||||||
|
return fmt.Errorf("start shell: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.session = session
|
||||||
|
s.stdin = stdin
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go s.readLoop(stdout, "stdout")
|
||||||
|
go s.readLoop(stderr, "stderr")
|
||||||
|
log.Debug("SSH: session started, shell running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends data to the SSH session stdin.
|
||||||
|
func (s *SSHClient) Write(data []byte) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
stdin := s.stdin
|
||||||
|
s.mu.Unlock()
|
||||||
|
if stdin == nil {
|
||||||
|
return errors.New("ssh session not started")
|
||||||
|
}
|
||||||
|
if _, err := stdin.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("write stdin: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize updates the PTY window size.
|
||||||
|
func (s *SSHClient) Resize(cols, rows int) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
session := s.session
|
||||||
|
s.mu.Unlock()
|
||||||
|
if session == nil {
|
||||||
|
return errors.New("ssh session not started")
|
||||||
|
}
|
||||||
|
return session.WindowChange(rows, cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the SSH session and underlying connection. Safe to call
|
||||||
|
// multiple times.
|
||||||
|
func (s *SSHClient) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
sshClient := s.sshClient
|
||||||
|
session := s.session
|
||||||
|
stdin := s.stdin
|
||||||
|
s.sshClient = nil
|
||||||
|
s.session = nil
|
||||||
|
s.stdin = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if stdin != nil {
|
||||||
|
if err := stdin.Close(); err != nil {
|
||||||
|
log.Debugf("ssh: stdin close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session != nil {
|
||||||
|
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
log.Debugf("ssh: session close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var firstErr error
|
||||||
|
if sshClient != nil {
|
||||||
|
if err := sshClient.Close(); err != nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.notifyClose("closed by client")
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, engine *internal.Engine,
|
||||||
|
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
|
||||||
|
|
||||||
|
switch serverType {
|
||||||
|
case detection.ServerTypeNetBirdJWT:
|
||||||
|
token, err := s.requestJWTToken(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("jwt: %w", err)
|
||||||
|
}
|
||||||
|
auths := []gossh.AuthMethod{gossh.Password(token)}
|
||||||
|
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
|
||||||
|
|
||||||
|
case detection.ServerTypeNetBirdNoJWT:
|
||||||
|
if cfg.SSHKey == "" {
|
||||||
|
return nil, nil, errors.New("no NetBird SSH key available")
|
||||||
|
}
|
||||||
|
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
|
||||||
|
}
|
||||||
|
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
|
||||||
|
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
|
||||||
|
|
||||||
|
default: // regular SSH
|
||||||
|
var auths []gossh.AuthMethod
|
||||||
|
if cfg.SSHKey != "" {
|
||||||
|
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
|
||||||
|
auths = append(auths, gossh.PublicKeys(signer))
|
||||||
|
} else {
|
||||||
|
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
pw := password
|
||||||
|
auths = append(auths, gossh.Password(pw))
|
||||||
|
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
|
||||||
|
answers := make([]string, len(questions))
|
||||||
|
for i := range questions {
|
||||||
|
answers[i] = pw
|
||||||
|
}
|
||||||
|
return answers, nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if len(auths) == 0 {
|
||||||
|
return nil, nil, errors.New("no auth method available: provide a password or configure NetBird SSH key")
|
||||||
|
}
|
||||||
|
return auths, gossh.InsecureIgnoreHostKey(), nil // nolint:gosec // TOFU not yet implemented
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
urlOpener := s.urlOpener
|
||||||
|
s.mu.Unlock()
|
||||||
|
if urlOpener == nil {
|
||||||
|
return "", errors.New("URL opener not configured for JWT auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profilemanager.GetLoginHint())
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create oauth flow: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flowInfo, err := flow.RequestAuthInfo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request auth info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
||||||
|
|
||||||
|
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("wait for token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := tokenInfo.GetTokenToUse()
|
||||||
|
if token == "" {
|
||||||
|
return "", errors.New("empty token returned by IdP")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) dialAndHandshake(host string, port int, clientConfig *gossh.ClientConfig) error {
|
||||||
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
log.Infof("SSH: connecting to %s as %s", addr, clientConfig.User)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var dialer net.Dialer
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn, chans, reqs, err := gossh.NewClientConn(conn, addr, clientConfig)
|
||||||
|
if err != nil {
|
||||||
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
log.Debugf("ssh: close after handshake error: %v", cerr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ssh handshake: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sshClient = gossh.NewClient(sshConn, chans, reqs)
|
||||||
|
listener := s.listener
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
log.Infof("SSH: connected to %s", addr)
|
||||||
|
if listener != nil {
|
||||||
|
listener.OnConnected()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) readLoop(r io.Reader, name string) {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
s.mu.Lock()
|
||||||
|
listener := s.listener
|
||||||
|
s.mu.Unlock()
|
||||||
|
if listener != nil {
|
||||||
|
chunk := make([]byte, n)
|
||||||
|
copy(chunk, buf[:n])
|
||||||
|
listener.OnData(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, io.EOF) {
|
||||||
|
log.Debugf("ssh %s read: %v", name, err)
|
||||||
|
}
|
||||||
|
s.notifyClose(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) notifyClose(reason string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
listener := s.listener
|
||||||
|
s.mu.Unlock()
|
||||||
|
if listener != nil {
|
||||||
|
listener.OnClose(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// engineHostKeyVerifier adapts *internal.Engine to nbssh.HostKeyVerifier.
|
||||||
|
type engineHostKeyVerifier struct {
|
||||||
|
engine *internal.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *engineHostKeyVerifier) VerifySSHHostKey(peerAddress string, presented []byte) error {
|
||||||
|
storedKey, found := v.engine.GetPeerSSHKey(peerAddress)
|
||||||
|
if !found {
|
||||||
|
return nbssh.ErrPeerNotFound
|
||||||
|
}
|
||||||
|
return nbssh.VerifyHostKey(storedKey, presented, peerAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectServerType(host string, port int) detection.ServerType {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dialer := &net.Dialer{}
|
||||||
|
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("ssh: server detection for %s:%d failed: %v (assuming regular SSH)", host, port, err)
|
||||||
|
return detection.ServerTypeRegular
|
||||||
|
}
|
||||||
|
return serverType
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeQuiet(c io.Closer, label string) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
log.Debugf("ssh: close %s: %v", label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
</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"
|
||||||
@@ -59,14 +62,6 @@
|
|||||||
<Component Id="NetbirdAumidRegistry" Guid="*">
|
<Component Id="NetbirdAumidRegistry" Guid="*">
|
||||||
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
||||||
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
||||||
<!-- Pre-seed the CLSID the Wails notifications service reads on
|
|
||||||
first startup (notifications_windows.go:getGUID looks for
|
|
||||||
the CustomActivator value under this key). Without this
|
|
||||||
the service generates a fresh per-install UUID, which
|
|
||||||
diverges from the ToastActivatorCLSID set on the Start
|
|
||||||
Menu / Desktop shortcuts above and the COM activator
|
|
||||||
never fires when a toast is clicked. -->
|
|
||||||
<RegistryValue Name="CustomActivator" Type="string" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
|
|
||||||
</RegistryKey>
|
</RegistryKey>
|
||||||
</Component>
|
</Component>
|
||||||
</StandardDirectory>
|
</StandardDirectory>
|
||||||
@@ -90,40 +85,10 @@
|
|||||||
<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\build\windows\icon.ico" />
|
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\assets\netbird.ico" />
|
||||||
<Property Id="ARPPRODUCTICON" Value="NetbirdIcon" />
|
<Property Id="ARPPRODUCTICON" Value="NetbirdIcon" />
|
||||||
|
|
||||||
</Package>
|
</Package>
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ 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) {}
|
||||||
|
|
||||||
@@ -115,25 +109,6 @@ 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) {}
|
||||||
|
|
||||||
@@ -252,12 +227,6 @@ 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 {}
|
||||||
@@ -275,10 +244,6 @@ 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 {}
|
||||||
@@ -443,12 +408,6 @@ 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
|
||||||
@@ -839,55 +798,6 @@ 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,17 +172,6 @@ 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())
|
||||||
|
|
||||||
@@ -260,38 +249,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/route"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExitNodeSelectionHelpers(t *testing.T) {
|
|
||||||
routesMap := map[route.NetID][]*route.Route{
|
|
||||||
"exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}},
|
|
||||||
"exitB": {{Network: netip.MustParsePrefix("::/0")}},
|
|
||||||
"lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node")
|
|
||||||
assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node")
|
|
||||||
assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node")
|
|
||||||
assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node")
|
|
||||||
|
|
||||||
others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"})
|
|
||||||
assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored")
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@ 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"
|
||||||
@@ -68,12 +67,6 @@ 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
|
||||||
@@ -130,7 +123,6 @@ 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)
|
||||||
@@ -148,15 +140,6 @@ 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)
|
||||||
@@ -237,20 +220,10 @@ 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 {
|
||||||
@@ -285,15 +258,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -305,6 +269,10 @@ 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
|
||||||
@@ -373,7 +341,9 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
|||||||
}
|
}
|
||||||
|
|
||||||
if msg.OptionalPreSharedKey != nil {
|
if msg.OptionalPreSharedKey != nil {
|
||||||
config.PreSharedKey = msg.OptionalPreSharedKey
|
if *msg.OptionalPreSharedKey != "" {
|
||||||
|
config.PreSharedKey = msg.OptionalPreSharedKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.CleanDNSLabels {
|
if msg.CleanDNSLabels {
|
||||||
@@ -599,35 +569,8 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
return &proto.LoginResponse{}, nil
|
return &proto.LoginResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitSSOLogin validates the supplied userCode against the in-flight OAuth
|
// WaitSSOLogin uses the userCode to validate the TokenInfo and
|
||||||
// device/PKCE flow and blocks until the user finishes the browser leg.
|
// waits for the user to continue with the login on a browser
|
||||||
//
|
|
||||||
// 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 {
|
||||||
@@ -687,21 +630,7 @@ 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()
|
||||||
switch {
|
state.Set(internal.StatusLoginFailed)
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -816,9 +745,6 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
|||||||
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,37 +844,23 @@ 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 by the goroutine's deferred cleanup (see
|
// The giveUpChan is closed at the end of connectWithRetryRuns.
|
||||||
// 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, giveUpChan closed")
|
log.Debugf("client goroutine finished successfully")
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1202,23 +1114,9 @@ func (s *Server) Status(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.buildStatusResponse(msg)
|
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
||||||
// state.Status() blanks the status when err is set (e.g. management
|
return nil, err
|
||||||
// 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() {
|
||||||
@@ -1229,10 +1127,6 @@ func (s *Server) buildStatusResponse(msg *proto.StatusRequest) (*proto.StatusRes
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -1242,7 +1136,6 @@ func (s *Server) buildStatusResponse(msg *proto.StatusRequest) (*proto.StatusRes
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1463,144 +1356,6 @@ 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()
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SubscribeStatus pushes a fresh StatusResponse on every connection state
|
|
||||||
// change. The first message is the current snapshot, so a re-subscribing
|
|
||||||
// client doesn't need to also call Status. Subsequent messages fire when
|
|
||||||
// the peer recorder reports any of: connected/disconnected/connecting,
|
|
||||||
// management or signal flip, address change, or peers list change.
|
|
||||||
//
|
|
||||||
// The change channel coalesces bursts to a single tick. If the consumer
|
|
||||||
// is slow the daemon drops extras (not blocks), and the next snapshot
|
|
||||||
// the consumer pulls already reflects everything.
|
|
||||||
func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
|
|
||||||
subID, ch := s.statusRecorder.SubscribeToStateChanges()
|
|
||||||
defer func() {
|
|
||||||
s.statusRecorder.UnsubscribeFromStateChanges(subID)
|
|
||||||
log.Debug("client unsubscribed from status updates")
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Debug("client subscribed to status updates")
|
|
||||||
|
|
||||||
if err := s.sendStatusSnapshot(req, stream); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case _, ok := <-ch:
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := s.sendStatusSnapshot(req, stream); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case <-stream.Context().Done():
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
|
|
||||||
resp, err := s.buildStatusResponse(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,11 +1,3 @@
|
|||||||
// This file is intentionally named test.go (not test_test.go) so the exported
|
|
||||||
// StartTestServer helper is visible to the ssh/proxy and ssh/client external
|
|
||||||
// test packages, not just this package's own tests. The //go:build !js tag
|
|
||||||
// keeps its "testing" import — and the whole testing/flag/regexp transitive
|
|
||||||
// chain it drags in — out of the wasm client, which links ssh/server through
|
|
||||||
// the engine but never runs Go tests under GOOS=js.
|
|
||||||
//go:build !js
|
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -55,10 +55,6 @@ 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 {
|
||||||
@@ -157,11 +153,6 @@ 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.
|
||||||
@@ -207,10 +198,6 @@ 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())
|
||||||
@@ -548,15 +535,6 @@ 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)
|
||||||
@@ -587,7 +565,6 @@ 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,
|
||||||
@@ -606,7 +583,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
|||||||
sshServerStatus,
|
sshServerStatus,
|
||||||
networks,
|
networks,
|
||||||
forwardingRulesString,
|
forwardingRulesString,
|
||||||
sessionExpiryString,
|
|
||||||
peersCountString,
|
peersCountString,
|
||||||
)
|
)
|
||||||
return summary
|
return summary
|
||||||
@@ -1020,57 +996,3 @@ 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,50 +641,3 @@ func TestTimeAgo(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHumaniseDuration(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
in time.Duration
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{0, "1s"},
|
|
||||||
{500 * time.Millisecond, "1s"},
|
|
||||||
{8 * time.Second, "8s"},
|
|
||||||
{59 * time.Second, "59s"},
|
|
||||||
{time.Minute, "1m"},
|
|
||||||
{47*time.Minute + 12*time.Second, "47m"},
|
|
||||||
{time.Hour, "1h"},
|
|
||||||
{2*time.Hour + 15*time.Minute, "2h 15m"},
|
|
||||||
{2 * time.Hour, "2h"},
|
|
||||||
{24 * time.Hour, "1d"},
|
|
||||||
{2*24*time.Hour + 3*time.Hour, "2d 3h"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
got := HumaniseDuration(tc.in)
|
|
||||||
assert.Equal(t, tc.want, got, "input %s", tc.in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatRemainingDuration_Expired(t *testing.T) {
|
|
||||||
assert.Equal(t, "expired 3m ago", FormatRemainingDuration(-3*time.Minute))
|
|
||||||
assert.Equal(t, "expired 1s ago", FormatRemainingDuration(-500*time.Millisecond))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionExpiresLineRendered(t *testing.T) {
|
|
||||||
in := overview // copy of the package-level fixture
|
|
||||||
deadline := time.Now().Add(2*time.Hour + 30*time.Minute).UTC()
|
|
||||||
in.SessionExpiresAt = &deadline
|
|
||||||
|
|
||||||
out := in.GeneralSummary(false, false, false, false)
|
|
||||||
assert.Contains(t, out, "Session expires: ")
|
|
||||||
assert.Contains(t, out, deadline.Format(time.RFC3339))
|
|
||||||
// 2h 30m drifts to "2h 29m" within 60s — match the family prefix.
|
|
||||||
assert.Contains(t, out, "(in 2h ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionExpiresLineOmittedWhenNil(t *testing.T) {
|
|
||||||
in := overview
|
|
||||||
in.SessionExpiresAt = nil
|
|
||||||
out := in.GeneralSummary(false, false, false, false)
|
|
||||||
assert.NotContains(t, out, "Session expires")
|
|
||||||
}
|
|
||||||
|
|||||||
8
client/ui/.gitignore
vendored
@@ -1,8 +0,0 @@
|
|||||||
.task
|
|
||||||
bin
|
|
||||||
frontend/dist
|
|
||||||
frontend/node_modules
|
|
||||||
frontend/bindings
|
|
||||||
frontend/.vite
|
|
||||||
build/linux/appimage/build
|
|
||||||
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# NetBird Wails UI — Working Notes
|
|
||||||
|
|
||||||
This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; the React/TS frontend lives in `frontend/`; bindings between them are generated under `frontend/bindings/`.
|
|
||||||
|
|
||||||
> **Keep these notes current.** When working in this directory with Claude, update this file (and `frontend/CLAUDE.md` for frontend-only changes) whenever you add a service, change an event name, shift a convention, rename a key directory, or land any other change that future-you would want to know about before reading the code. The goal is that a cold-start agent can orient itself from these notes without re-deriving the codebase.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
### Go (top-level package `main`)
|
|
||||||
- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch` → `app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; first user-provided value drops the seeded `console` default), `--log-level` (`trace|debug|info|warn|error`, default `info`).
|
|
||||||
- `tray.go` — `Tray` struct + menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast.
|
|
||||||
- `tray_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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Linux tray support (StatusNotifierWatcher + XEmbed)
|
|
||||||
|
|
||||||
Minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the AppIndicator extension) don't ship a `StatusNotifierWatcher`, so tray icons using libayatana-appindicator / freedesktop StatusNotifier silently fail. `main.go` calls `startStatusNotifierWatcher()` *before* `NewTray` so the Wails systray's `RegisterStatusNotifierItem` call hits the in-process watcher we control.
|
|
||||||
|
|
||||||
- `tray_watcher_linux.go` — owns `org.kde.StatusNotifierWatcher` on the session bus if no other process has it. Safe to call unconditionally.
|
|
||||||
- `xembed_host_linux.go` + `xembed_tray_linux.{c,h}` — when an XEmbed tray (`_NET_SYSTEM_TRAY_S0`) is available, also start an in-process XEmbed host that bridges the SNI icon into the XEmbed tray. Reads `IconPixmap` over D-Bus, draws via cairo+X11, polls for clicks, fetches `com.canonical.dbusmenu.GetLayout` for the popup menu, fires `com.canonical.dbusmenu.Event` on click.
|
|
||||||
|
|
||||||
Build is gated on `linux && !386`; the 386 build (no cgo) and non-Linux builds use the `tray_watcher_other.go` no-op.
|
|
||||||
BIN
client/ui/Netbird.icns
Normal file
@@ -1,100 +0,0 @@
|
|||||||
# NetBird desktop UI (Wails3 + React)
|
|
||||||
|
|
||||||
Replaces `client/ui` (Fyne). One binary on Windows / macOS / Linux,
|
|
||||||
talks to the NetBird daemon over gRPC, renders a React frontend in a
|
|
||||||
WebView.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Go ≥ 1.25, Node ≥ 20, **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`)
|
|
||||||
- `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
|
|
||||||
- `task`: `go install github.com/go-task/task/v3/cmd/task@latest`
|
|
||||||
- A running NetBird daemon (default: `unix:///var/run/netbird.sock`,
|
|
||||||
Windows `tcp://127.0.0.1:41731`)
|
|
||||||
- Linux only: `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.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
includes:
|
|
||||||
common: ./build/Taskfile.yml
|
|
||||||
windows: ./build/windows/Taskfile.yml
|
|
||||||
darwin: ./build/darwin/Taskfile.yml
|
|
||||||
linux: ./build/linux/Taskfile.yml
|
|
||||||
|
|
||||||
vars:
|
|
||||||
APP_NAME: "netbird-ui"
|
|
||||||
BIN_DIR: "bin"
|
|
||||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
build:
|
|
||||||
summary: Builds the application
|
|
||||||
cmds:
|
|
||||||
- task: "{{OS}}:build"
|
|
||||||
|
|
||||||
package:
|
|
||||||
summary: Packages a production build of the application
|
|
||||||
cmds:
|
|
||||||
- task: "{{OS}}:package"
|
|
||||||
|
|
||||||
run:
|
|
||||||
summary: Runs the application
|
|
||||||
cmds:
|
|
||||||
- task: "{{OS}}:run"
|
|
||||||
|
|
||||||
dev:
|
|
||||||
summary: Runs the application in development mode
|
|
||||||
cmds:
|
|
||||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
|
||||||
|
|
||||||
setup:docker:
|
|
||||||
summary: Builds Docker image for cross-compilation (~800MB download)
|
|
||||||
cmds:
|
|
||||||
- task: common:setup:docker
|
|
||||||
|
|
||||||
build:server:
|
|
||||||
summary: Builds the application in server mode (no GUI, HTTP server only)
|
|
||||||
cmds:
|
|
||||||
- task: common:build:server
|
|
||||||
|
|
||||||
run:server:
|
|
||||||
summary: Runs the application in server mode
|
|
||||||
cmds:
|
|
||||||
- task: common:run:server
|
|
||||||
|
|
||||||
build:docker:
|
|
||||||
summary: Builds a Docker image for server mode deployment
|
|
||||||
cmds:
|
|
||||||
- task: common:build:docker
|
|
||||||
|
|
||||||
run:docker:
|
|
||||||
summary: Builds and runs the Docker image
|
|
||||||
cmds:
|
|
||||||
- task: common:run:docker
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Wails Dialogs (frontend, `@wailsio/runtime`)
|
|
||||||
|
|
||||||
The frontend dialog API lives in `@wailsio/runtime` as `Dialogs`. Authoritative signatures are in
|
|
||||||
`frontend/node_modules/@wailsio/runtime/types/dialogs.d.ts`.
|
|
||||||
|
|
||||||
See `CLAUDE.md` for project conventions on *when* to use these (errors vs. inline validation, confirmation flow, etc.).
|
|
||||||
|
|
||||||
## Message dialogs
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { Dialogs } from "@wailsio/runtime";
|
|
||||||
|
|
||||||
await Dialogs.Info({ Title, Message, Buttons?, Detached? });
|
|
||||||
await Dialogs.Warning({ Title, Message, Buttons?, Detached? });
|
|
||||||
await Dialogs.Error({ Title, Message, Buttons?, Detached? });
|
|
||||||
await Dialogs.Question({ Title, Message, Buttons?, Detached? });
|
|
||||||
```
|
|
||||||
|
|
||||||
All four return `Promise<string>` resolving to the **Label** of the button the user clicked. With no `Buttons` provided you get a single OK button — the promise just resolves when the user dismisses.
|
|
||||||
|
|
||||||
`MessageDialogOptions` fields:
|
|
||||||
- `Title?: string` — window title (short).
|
|
||||||
- `Message?: string` — the body text.
|
|
||||||
- `Buttons?: Button[]` — custom buttons. Each `Button` is `{ Label?, IsCancel?, IsDefault? }`. `IsCancel` is what Esc/⌘. triggers; `IsDefault` is what Enter triggers.
|
|
||||||
- `Detached?: boolean` — when `true`, the dialog isn't tied to the parent window (no sheet behavior on macOS).
|
|
||||||
|
|
||||||
## File dialogs
|
|
||||||
|
|
||||||
`Dialogs.OpenFile(options)` and `Dialogs.SaveFile(options)` — see `dialogs.d.ts` for the full `OpenFileDialogOptions` / `SaveFileDialogOptions` field set (filters, ButtonText, multi-select, hidden files, alias resolution, directory mode, etc).
|
|
||||||
|
|
||||||
## Per-OS behavior
|
|
||||||
|
|
||||||
| Platform | Behavior |
|
|
||||||
|---|---|
|
|
||||||
| **macOS** | Sheet-style when attached to a parent window. Up to ~4 custom buttons render naturally. Keyboard: Enter = default, ⌘. or Esc = cancel. Follows system theme. Accessibility is built-in. |
|
|
||||||
| **Windows** | Modal `TaskDialog`-style. Standard button labels are nudged toward OS conventions. Keyboard: Enter = default, Esc = cancel. Follows system theme. |
|
|
||||||
| **Linux** | GTK dialogs — appearance varies by desktop environment (GNOME/KDE). Follows desktop theme. Standard keyboard nav. |
|
|
||||||
|
|
||||||
Behavioural notes that affect us:
|
|
||||||
- The promise resolves with the **button label string**, not an index. Compare against the literal `Label` you passed (e.g. `if (result !== "Delete") return;`).
|
|
||||||
- `Buttons[]` on Linux/Windows uses the labels you supply, but the OS layout/styling is fixed.
|
|
||||||
- `Dialogs.Error` plays the platform error sound and uses the platform error icon. Don't use it for confirmations — use `Dialogs.Warning` or `Dialogs.Question`.
|
|
||||||
- Don't fire dialogs in a tight loop or from every keystroke — they interrupt focus and (on macOS) animate in/out. Debounce or guard with a `busy` flag.
|
|
||||||
|
|
||||||
## Frameless / custom-window dialogs (Go side)
|
|
||||||
|
|
||||||
When the native dialog API isn't enough — rich content, embedded webview, multi-screen flow — open a regular Wails window. This is done on the **Go side** via `app.Window.NewWithOptions(application.WebviewWindowOptions{...})`. Useful options:
|
|
||||||
- `Parent` — attach to a parent so OS treats it as a child.
|
|
||||||
- `AlwaysOnTop: true` — float above the parent.
|
|
||||||
- `Frameless: true` — no titlebar/chrome.
|
|
||||||
- `Resizable: false` (also `DisableResize: true` in v3) — fixed-size dialog feel.
|
|
||||||
- `Hidden: true` initially, then `dialog.Show()` + `dialog.SetFocus()`.
|
|
||||||
|
|
||||||
We **do** use this pattern, but pragmatically: `WindowManager.OpenSettings` and `OpenBrowserLogin` are regular small webview windows (not modal sheets) with no resize, hidden minimise/maximise buttons, and a translucent macOS title bar. They're not classic "OS modal dialogs"; they're just lightweight ancillary windows that look the part. Modal behaviour (`parent.SetEnabled(false)`) is intentionally not used — the user can still click back to the main window.
|
|
||||||
|
|
||||||
In-app modals (`NewProfileDialog`, delete-profile confirmation, etc.) are Radix `Dialog` primitives inside the main webview. Reach for a custom OS window only when content must escape the main window (BrowserLogin is the canonical example — its lifecycle is tied to the SSO wait) or when the window needs its own taskbar entry / dock icon.
|
|
||||||
BIN
client/ui/assets/connected.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
client/ui/assets/disconnected.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
client/ui/assets/netbird-disconnected.ico
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
client/ui/assets/netbird-disconnected.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 526 B |
|
Before Width: | Height: | Size: 739 B |
|
Before Width: | Height: | Size: 838 B |
|
Before Width: | Height: | Size: 508 B |
|
Before Width: | Height: | Size: 615 B |
|
Before Width: | Height: | Size: 452 B |
|
Before Width: | Height: | Size: 520 B |
|
Before Width: | Height: | Size: 637 B |
|
Before Width: | Height: | Size: 452 B |
|
Before Width: | Height: | Size: 532 B |
|
Before Width: | Height: | Size: 629 B |
|
Before Width: | Height: | Size: 433 B |
|
Before Width: | Height: | Size: 490 B |
|
Before Width: | Height: | Size: 602 B |
|
Before Width: | Height: | Size: 483 B |
|
Before Width: | Height: | Size: 512 B |
|
Before Width: | Height: | Size: 605 B |
|
Before Width: | Height: | Size: 456 B |
BIN
client/ui/assets/netbird-systemtray-connected-dark.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.8 KiB |
BIN
client/ui/assets/netbird-systemtray-connected.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
client/ui/assets/netbird-systemtray-connecting-dark.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.8 KiB |
BIN
client/ui/assets/netbird-systemtray-connecting.ico
Normal file
|
After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |