mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-31 21:19:55 +00:00
Compare commits
92 Commits
ui-refacto
...
embedded-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
144dfbc12c | ||
|
|
6c9465df54 | ||
|
|
6cd5d6084f | ||
|
|
3bcacffd2c | ||
|
|
65f302b698 | ||
|
|
2f67841b1e | ||
|
|
bf2fb2fd44 | ||
|
|
4e3e3ce6d3 | ||
|
|
5e2830be8a | ||
|
|
f557e665a5 | ||
|
|
fa57eedaf5 | ||
|
|
7cb6388349 | ||
|
|
1f912be673 | ||
|
|
8d329da591 | ||
|
|
8e72967bbe | ||
|
|
c29ef638f4 | ||
|
|
97b7b010f5 | ||
|
|
030c57150f | ||
|
|
0f03c612d1 | ||
|
|
1cc5967198 | ||
|
|
412193c602 | ||
|
|
5e67febf57 | ||
|
|
ee348ba007 | ||
|
|
3d3055dc7f | ||
|
|
2f4ddf0796 | ||
|
|
98d533c8e8 | ||
|
|
ef4ea2e311 | ||
|
|
b41d11bbbe | ||
|
|
f37e228cc2 | ||
|
|
640a267556 | ||
|
|
17359cdc1e | ||
|
|
7e5846a1ee | ||
|
|
517bea0daf | ||
|
|
896530fd82 | ||
|
|
354fd004c7 | ||
|
|
c28e41e82b | ||
|
|
02b9fe704b | ||
|
|
5e200fa571 | ||
|
|
7d61975f6c | ||
|
|
62b36112ea | ||
|
|
df9a6fb020 | ||
|
|
b1b04f9ec6 | ||
|
|
fe15688f20 | ||
|
|
2285db2b62 | ||
|
|
b3f0f53a23 | ||
|
|
5eec9962ba | ||
|
|
393c102f45 | ||
|
|
b41fbad5e1 | ||
|
|
24a5f2252c | ||
|
|
9d189bb3e8 | ||
|
|
8e2505b59c | ||
|
|
97bc1eebde | ||
|
|
32a5a061b8 | ||
|
|
d927ef468a | ||
|
|
d3f3e08035 | ||
|
|
6bb66e0fad | ||
|
|
bc407527f4 | ||
|
|
5543404188 | ||
|
|
c2fdf62f1f | ||
|
|
b9f5264e36 | ||
|
|
97d0a6776f | ||
|
|
7e7e056f3a | ||
|
|
785f94d13f | ||
|
|
bfb6750b13 | ||
|
|
f5e1057127 | ||
|
|
ee393d0e62 | ||
|
|
0b8fc5da59 | ||
|
|
2d0a54f31a | ||
|
|
61ec8d67de | ||
|
|
76add0b9b2 | ||
|
|
a11341f57a | ||
|
|
b135d462d6 | ||
|
|
da37a28951 | ||
|
|
4f884d9f30 | ||
|
|
2bed8b641b | ||
|
|
b4f696272a | ||
|
|
6d937af7a0 | ||
|
|
db5b6cfbb7 | ||
|
|
e75948753a | ||
|
|
047cc958b5 | ||
|
|
cd005ef9a9 | ||
|
|
44ed0c1992 | ||
|
|
d6d3fa95c7 | ||
|
|
fa90283781 | ||
|
|
8bf13b0d0c | ||
|
|
a8541a1529 | ||
|
|
94068d3ebc | ||
|
|
738c585ee7 | ||
|
|
9b5541d17d | ||
|
|
7123e6d1f4 | ||
|
|
62cf9e873b | ||
|
|
9f0aa1ce26 |
45
.github/dependabot.yml
vendored
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
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
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
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
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
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
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
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
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
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
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
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
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;
|
||||||
|
|||||||
68
.github/workflows/proto-version-check.yml
vendored
68
.github/workflows/proto-version-check.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check for proto tool version changes
|
- name: Check for proto tool version changes
|
||||||
uses: actions/github-script@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, {
|
||||||
@@ -20,66 +20,34 @@ jobs:
|
|||||||
per_page: 100,
|
per_page: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
const modifiedPbFiles = files.filter(
|
const pbFiles = files.filter(f => f.filename.endsWith('.pb.go'));
|
||||||
f => f.filename.endsWith('.pb.go') && f.status === 'modified'
|
const missingPatch = pbFiles.filter(f => !f.patch).map(f => f.filename);
|
||||||
);
|
if (missingPatch.length > 0) {
|
||||||
if (modifiedPbFiles.length === 0) {
|
core.setFailed(
|
||||||
console.log('No modified .pb.go files to check');
|
`Cannot inspect patch data for:\n` +
|
||||||
|
missingPatch.map(f => `- ${f}`).join('\n') +
|
||||||
|
`\nThis can happen with very large PRs. Verify proto versions manually.`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const versionPattern = /^[+-]\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
||||||
const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
|
||||||
const baseSha = context.payload.pull_request.base.sha;
|
|
||||||
const headSha = context.payload.pull_request.head.sha;
|
|
||||||
|
|
||||||
async function getVersionHeader(path, ref) {
|
|
||||||
try {
|
|
||||||
const res = await github.rest.repos.getContent({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
path,
|
|
||||||
ref,
|
|
||||||
});
|
|
||||||
if (!res.data.content) {
|
|
||||||
return { ok: false, reason: 'no inline content (file too large)' };
|
|
||||||
}
|
|
||||||
const content = Buffer.from(res.data.content, 'base64').toString('utf8');
|
|
||||||
const lines = content
|
|
||||||
.split('\n')
|
|
||||||
.slice(0, 20)
|
|
||||||
.filter(line => versionPattern.test(line));
|
|
||||||
return { ok: true, lines };
|
|
||||||
} catch (e) {
|
|
||||||
return { ok: false, reason: e.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const violations = [];
|
const violations = [];
|
||||||
for (const file of modifiedPbFiles) {
|
|
||||||
const [base, head] = await Promise.all([
|
for (const file of pbFiles) {
|
||||||
getVersionHeader(file.filename, baseSha),
|
const changed = file.patch
|
||||||
getVersionHeader(file.filename, headSha),
|
.split('\n')
|
||||||
]);
|
.filter(line => versionPattern.test(line));
|
||||||
if (!base.ok || !head.ok) {
|
if (changed.length > 0) {
|
||||||
core.warning(
|
|
||||||
`Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (base.lines.join('\n') !== head.lines.join('\n')) {
|
|
||||||
violations.push({
|
violations.push({
|
||||||
file: file.filename,
|
file: file.filename,
|
||||||
base: base.lines,
|
lines: changed,
|
||||||
head: head.lines,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (violations.length > 0) {
|
if (violations.length > 0) {
|
||||||
const details = violations.map(v =>
|
const details = violations.map(v =>
|
||||||
`${v.file}:\n` +
|
`${v.file}:\n${v.lines.map(l => ' ' + l).join('\n')}`
|
||||||
` base:\n${v.base.map(l => ' ' + l).join('\n') || ' (none)'}\n` +
|
|
||||||
` head:\n${v.head.map(l => ' ' + l).join('\n') || ' (none)'}`
|
|
||||||
).join('\n\n');
|
).join('\n\n');
|
||||||
|
|
||||||
core.setFailed(
|
core.setFailed(
|
||||||
|
|||||||
241
.github/workflows/release.yml
vendored
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
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
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
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
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 }}" }'
|
||||||
21
.github/workflows/wasm-build-validation.yml
vendored
21
.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
|
||||||
@@ -65,7 +61,8 @@ jobs:
|
|||||||
|
|
||||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||||
|
|
||||||
if [ ${SIZE} -gt 58720256 ]; then
|
if [ ${SIZE} -gt 62914560 ]; then
|
||||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB 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
|
||||||
|
|||||||
153
README.md
153
README.md
@@ -1,134 +1,147 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<p align="center">
|
<br/>
|
||||||
<img width="234" src="docs/media/logo-full.png" alt="NetBird logo"/>
|
<br/>
|
||||||
</p>
|
<p align="center">
|
||||||
<p align="center">
|
<img width="234" src="docs/media/logo-full.png"/>
|
||||||
<a href="https://sonarcloud.io/dashboard?id=netbirdio_netbird">
|
</p>
|
||||||
<img src="https://sonarcloud.io/api/project_badges/measure?project=netbirdio_netbird&metric=alert_status" alt="SonarCloud alert status"/>
|
<p>
|
||||||
</a>
|
<a href="https://img.shields.io/badge/license-BSD--3-blue)">
|
||||||
<a href="https://github.com/netbirdio/netbird/blob/main/LICENSE">
|
<img src="https://sonarcloud.io/api/project_badges/measure?project=netbirdio_netbird&metric=alert_status" />
|
||||||
<img src="https://img.shields.io/badge/license-BSD--3-blue" alt="BSD-3 License"/>
|
</a>
|
||||||
</a>
|
<a href="https://github.com/netbirdio/netbird/blob/main/LICENSE">
|
||||||
|
<img src="https://img.shields.io/badge/license-BSD--3-blue" />
|
||||||
|
</a>
|
||||||
|
<br>
|
||||||
<a href="https://docs.netbird.io/slack-url">
|
<a href="https://docs.netbird.io/slack-url">
|
||||||
<img src="https://img.shields.io/badge/slack-@netbird-red.svg?logo=slack" alt="NetBird Slack"/>
|
<img src="https://img.shields.io/badge/slack-@netbird-red.svg?logo=slack"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://forum.netbird.io">
|
<a href="https://forum.netbird.io">
|
||||||
<img src="https://img.shields.io/badge/community%20forum-@netbird-red.svg?logo=discourse" alt="Community forum"/>
|
<img src="https://img.shields.io/badge/community forum-@netbird-red.svg?logo=discourse"/>
|
||||||
</a>
|
</a>
|
||||||
|
<br>
|
||||||
<a href="https://gurubase.io/g/netbird">
|
<a href="https://gurubase.io/g/netbird">
|
||||||
<img src="https://img.shields.io/badge/Gurubase-Ask%20NetBird%20Guru-006BFF" alt="Gurubase: Ask NetBird Guru"/>
|
<img src="https://img.shields.io/badge/Gurubase-Ask%20NetBird%20Guru-006BFF"/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>
|
<strong>
|
||||||
Start using NetBird at <a href="https://netbird.io/pricing">netbird.io</a>
|
Start using NetBird at <a href="https://netbird.io/pricing">netbird.io</a>
|
||||||
<br/>
|
|
||||||
See <a href="https://netbird.io/docs/">Documentation</a>
|
|
||||||
<br/>
|
|
||||||
Join our <a href="https://docs.netbird.io/slack-url">Slack channel</a> or our <a href="https://forum.netbird.io">Community forum</a>
|
|
||||||
</strong>
|
|
||||||
<br/>
|
<br/>
|
||||||
|
See <a href="https://netbird.io/docs/">Documentation</a>
|
||||||
<br/>
|
<br/>
|
||||||
<strong>
|
Join our <a href="https://docs.netbird.io/slack-url">Slack channel</a> or our <a href="https://forum.netbird.io">Community forum</a>
|
||||||
🚀 <a href="https://careers.netbird.io">We are hiring! Join us at careers.netbird.io</a>
|
<br/>
|
||||||
</strong>
|
|
||||||
|
</strong>
|
||||||
|
<br>
|
||||||
|
<strong>
|
||||||
|
🚀 <a href="https://careers.netbird.io">We are hiring! Join us at careers.netbird.io</a>
|
||||||
|
</strong>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<a href="https://registry.terraform.io/providers/netbirdio/netbird/latest">
|
||||||
|
New: NetBird terraform provider
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
**NetBird combines a configuration-free peer-to-peer private network and a centralized access control system in a single platform, making it easy to create secure private networks for your organization or home.**
|
**NetBird combines a configuration-free peer-to-peer private network and a centralized access control system in a single platform, making it easy to create secure private networks for your organization or home.**
|
||||||
|
|
||||||
**Connect.** NetBird creates a WireGuard-based overlay network that automatically connects your machines over an encrypted tunnel, leaving behind the hassle of opening ports, complex firewall rules, VPN gateways, and so forth.
|
**Connect.** NetBird creates a WireGuard-based overlay network that automatically connects your machines over an encrypted tunnel, leaving behind the hassle of opening ports, complex firewall rules, VPN gateways, and so forth.
|
||||||
|
|
||||||
**Secure.** NetBird enables secure remote access by applying granular access policies while allowing you to manage them intuitively from a single place. Works universally on any infrastructure.
|
**Secure.** NetBird enables secure remote access by applying granular access policies while allowing you to manage them intuitively from a single place. Works universally on any infrastructure.
|
||||||
|
|
||||||
|
### Open Source Network Security in a Single Platform
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2
|
https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2
|
||||||
|
|
||||||
### Self-host NetBird (video)
|
### Self-Host NetBird (Video)
|
||||||
|
|
||||||
[](https://youtu.be/bZAgpT6nzaQ)
|
[](https://youtu.be/bZAgpT6nzaQ)
|
||||||
|
|
||||||
### Key features
|
### Key features
|
||||||
|
|
||||||
| Connectivity | Management | Security | Automation | Platforms |
|
| Connectivity | Management | Security | Automation| Platforms |
|
||||||
|---|---|---|---|---|
|
|----|----|----|----|----|
|
||||||
| ✓ [Kernel WireGuard](https://docs.netbird.io/about-netbird/why-wireguard-with-netbird) | ✓ [Admin Web UI](https://github.com/netbirdio/dashboard) | ✓ [SSO & MFA support](https://docs.netbird.io/how-to/installation#running-net-bird-with-sso-login) | ✓ [Public API](https://docs.netbird.io/api) | ✓ [Linux](https://docs.netbird.io/get-started/install/linux) |
|
| <ul><li>- \[x] Kernel WireGuard</ul></li> | <ul><li>- \[x] [Admin Web UI](https://github.com/netbirdio/dashboard)</ul></li> | <ul><li>- \[x] [SSO & MFA support](https://docs.netbird.io/how-to/installation#running-net-bird-with-sso-login)</ul></li> | <ul><li>- \[x] [Public API](https://docs.netbird.io/api)</ul></li> | <ul><li>- \[x] Linux</ul></li> |
|
||||||
| ✓ [Peer-to-peer connections](https://docs.netbird.io/about-netbird/how-netbird-works) | ✓ Auto peer discovery and configuration | ✓ [Access control: groups & rules](https://docs.netbird.io/how-to/manage-network-access) | ✓ [Setup keys for bulk provisioning](https://docs.netbird.io/how-to/register-machines-using-setup-keys) | ✓ [macOS](https://docs.netbird.io/get-started/install/macos) |
|
| <ul><li>- \[x] Peer-to-peer connections</ul></li> | <ul><li>- \[x] Auto peer discovery and configuration</ui></li> | <ul><li>- \[x] [Access control - groups & rules](https://docs.netbird.io/how-to/manage-network-access)</ui></li> | <ul><li>- \[x] [Setup keys for bulk network provisioning](https://docs.netbird.io/how-to/register-machines-using-setup-keys)</ui></li> | <ul><li>- \[x] Mac</ui></li> |
|
||||||
| ✓ Connection relay fallback | ✓ [IdP integrations](https://docs.netbird.io/selfhosted/identity-providers) | ✓ [Activity logging](https://docs.netbird.io/how-to/audit-events-logging) | ✓ [Self-hosting quickstart script](https://docs.netbird.io/selfhosted/selfhosted-quickstart) | ✓ [Windows](https://docs.netbird.io/get-started/install/windows) |
|
| <ul><li>- \[x] Connection relay fallback</ui></li> | <ul><li>- \[x] [IdP integrations](https://docs.netbird.io/selfhosted/identity-providers)</ui></li> | <ul><li>- \[x] [Activity logging](https://docs.netbird.io/how-to/audit-events-logging)</ui></li> | <ul><li>- \[x] [Self-hosting quickstart script](https://docs.netbird.io/selfhosted/selfhosted-quickstart)</ui></li> | <ul><li>- \[x] Windows</ui></li> |
|
||||||
| ✓ [Routes to external networks](https://docs.netbird.io/how-to/routing-traffic-to-private-networks) | ✓ [Private DNS](https://docs.netbird.io/how-to/manage-dns-in-your-network) | ✓ [Traffic events](https://docs.netbird.io/manage/activity/traffic-events-logging) | ✓ [IdP groups sync with JWT](https://docs.netbird.io/manage/team/idp-sync) | ✓ [Android](https://docs.netbird.io/get-started/install/android) |
|
| <ul><li>- \[x] [Routes to external networks](https://docs.netbird.io/how-to/routing-traffic-to-private-networks)</ui></li> | <ul><li>- \[x] [Private DNS](https://docs.netbird.io/how-to/manage-dns-in-your-network)</ui></li> | <ul><li>- \[x] [Device posture checks](https://docs.netbird.io/how-to/manage-posture-checks)</ui></li> | <ul><li>- \[x] IdP groups sync with JWT</ui></li> | <ul><li>- \[x] Android</ui></li> |
|
||||||
| ✓ [Domain-based DNS routes](https://docs.netbird.io/manage/dns/dns-aliases-for-routed-networks) | ✓ [Custom DNS zones](https://docs.netbird.io/manage/dns/custom-zones) | ✓ [Device posture checks](https://docs.netbird.io/how-to/manage-posture-checks) | ✓ [Terraform provider](https://registry.terraform.io/providers/netbirdio/netbird/latest) | ✓ [Android TV](https://docs.netbird.io/get-started/install/android-tv) |
|
| <ul><li>- \[x] NAT traversal with BPF</ui></li> | <ul><li>- \[x] [Multiuser support](https://docs.netbird.io/how-to/add-users-to-your-network)</ui></li> | <ul><li>- \[x] Peer-to-peer encryption</ui></li> || <ul><li>- \[x] iOS</ui></li> |
|
||||||
| ✓ [Exit nodes](https://docs.netbird.io/manage/network-routes/use-cases/exit-nodes) | ✓ [Multiuser support](https://docs.netbird.io/how-to/add-users-to-your-network) | ✓ Peer-to-peer encryption | ✓ [Ansible collection](https://github.com/netbirdio/ansible-netbird) | ✓ [iOS](https://docs.netbird.io/get-started/install/ios) |
|
||| <ul><li>- \[x] [Quantum-resistance with Rosenpass](https://netbird.io/knowledge-hub/the-first-quantum-resistant-mesh-vpn)</ui></li> || <ul><li>- \[x] OpenWRT</ui></li> |
|
||||||
| ✓ [IPv6 dual-stack overlay](https://docs.netbird.io/manage/settings/ipv6) | ✓ [Multi-account profile switching](https://docs.netbird.io/client/profiles) | ✓ [SSH with central access policies](https://docs.netbird.io/manage/peers/ssh) | | ✓ [Apple TV](https://docs.netbird.io/get-started/install/tvos) |
|
||| <ul><li>- \[x] [Periodic re-authentication](https://docs.netbird.io/how-to/enforce-periodic-user-authentication)</ui></li> || <ul><li>- \[x] [Serverless](https://docs.netbird.io/how-to/netbird-on-faas)</ui></li> |
|
||||||
| ✓ [Browser SSH & RDP](https://docs.netbird.io/manage/peers/browser-client) | | ✓ [Quantum-resistance with Rosenpass](https://netbird.io/knowledge-hub/the-first-quantum-resistant-mesh-vpn) | | ✓ FreeBSD |
|
||||| <ul><li>- \[x] Docker</ui></li> |
|
||||||
| ✓ [Reverse proxy with auto-TLS](https://docs.netbird.io/manage/reverse-proxy) | | ✓ [Periodic re-authentication](https://docs.netbird.io/how-to/enforce-periodic-user-authentication) | | ✓ [pfSense](https://docs.netbird.io/get-started/install/pfsense) |
|
|
||||||
| | | | | ✓ [OPNsense](https://docs.netbird.io/get-started/install/opnsense) |
|
|
||||||
| | | | | ✓ [MikroTik RouterOS](https://docs.netbird.io/use-cases/homelab/client-on-mikrotik-router) |
|
|
||||||
| | | | | ✓ OpenWRT |
|
|
||||||
| | | | | ✓ [Synology](https://docs.netbird.io/get-started/install/synology) |
|
|
||||||
| | | | | ✓ [TrueNAS](https://docs.netbird.io/get-started/install/truenas) |
|
|
||||||
| | | | | ✓ [Proxmox](https://docs.netbird.io/get-started/install/proxmox-ve) |
|
|
||||||
| | | | | ✓ [Raspberry Pi](https://docs.netbird.io/get-started/install/raspberrypi) |
|
|
||||||
| | | | | ✓ [Serverless](https://docs.netbird.io/how-to/netbird-on-faas) |
|
|
||||||
| | | | | ✓ [Container](https://docs.netbird.io/get-started/install/docker) |
|
|
||||||
|
|
||||||
### Quickstart with NetBird Cloud
|
### Quickstart with NetBird Cloud
|
||||||
|
|
||||||
- Download and install NetBird at [https://app.netbird.io/install](https://app.netbird.io/install).
|
- Download and install NetBird at [https://app.netbird.io/install](https://app.netbird.io/install)
|
||||||
- Follow the steps to sign up with Google, Microsoft, GitHub or your email address.
|
- Follow the steps to sign-up with Google, Microsoft, GitHub or your email address.
|
||||||
- Check the NetBird [admin UI](https://app.netbird.io/).
|
- Check NetBird [admin UI](https://app.netbird.io/).
|
||||||
|
- Add more machines.
|
||||||
|
|
||||||
### Quickstart with self-hosted NetBird
|
### Quickstart with self-hosted NetBird
|
||||||
|
|
||||||
This is the quickest way to try self-hosted NetBird. It should take around 5 minutes to get started if you already have a public domain and a VM. Follow the [Advanced guide with a custom identity provider](https://docs.netbird.io/selfhosted/selfhosted-guide#advanced-guide-with-a-custom-identity-provider) for installations with different IdPs.
|
> This is the quickest way to try self-hosted NetBird. It should take around 5 minutes to get started if you already have a public domain and a VM.
|
||||||
|
Follow the [Advanced guide with a custom identity provider](https://docs.netbird.io/selfhosted/selfhosted-guide#advanced-guide-with-a-custom-identity-provider) for installations with different IDPs.
|
||||||
|
|
||||||
**Infrastructure requirements:**
|
**Infrastructure requirements:**
|
||||||
- A Linux VM with at least **1 CPU** and **2 GB** of memory.
|
- A Linux VM with at least **1CPU** and **2GB** of memory.
|
||||||
- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port **3478**.
|
- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port: **3478**.
|
||||||
- A **public domain** name pointing to the VM.
|
- **Public domain** name pointing to the VM.
|
||||||
|
|
||||||
**Software requirements:**
|
**Software requirements:**
|
||||||
- Docker with the Compose plugin (Compose v2 or higher). See the [Docker installation guide](https://docs.docker.com/engine/install/).
|
- Docker installed on the VM with the docker-compose plugin ([Docker installation guide](https://docs.docker.com/engine/install/)) or docker with docker-compose in version 2 or higher.
|
||||||
|
- [jq](https://jqlang.github.io/jq/) installed. In most distributions
|
||||||
|
Usually available in the official repositories and can be installed with `sudo apt install jq` or `sudo yum install jq`
|
||||||
|
- [curl](https://curl.se/) installed.
|
||||||
|
Usually available in the official repositories and can be installed with `sudo apt install curl` or `sudo yum install curl`
|
||||||
|
|
||||||
**Steps**
|
**Steps**
|
||||||
- Download and run the installation script:
|
- Download and run the installation script:
|
||||||
```bash
|
```bash
|
||||||
export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh | bash
|
export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh | bash
|
||||||
```
|
```
|
||||||
|
- Once finished, you can manage the resources via `docker-compose`
|
||||||
|
|
||||||
### A bit on NetBird internals
|
### A bit on NetBird internals
|
||||||
- Every machine in the network runs the [NetBird agent](client/), which manages WireGuard.
|
- Every machine in the network runs [NetBird Agent (or Client)](client/) that manages WireGuard.
|
||||||
- Every agent connects to the [Management Service](management/), which holds network state, manages peer IPs, and distributes updates to agents.
|
- Every agent connects to [Management Service](management/) that holds network state, manages peer IPs, and distributes network updates to agents (peers).
|
||||||
- Agents use ICE (via [pion/ice](https://github.com/pion/ice)) to discover connection candidates for peer-to-peer connections.
|
- NetBird agent uses WebRTC ICE implemented in [pion/ice library](https://github.com/pion/ice) to discover connection candidates when establishing a peer-to-peer connection between machines.
|
||||||
- Candidates are discovered with the help of [STUN](https://en.wikipedia.org/wiki/STUN) servers.
|
- Connection candidates are discovered with the help of [STUN](https://en.wikipedia.org/wiki/STUN) servers.
|
||||||
- Agents negotiate a connection through the [Signal Service](signal/), exchanging end-to-end encrypted messages with candidates.
|
- Agents negotiate a connection through [Signal Service](signal/) passing p2p encrypted messages with candidates.
|
||||||
- When NAT traversal fails (e.g. mobile carrier-grade NAT) and a direct p2p connection isn't possible, the system falls back to a [Relay Service](relay/) and a secure WireGuard tunnel is established through it.
|
- Sometimes the NAT traversal is unsuccessful due to strict NATs (e.g. mobile carrier-grade NAT) and a p2p connection isn't possible. When this occurs the system falls back to a relay server called [TURN](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT), and a secure WireGuard tunnel is established via the TURN server.
|
||||||
|
|
||||||
|
[Coturn](https://github.com/coturn/coturn) is the one that has been successfully used for STUN and TURN in NetBird setups.
|
||||||
|
|
||||||
<p float="left" align="middle">
|
<p float="left" align="middle">
|
||||||
<img src="https://docs.netbird.io/docs-static/img/about-netbird/high-level-dia.png" width="700" alt="NetBird high-level architecture diagram"/>
|
<img src="https://docs.netbird.io/docs-static/img/about-netbird/high-level-dia.png" width="700"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details.
|
See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details.
|
||||||
|
|
||||||
### Community projects
|
### Community projects
|
||||||
- [NetBird installer script](https://github.com/physk/netbird-installer)
|
- [NetBird installer script](https://github.com/physk/netbird-installer)
|
||||||
- [netbird-tui](https://github.com/n0pashkov/netbird-tui) - terminal UI for managing NetBird peers, routes, and settings
|
- [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/)
|
||||||
- [caddy-netbird](https://github.com/lixmal/caddy-netbird) - Caddy plugin that embeds a NetBird client for proxying HTTP and TCP/UDP traffic through NetBird networks
|
- [netbird-tui](https://github.com/n0pashkov/netbird-tui) — terminal UI for managing NetBird peers, routes, and settings
|
||||||
|
|
||||||
**Note**: The `main` branch may be in an *unstable or even broken state* during development.
|
**Note**: The `main` branch may be in an *unstable or even broken state* during development.
|
||||||
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
|
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
|
||||||
|
|
||||||
### Support acknowledgement
|
### Support acknowledgement
|
||||||
|
|
||||||
In November 2022, NetBird joined the [StartUpSecure program](https://www.forschung-it-sicherheit-kommunikationssysteme.de/foerderung/bekanntmachungen/startup-secure) sponsored by the Federal Ministry of Education and Research of the Federal Republic of Germany. Together with the [CISPA Helmholtz Center for Information Security](https://cispa.de/en), NetBird brings security best practices and simplicity to private networking.
|
In November 2022, NetBird joined the [StartUpSecure program](https://www.forschung-it-sicherheit-kommunikationssysteme.de/foerderung/bekanntmachungen/startup-secure) sponsored by The Federal Ministry of Education and Research of The Federal Republic of Germany. Together with [CISPA Helmholtz Center for Information Security](https://cispa.de/en) NetBird brings the security best practices and simplicity to private networking.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Acknowledgements
|
### Testimonials
|
||||||
We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
|
We use open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE (WebRTC)](https://github.com/pion/ice), [Coturn](https://github.com/coturn/coturn), and [Rosenpass](https://rosenpass.eu). We very much appreciate the work these guys are doing and we'd greatly appreciate if you could support them in any way (e.g., by giving a star or a contribution).
|
||||||
|
|
||||||
### Legal
|
### Legal
|
||||||
This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
This repository is licensed under BSD-3-Clause license that applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
||||||
Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory.
|
Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory.
|
||||||
|
|
||||||
_WireGuard_ and the _WireGuard_ logo are [registered trademarks](https://www.wireguard.com/trademark-policy/) of Jason A. Donenfeld.
|
_WireGuard_ and the _WireGuard_ logo are [registered trademarks](https://www.wireguard.com/trademark-policy/) of Jason A. Donenfeld.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
"github.com/netbirdio/management-integrations/integrations"
|
||||||
|
|
||||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
iv, _ := validator.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
|
iv, _ := integrations.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
|
||||||
|
|
||||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
106
client/cmd/up.go
106
client/cmd/up.go
@@ -361,6 +361,12 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
|||||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||||
req.ServerSSHAllowed = &serverSSHAllowed
|
req.ServerSSHAllowed = &serverSSHAllowed
|
||||||
}
|
}
|
||||||
|
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||||
|
req.ServerVNCAllowed = &serverVNCAllowed
|
||||||
|
}
|
||||||
|
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
||||||
|
req.DisableVNCApproval = &disableVNCApproval
|
||||||
|
}
|
||||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||||
req.EnableSSHRoot = &enableSSHRoot
|
req.EnableSSHRoot = &enableSSHRoot
|
||||||
}
|
}
|
||||||
@@ -467,30 +473,14 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
|||||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||||
ic.ServerSSHAllowed = &serverSSHAllowed
|
ic.ServerSSHAllowed = &serverSSHAllowed
|
||||||
}
|
}
|
||||||
|
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
ic.ServerVNCAllowed = &serverVNCAllowed
|
||||||
ic.EnableSSHRoot = &enableSSHRoot
|
}
|
||||||
|
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
||||||
|
ic.DisableVNCApproval = &disableVNCApproval
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
applySSHFlagsToConfig(cmd, &ic)
|
||||||
ic.EnableSSHSFTP = &enableSSHSFTP
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
|
||||||
ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
|
||||||
ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
|
||||||
ic.DisableSSHAuth = &disableSSHAuth
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
|
||||||
ic.SSHJWTCacheTTL = &sshJWTCacheTTL
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(interfaceNameFlag).Changed {
|
if cmd.Flag(interfaceNameFlag).Changed {
|
||||||
if err := parseInterfaceName(interfaceName); err != nil {
|
if err := parseInterfaceName(interfaceName); err != nil {
|
||||||
@@ -566,6 +556,49 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
|||||||
return &ic, nil
|
return &ic, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applySSHFlagsToConfig(cmd *cobra.Command, ic *profilemanager.ConfigInput) {
|
||||||
|
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||||
|
ic.EnableSSHRoot = &enableSSHRoot
|
||||||
|
}
|
||||||
|
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||||
|
ic.EnableSSHSFTP = &enableSSHSFTP
|
||||||
|
}
|
||||||
|
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||||
|
ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||||
|
}
|
||||||
|
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||||
|
ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||||
|
}
|
||||||
|
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||||
|
ic.DisableSSHAuth = &disableSSHAuth
|
||||||
|
}
|
||||||
|
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||||
|
ic.SSHJWTCacheTTL = &sshJWTCacheTTL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySSHFlagsToLogin(cmd *cobra.Command, req *proto.LoginRequest) {
|
||||||
|
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||||
|
req.EnableSSHRoot = &enableSSHRoot
|
||||||
|
}
|
||||||
|
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||||
|
req.EnableSSHSFTP = &enableSSHSFTP
|
||||||
|
}
|
||||||
|
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||||
|
req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||||
|
}
|
||||||
|
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||||
|
req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||||
|
}
|
||||||
|
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||||
|
req.DisableSSHAuth = &disableSSHAuth
|
||||||
|
}
|
||||||
|
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||||
|
ttl := int32(sshJWTCacheTTL)
|
||||||
|
req.SshJWTCacheTTL = &ttl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) {
|
func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) {
|
||||||
loginRequest := proto.LoginRequest{
|
loginRequest := proto.LoginRequest{
|
||||||
SetupKey: providedSetupKey,
|
SetupKey: providedSetupKey,
|
||||||
@@ -595,31 +628,14 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
|||||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||||
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
||||||
}
|
}
|
||||||
|
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
loginRequest.ServerVNCAllowed = &serverVNCAllowed
|
||||||
loginRequest.EnableSSHRoot = &enableSSHRoot
|
}
|
||||||
|
if cmd.Flag(disableVNCApprovalFlag).Changed {
|
||||||
|
loginRequest.DisableVNCApproval = &disableVNCApproval
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
applySSHFlagsToLogin(cmd, &loginRequest)
|
||||||
loginRequest.EnableSSHSFTP = &enableSSHSFTP
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
|
||||||
loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
|
||||||
loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
|
||||||
loginRequest.DisableSSHAuth = &disableSSHAuth
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
|
||||||
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
|
||||||
loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(disableAutoConnectFlag).Changed {
|
if cmd.Flag(disableAutoConnectFlag).Changed {
|
||||||
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
||||||
|
|||||||
100
client/cmd/vnc_agent.go
Normal file
100
client/cmd/vnc_agent.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
//go:build windows || (darwin && !ios)
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
vncAgentSocket string
|
||||||
|
vncAgentTargetUID uint32
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
vncAgentCmd.Flags().StringVar(&vncAgentSocket, "socket", "", "Unix-domain socket path the agent listens on (required)")
|
||||||
|
vncAgentCmd.Flags().Uint32Var(&vncAgentTargetUID, "target-uid", 0, "uid the agent should drop privileges to before listening (darwin only; 0 = stay as current uid)")
|
||||||
|
rootCmd.AddCommand(vncAgentCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vncAgentCmd runs a VNC server inside the user's interactive session,
|
||||||
|
// listening on a Unix-domain socket. The NetBird service spawns it: on
|
||||||
|
// Windows via CreateProcessAsUser into the console session, on macOS via
|
||||||
|
// launchctl asuser into the Aqua session.
|
||||||
|
var vncAgentCmd = &cobra.Command{
|
||||||
|
Use: "vnc-agent",
|
||||||
|
Short: "Run VNC capture agent (internal, spawned by service)",
|
||||||
|
Hidden: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
log.SetReportCaller(true)
|
||||||
|
log.SetFormatter(&log.JSONFormatter{})
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
if vncAgentSocket == "" {
|
||||||
|
return fmt.Errorf("--socket is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := os.Getenv("NB_VNC_AGENT_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service")
|
||||||
|
}
|
||||||
|
// Purge the token from env so it doesn't leak via /proc/<pid>/environ.
|
||||||
|
if err := os.Unsetenv("NB_VNC_AGENT_TOKEN"); err != nil {
|
||||||
|
log.Debugf("unset NB_VNC_AGENT_TOKEN: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop root privileges to the target console user BEFORE creating
|
||||||
|
// the listening socket: keeps a post-auth bug in the encoder /
|
||||||
|
// input / capture paths confined to the user's own privileges
|
||||||
|
// rather than escalating to host root, and makes the daemon's
|
||||||
|
// LOCAL_PEERCRED check see the right uid. No-op on Windows
|
||||||
|
// (both processes run as SYSTEM) and when --target-uid is 0.
|
||||||
|
if vncAgentTargetUID != 0 {
|
||||||
|
if err := dropAgentPrivileges(vncAgentTargetUID); err != nil {
|
||||||
|
return fmt.Errorf("drop privileges to uid %d: %w", vncAgentTargetUID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(vncAgentSocket); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Debugf("remove stale socket %s: %v", vncAgentSocket, err)
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("unix", vncAgentSocket)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen on %s: %w", vncAgentSocket, err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(vncAgentSocket, 0o600); err != nil {
|
||||||
|
log.Debugf("chmod %s: %v", vncAgentSocket, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
capturer, injector, err := newAgentResources()
|
||||||
|
if err != nil {
|
||||||
|
_ = ln.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srv := vncserver.New(vncserver.Config{
|
||||||
|
Capturer: capturer,
|
||||||
|
Injector: injector,
|
||||||
|
DisableAuth: true,
|
||||||
|
AgentTokenHex: token,
|
||||||
|
Listener: ln,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := srv.Start(cmd.Context(), netip.AddrPort{}, netip.Prefix{}); err != nil {
|
||||||
|
return fmt.Errorf("start vnc server: %w", err)
|
||||||
|
}
|
||||||
|
log.Infof("vnc-agent listening on %s, ready", vncAgentSocket)
|
||||||
|
|
||||||
|
<-cmd.Context().Done()
|
||||||
|
log.Info("vnc-agent context cancelled, shutting down")
|
||||||
|
return srv.Stop()
|
||||||
|
},
|
||||||
|
SilenceUsage: true,
|
||||||
|
}
|
||||||
18
client/cmd/vnc_agent_darwin.go
Normal file
18
client/cmd/vnc_agent_darwin.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build darwin && !ios
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||||
|
capturer := vncserver.NewMacPoller()
|
||||||
|
injector, err := vncserver.NewMacInputInjector()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("macOS input injector: %w", err)
|
||||||
|
}
|
||||||
|
return capturer, injector, nil
|
||||||
|
}
|
||||||
50
client/cmd/vnc_agent_dropprivs_darwin.go
Normal file
50
client/cmd/vnc_agent_dropprivs_darwin.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//go:build darwin && !ios
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dropAgentPrivileges drops the vnc-agent process from root (its
|
||||||
|
// launchctl-asuser-inherited starting uid) to the target console user
|
||||||
|
// before any other initialisation runs. Without this the agent runs as
|
||||||
|
// root for the lifetime of the session; any post-auth memory-safety
|
||||||
|
// issue in the capture/input/encode paths would then be a root-level
|
||||||
|
// RCE on the host instead of a user-level one. Also makes the daemon's
|
||||||
|
// LOCAL_PEERCRED check correctly identify the agent as the console user,
|
||||||
|
// not as root.
|
||||||
|
//
|
||||||
|
// Returns an error when the agent is running as a non-root uid that
|
||||||
|
// differs from targetUID: non-root can only setuid to itself, so a
|
||||||
|
// mismatch here means the spawn went to the wrong session.
|
||||||
|
func dropAgentPrivileges(targetUID uint32) error {
|
||||||
|
if targetUID == 0 {
|
||||||
|
return fmt.Errorf("refusing to keep agent running as root (target uid 0)")
|
||||||
|
}
|
||||||
|
cur := uint32(os.Getuid())
|
||||||
|
if cur == targetUID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cur != 0 {
|
||||||
|
return fmt.Errorf("agent uid %d does not match expected %d and we lack root to fix it", cur, targetUID)
|
||||||
|
}
|
||||||
|
// Drop supplementary groups first: setgid alone doesn't touch the
|
||||||
|
// auxiliary group list, leaving root's groups attached would let the
|
||||||
|
// dropped process write to root-only group-writable files.
|
||||||
|
if err := syscall.Setgroups([]int{}); err != nil {
|
||||||
|
return fmt.Errorf("setgroups([]): %w", err)
|
||||||
|
}
|
||||||
|
if err := syscall.Setgid(int(targetUID)); err != nil {
|
||||||
|
return fmt.Errorf("setgid(%d): %w", targetUID, err)
|
||||||
|
}
|
||||||
|
if err := syscall.Setuid(int(targetUID)); err != nil {
|
||||||
|
return fmt.Errorf("setuid(%d): %w", targetUID, err)
|
||||||
|
}
|
||||||
|
if uint32(os.Getuid()) != targetUID || uint32(os.Geteuid()) != targetUID {
|
||||||
|
return fmt.Errorf("setuid verification: uid=%d euid=%d, expected %d", os.Getuid(), os.Geteuid(), targetUID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
55
client/cmd/vnc_agent_dropprivs_darwin_test.go
Normal file
55
client/cmd/vnc_agent_dropprivs_darwin_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//go:build darwin && !ios
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDropAgentPrivileges_RefusesRootTarget locks in the contract that
|
||||||
|
// dropAgentPrivileges must never be a no-op when asked to keep the
|
||||||
|
// agent as root (target uid 0). A future caller that passes 0 by
|
||||||
|
// mistake would otherwise leave the post-auth attack surface running
|
||||||
|
// with full root privileges.
|
||||||
|
func TestDropAgentPrivileges_RefusesRootTarget(t *testing.T) {
|
||||||
|
err := dropAgentPrivileges(0)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected refusal for target uid 0, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "root") {
|
||||||
|
t.Fatalf("error should mention root, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDropAgentPrivileges_NoOpWhenAlreadyTarget covers the dev path
|
||||||
|
// where the agent is launched by hand as the target user (no root
|
||||||
|
// available, no setuid needed). The helper must succeed silently
|
||||||
|
// instead of trying (and failing) a setuid to its current uid.
|
||||||
|
func TestDropAgentPrivileges_NoOpWhenAlreadyTarget(t *testing.T) {
|
||||||
|
// Skip when running as root: the early-return path we want to
|
||||||
|
// cover only fires when current uid == target uid.
|
||||||
|
uid := currentUIDForTest()
|
||||||
|
if uid == 0 {
|
||||||
|
t.Skip("test must not run as root; cannot exercise the no-op early-return")
|
||||||
|
}
|
||||||
|
if err := dropAgentPrivileges(uid); err != nil {
|
||||||
|
t.Fatalf("expected no-op when current uid == target, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDropAgentPrivileges_RefusesMismatchedNonRoot guards the "non-root
|
||||||
|
// caller tries to setuid to a different uid" path: setuid would fail
|
||||||
|
// with EPERM anyway, but the helper should surface a clear error
|
||||||
|
// before issuing the syscall so a misconfigured spawn (wrong --target-uid
|
||||||
|
// flag) is debuggable.
|
||||||
|
func TestDropAgentPrivileges_RefusesMismatchedNonRoot(t *testing.T) {
|
||||||
|
uid := currentUIDForTest()
|
||||||
|
if uid == 0 {
|
||||||
|
t.Skip("test must not run as root; covered case requires non-root caller")
|
||||||
|
}
|
||||||
|
err := dropAgentPrivileges(uid + 1)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected refusal when non-root caller asks to setuid elsewhere")
|
||||||
|
}
|
||||||
|
}
|
||||||
11
client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go
Normal file
11
client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build darwin && !ios
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// currentUIDForTest exposes os.Getuid for the darwin dropprivs tests
|
||||||
|
// without leaking an os import into the test file itself.
|
||||||
|
func currentUIDForTest() uint32 {
|
||||||
|
return uint32(os.Getuid())
|
||||||
|
}
|
||||||
14
client/cmd/vnc_agent_dropprivs_windows.go
Normal file
14
client/cmd/vnc_agent_dropprivs_windows.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
// dropAgentPrivileges is a no-op on Windows: the agent and the daemon
|
||||||
|
// both run as SYSTEM (the daemon spawns the agent into the interactive
|
||||||
|
// session via CreateProcessAsUser with an impersonation token, but the
|
||||||
|
// resulting process still runs under SYSTEM, not under the user's
|
||||||
|
// account). The Windows path relies on the C:\Windows\Temp socket
|
||||||
|
// location (admin/SYSTEM-write-only) and the per-spawn token for
|
||||||
|
// integrity instead.
|
||||||
|
func dropAgentPrivileges(_ uint32) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
15
client/cmd/vnc_agent_windows.go
Normal file
15
client/cmd/vnc_agent_windows.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||||
|
sessionID := vncserver.GetCurrentSessionID()
|
||||||
|
log.Infof("VNC agent running in Windows session %d", sessionID)
|
||||||
|
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), nil
|
||||||
|
}
|
||||||
16
client/cmd/vnc_flags.go
Normal file
16
client/cmd/vnc_flags.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
const (
|
||||||
|
serverVNCAllowedFlag = "allow-server-vnc"
|
||||||
|
disableVNCApprovalFlag = "disable-vnc-approval"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
serverVNCAllowed bool
|
||||||
|
disableVNCApproval bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
upCmd.PersistentFlags().BoolVar(&serverVNCAllowed, serverVNCAllowedFlag, false, "Allow embedded VNC server on peer")
|
||||||
|
upCmd.PersistentFlags().BoolVar(&disableVNCApproval, disableVNCApprovalFlag, false, "Disable per-connection user approval prompts for the embedded VNC server")
|
||||||
|
}
|
||||||
@@ -6,19 +6,30 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
var StateDir string
|
var (
|
||||||
|
// StateDir holds persistent state (config, profiles, install metadata).
|
||||||
|
StateDir string
|
||||||
|
// RuntimeDir holds ephemeral artifacts that should not survive reboot,
|
||||||
|
// such as Unix sockets for daemon and per-session IPC. Empty on
|
||||||
|
// platforms without a conventional /var/run-style location.
|
||||||
|
RuntimeDir string
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
StateDir = os.Getenv("NB_STATE_DIR")
|
|
||||||
if StateDir != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "windows":
|
case "windows":
|
||||||
StateDir = filepath.Join(os.Getenv("PROGRAMDATA"), "Netbird")
|
StateDir = filepath.Join(os.Getenv("PROGRAMDATA"), "Netbird")
|
||||||
case "darwin", "linux":
|
case "darwin", "linux":
|
||||||
StateDir = "/var/lib/netbird"
|
StateDir = "/var/lib/netbird"
|
||||||
|
RuntimeDir = "/var/run/netbird"
|
||||||
case "freebsd", "openbsd", "netbsd", "dragonfly":
|
case "freebsd", "openbsd", "netbsd", "dragonfly":
|
||||||
StateDir = "/var/db/netbird"
|
StateDir = "/var/db/netbird"
|
||||||
|
RuntimeDir = "/var/run/netbird"
|
||||||
|
}
|
||||||
|
if v := os.Getenv("NB_STATE_DIR"); v != "" {
|
||||||
|
StateDir = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("NB_RUNTIME_DIR"); v != "" {
|
||||||
|
RuntimeDir = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
|
||||||
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
@@ -85,12 +84,6 @@ type Options struct {
|
|||||||
DisableIPv6 bool
|
DisableIPv6 bool
|
||||||
// BlockInbound blocks all inbound connections from peers
|
// BlockInbound blocks all inbound connections from peers
|
||||||
BlockInbound bool
|
BlockInbound bool
|
||||||
// BlockLANAccess blocks the embedded peer from reaching the host's
|
|
||||||
// LAN (RFC 1918, link-local, loopback) when it's used as a routing
|
|
||||||
// peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
|
|
||||||
// when the embedded client must never act as a stepping stone into
|
|
||||||
// the host's local network (e.g. the proxy's overlay peer).
|
|
||||||
BlockLANAccess bool
|
|
||||||
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
||||||
WireguardPort *int
|
WireguardPort *int
|
||||||
// MTU is the MTU for the tunnel interface.
|
// MTU is the MTU for the tunnel interface.
|
||||||
@@ -101,26 +94,6 @@ type Options struct {
|
|||||||
MTU *uint16
|
MTU *uint16
|
||||||
// DNSLabels defines additional DNS labels configured in the peer.
|
// DNSLabels defines additional DNS labels configured in the peer.
|
||||||
DNSLabels []string
|
DNSLabels []string
|
||||||
// Performance configures the tunnel's buffer pool cap and batch size.
|
|
||||||
Performance Performance
|
|
||||||
}
|
|
||||||
|
|
||||||
// Performance configures the embedded client's tunnel memory/throughput knobs.
|
|
||||||
//
|
|
||||||
// These settings are process-global: any non-nil field also becomes the
|
|
||||||
// default for Clients constructed by later embed.New calls in the same
|
|
||||||
// process. Nil fields are ignored.
|
|
||||||
type Performance struct {
|
|
||||||
// PreallocatedBuffersPerPool caps the per-tunnel buffer pool. Zero
|
|
||||||
// leaves the pool unbounded. Lower values trade throughput for a
|
|
||||||
// tighter memory ceiling. May also be changed on a running Client via
|
|
||||||
// Client.SetPerformance, provided this field was nonzero at construction.
|
|
||||||
PreallocatedBuffersPerPool *uint32
|
|
||||||
// MaxBatchSize overrides the number of packets the tunnel reads or
|
|
||||||
// writes per syscall, which also bounds eager buffer allocation per
|
|
||||||
// worker. Zero uses the platform default. Applied at construction
|
|
||||||
// only; ignored by Client.SetPerformance.
|
|
||||||
MaxBatchSize *uint32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCredentials checks that exactly one credential type is provided
|
// validateCredentials checks that exactly one credential type is provided
|
||||||
@@ -202,7 +175,6 @@ func New(opts Options) (*Client, error) {
|
|||||||
DisableClientRoutes: &opts.DisableClientRoutes,
|
DisableClientRoutes: &opts.DisableClientRoutes,
|
||||||
DisableIPv6: &opts.DisableIPv6,
|
DisableIPv6: &opts.DisableIPv6,
|
||||||
BlockInbound: &opts.BlockInbound,
|
BlockInbound: &opts.BlockInbound,
|
||||||
BlockLANAccess: &opts.BlockLANAccess,
|
|
||||||
WireguardPort: opts.WireguardPort,
|
WireguardPort: opts.WireguardPort,
|
||||||
MTU: opts.MTU,
|
MTU: opts.MTU,
|
||||||
DNSLabels: parsedLabels,
|
DNSLabels: parsedLabels,
|
||||||
@@ -220,13 +192,6 @@ func New(opts Options) (*Client, error) {
|
|||||||
config.PrivateKey = opts.PrivateKey
|
config.PrivateKey = opts.PrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Performance.PreallocatedBuffersPerPool != nil {
|
|
||||||
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
|
|
||||||
}
|
|
||||||
if opts.Performance.MaxBatchSize != nil {
|
|
||||||
wgdevice.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
deviceName: opts.DeviceName,
|
deviceName: opts.DeviceName,
|
||||||
setupKey: opts.SetupKey,
|
setupKey: opts.SetupKey,
|
||||||
@@ -440,21 +405,6 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdentityForIP looks up a remote peer by its tunnel IP using the
|
|
||||||
// embedded client's status recorder. Returns the peer's WireGuard public
|
|
||||||
// key and FQDN. ok=false means the IP isn't in this client's peer
|
|
||||||
// roster — callers should treat that as "unknown peer".
|
|
||||||
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
|
|
||||||
if !ip.IsValid() || c.recorder == nil {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
state, found := c.recorder.PeerStateByIP(ip.String())
|
|
||||||
if !found {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
return state.PubKey, state.FQDN, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status returns the current status of the client.
|
// Status returns the current status of the client.
|
||||||
func (c *Client) Status() (peer.FullStatus, error) {
|
func (c *Client) Status() (peer.FullStatus, error) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -523,25 +473,6 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
|
|||||||
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
|
|
||||||
// takes effect, and only when it was nonzero at construction;
|
|
||||||
// MaxBatchSize is construction-only and returns an error if set here.
|
|
||||||
//
|
|
||||||
// Returns ErrClientNotStarted / ErrEngineNotStarted if the Client is not
|
|
||||||
// running yet.
|
|
||||||
func (c *Client) SetPerformance(t Performance) error {
|
|
||||||
if t.MaxBatchSize != nil {
|
|
||||||
return errors.New("MaxBatchSize is construction-only and cannot be changed at runtime")
|
|
||||||
}
|
|
||||||
engine, err := c.getEngine()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return engine.SetPerformance(internal.Performance{
|
|
||||||
PreallocatedBuffersPerPool: t.PreallocatedBuffersPerPool,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartCapture begins capturing packets on this client's tunnel device.
|
// StartCapture begins capturing packets on this client's tunnel device.
|
||||||
// Only one capture can be active at a time; starting a new one stops the previous.
|
// Only one capture can be active at a time; starting a new one stops the previous.
|
||||||
// Call StopCapture (or CaptureSession.Stop) to end it.
|
// Call StopCapture (or CaptureSession.Stop) to end it.
|
||||||
|
|||||||
@@ -52,10 +52,9 @@ func (m *externalChainMonitor) start() {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
m.cancel = cancel
|
m.cancel = cancel
|
||||||
done := make(chan struct{})
|
m.done = make(chan struct{})
|
||||||
m.done = done
|
|
||||||
|
|
||||||
go m.run(ctx, done)
|
go m.run(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *externalChainMonitor) stop() {
|
func (m *externalChainMonitor) stop() {
|
||||||
@@ -73,8 +72,8 @@ func (m *externalChainMonitor) stop() {
|
|||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *externalChainMonitor) run(ctx context.Context, done chan struct{}) {
|
func (m *externalChainMonitor) run(ctx context.Context) {
|
||||||
defer close(done)
|
defer close(m.done)
|
||||||
|
|
||||||
bo := &backoff.ExponentialBackOff{
|
bo := &backoff.ExponentialBackOff{
|
||||||
InitialInterval: externalMonitorInitInterval,
|
InitialInterval: externalMonitorInitInterval,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -260,15 +260,23 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
|
|||||||
|
|
||||||
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
||||||
|
|
||||||
; Create autostart registry entry based on checkbox
|
; Drop Run, App Paths and Uninstall entries left in the 32-bit registry view
|
||||||
|
; or HKCU by legacy installers.
|
||||||
|
DetailPrint "Cleaning legacy 32-bit / HKCU entries..."
|
||||||
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
SetRegView 32
|
||||||
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
DeleteRegKey HKLM "${REG_APP_PATH}"
|
||||||
|
DeleteRegKey HKLM "${UI_REG_APP_PATH}"
|
||||||
|
DeleteRegKey HKLM "${UNINSTALL_PATH}"
|
||||||
|
SetRegView 64
|
||||||
|
|
||||||
DetailPrint "Autostart enabled: $AutostartEnabled"
|
DetailPrint "Autostart enabled: $AutostartEnabled"
|
||||||
${If} $AutostartEnabled == "1"
|
${If} $AutostartEnabled == "1"
|
||||||
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
||||||
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
||||||
${Else}
|
${Else}
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
|
||||||
DetailPrint "Autostart not enabled by user"
|
DetailPrint "Autostart not enabled by user"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
@@ -280,43 +288,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'
|
||||||
@@ -336,11 +307,16 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
|
|||||||
DetailPrint "Terminating Netbird UI process..."
|
DetailPrint "Terminating Netbird UI process..."
|
||||||
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
||||||
|
|
||||||
; Remove autostart registry entry
|
; Remove autostart entries from every view a previous installer may have used.
|
||||||
DetailPrint "Removing autostart registry entry if exists..."
|
DetailPrint "Removing autostart registry entry if exists..."
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
SetRegView 32
|
||||||
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
DeleteRegKey HKLM "${REG_APP_PATH}"
|
||||||
|
DeleteRegKey HKLM "${UI_REG_APP_PATH}"
|
||||||
|
DeleteRegKey HKLM "${UNINSTALL_PATH}"
|
||||||
|
SetRegView 64
|
||||||
|
|
||||||
; Handle data deletion based on checkbox
|
; Handle data deletion based on checkbox
|
||||||
DetailPrint "Checking if user requested data deletion..."
|
DetailPrint "Checking if user requested data deletion..."
|
||||||
@@ -363,9 +339,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"
|
||||||
|
|
||||||
|
|||||||
219
client/internal/approval/broker.go
Normal file
219
client/internal/approval/broker.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
// Package approval brokers per-attempt user-accept prompts for inbound
|
||||||
|
// remote access (VNC today, SSH and others in the future). A caller pushes
|
||||||
|
// a Prompt; the broker emits a SystemEvent on the daemon→UI stream and
|
||||||
|
// blocks until the UI calls the daemon's RespondApproval RPC, the per-
|
||||||
|
// request timeout fires, or no subscriber is connected. The latter case
|
||||||
|
// fails closed so a backgrounded UI cannot silently bypass the gate.
|
||||||
|
package approval
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metadata keys the broker reserves on the emitted SystemEvent. Callers
|
||||||
|
// should not set these themselves; values in Prompt.Metadata that collide
|
||||||
|
// are overwritten by the broker.
|
||||||
|
const (
|
||||||
|
MetaRequestID = "request_id"
|
||||||
|
MetaKind = "kind"
|
||||||
|
MetaExpiresAt = "expires_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShortKeyFingerprint formats a hex-encoded Noise_IK static pubkey as a
|
||||||
|
// short, eyeball-able fingerprint to display in the approval dialog.
|
||||||
|
// The dashboard-supplied display name attached to a SessionPubKey isn't
|
||||||
|
// cryptographically asserted by the connecting client, so the prompt
|
||||||
|
// must also show something that IS: the key fingerprint, a hash of
|
||||||
|
// the static public key the client just proved possession of during the
|
||||||
|
// Noise handshake. Returns the empty string when the input is too short
|
||||||
|
// to plausibly be a hex pubkey, so the row is omitted rather than
|
||||||
|
// rendered as a misleading partial.
|
||||||
|
//
|
||||||
|
// Output format: 16 hex chars grouped as XXXX-XXXX-XXXX-XXXX (64 bits of
|
||||||
|
// fingerprint, resistant to random-prefix collisions and easy for a human
|
||||||
|
// to compare with an out-of-band reference).
|
||||||
|
func ShortKeyFingerprint(hexKey string) string {
|
||||||
|
if len(hexKey) < 8 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
src := hexKey
|
||||||
|
if len(src) > 16 {
|
||||||
|
src = src[:16]
|
||||||
|
}
|
||||||
|
var out []byte
|
||||||
|
for i, c := range src {
|
||||||
|
if i > 0 && i%4 == 0 {
|
||||||
|
out = append(out, '-')
|
||||||
|
}
|
||||||
|
out = append(out, byte(c))
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind values for the well-known prompt subjects. New subsystems should
|
||||||
|
// add a constant here so the UI can dispatch on a known string.
|
||||||
|
const (
|
||||||
|
KindVNC = "vnc"
|
||||||
|
KindSSH = "ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTimeout is the wall-clock window the user has to accept or deny a
|
||||||
|
// pending approval before the broker fails closed and returns ErrTimeout.
|
||||||
|
// Kept well under typical VNC client and dashboard connection timeouts so
|
||||||
|
// the RFB rejection actually reaches the browser instead of racing the
|
||||||
|
// browser's own "connection timed out" message.
|
||||||
|
const DefaultTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
// timeoutValue returns the active timeout. It's a var so tests in this
|
||||||
|
// package can shorten the wait without exposing a setter on the public
|
||||||
|
// API. Production code always sees DefaultTimeout.
|
||||||
|
var timeoutValue = func() time.Duration { return DefaultTimeout }
|
||||||
|
|
||||||
|
// ErrNoSubscriber indicates no UI is connected to consume the prompt.
|
||||||
|
// The caller must reject the underlying connection (fail-closed).
|
||||||
|
var ErrNoSubscriber = errors.New("no UI subscriber connected for approval")
|
||||||
|
|
||||||
|
// ErrTimeout indicates the user did not respond within DefaultTimeout.
|
||||||
|
var ErrTimeout = errors.New("approval timed out")
|
||||||
|
|
||||||
|
// ErrDenied indicates the user explicitly denied the connection.
|
||||||
|
var ErrDenied = errors.New("approval denied")
|
||||||
|
|
||||||
|
// EventPublisher is the subset of peer.Status used to emit prompts.
|
||||||
|
type EventPublisher interface {
|
||||||
|
PublishEvent(
|
||||||
|
severity proto.SystemEvent_Severity,
|
||||||
|
category proto.SystemEvent_Category,
|
||||||
|
msg string,
|
||||||
|
userMsg string,
|
||||||
|
metadata map[string]string,
|
||||||
|
)
|
||||||
|
HasEventSubscribers() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt describes the pending request shown to the user. Kind selects
|
||||||
|
// the UI dispatch path (e.g. "vnc", "ssh"). Subject is the human-readable
|
||||||
|
// one-liner the UI may show as a title or notification body. Metadata is
|
||||||
|
// passed through verbatim and is the subsystem-specific payload (peer
|
||||||
|
// name, source IP, mode, etc.).
|
||||||
|
type Prompt struct {
|
||||||
|
Kind string
|
||||||
|
Subject string
|
||||||
|
Metadata map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision carries the user's response to an approval prompt. ViewOnly is
|
||||||
|
// only meaningful when Accept is true; it lets the host grant the
|
||||||
|
// connection but signal the requester that input control is withheld.
|
||||||
|
type Decision struct {
|
||||||
|
Accept bool
|
||||||
|
ViewOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broker holds in-flight approval requests keyed by request ID.
|
||||||
|
type Broker struct {
|
||||||
|
pub EventPublisher
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
pending map[string]chan Decision
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a broker that publishes prompts via pub.
|
||||||
|
func New(pub EventPublisher) *Broker {
|
||||||
|
return &Broker{
|
||||||
|
pub: pub,
|
||||||
|
pending: make(map[string]chan Decision),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request emits a SystemEvent for p and blocks until the UI calls Respond,
|
||||||
|
// ctx is cancelled, or DefaultTimeout elapses. Returns a Decision when
|
||||||
|
// the user replied; ErrDenied / ErrTimeout / ErrNoSubscriber / ctx.Err
|
||||||
|
// otherwise. Callers must treat any non-nil error as a deny.
|
||||||
|
func (b *Broker) Request(ctx context.Context, p Prompt) (Decision, error) {
|
||||||
|
var zero Decision
|
||||||
|
if b == nil || b.pub == nil {
|
||||||
|
return zero, fmt.Errorf("approval broker not configured")
|
||||||
|
}
|
||||||
|
if !b.pub.HasEventSubscribers() {
|
||||||
|
return zero, ErrNoSubscriber
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
resp := make(chan Decision, 1)
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.pending[id] = resp
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
defer b.dropPending(id)
|
||||||
|
|
||||||
|
timeout := timeoutValue()
|
||||||
|
expiresAt := time.Now().Add(timeout)
|
||||||
|
meta := make(map[string]string, len(p.Metadata)+3)
|
||||||
|
for k, v := range p.Metadata {
|
||||||
|
meta[k] = v
|
||||||
|
}
|
||||||
|
meta[MetaRequestID] = id
|
||||||
|
meta[MetaKind] = p.Kind
|
||||||
|
meta[MetaExpiresAt] = expiresAt.UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
subject := p.Subject
|
||||||
|
if subject == "" {
|
||||||
|
subject = fmt.Sprintf("%s connection requires approval", p.Kind)
|
||||||
|
}
|
||||||
|
b.pub.PublishEvent(proto.SystemEvent_INFO, proto.SystemEvent_APPROVAL, subject, subject, meta)
|
||||||
|
log.Debugf("approval request %s (%s) emitted: %s", id, p.Kind, subject)
|
||||||
|
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case d := <-resp:
|
||||||
|
if !d.Accept {
|
||||||
|
return zero, ErrDenied
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
case <-timer.C:
|
||||||
|
return zero, ErrTimeout
|
||||||
|
case <-ctx.Done():
|
||||||
|
return zero, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond delivers the user's decision for id. Returns true when a pending
|
||||||
|
// request matched and was woken, false when id was unknown or already done.
|
||||||
|
func (b *Broker) Respond(id string, d Decision) bool {
|
||||||
|
if b == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
ch, ok := b.pending[id]
|
||||||
|
if ok {
|
||||||
|
delete(b.pending, id)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- d:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) dropPending(id string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.pending, id)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
434
client/internal/approval/broker_test.go
Normal file
434
client/internal/approval/broker_test.go
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
package approval
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePublisher records published events and reports whether subscribers
|
||||||
|
// are connected. The subscribers flag is the security-critical signal:
|
||||||
|
// when false the broker must refuse to emit and the gate must fail closed.
|
||||||
|
type fakePublisher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
subscribers bool
|
||||||
|
events []*proto.SystemEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakePublisher) PublishEvent(
|
||||||
|
severity proto.SystemEvent_Severity,
|
||||||
|
category proto.SystemEvent_Category,
|
||||||
|
msg string,
|
||||||
|
userMsg string,
|
||||||
|
metadata map[string]string,
|
||||||
|
) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.events = append(p.events, &proto.SystemEvent{
|
||||||
|
Severity: severity,
|
||||||
|
Category: category,
|
||||||
|
Message: msg,
|
||||||
|
UserMessage: userMsg,
|
||||||
|
Metadata: metadata,
|
||||||
|
})
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakePublisher) HasEventSubscribers() bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.subscribers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakePublisher) lastEvent(t *testing.T) *proto.SystemEvent {
|
||||||
|
t.Helper()
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
require.NotEmpty(t, p.events, "publisher saw no events")
|
||||||
|
return p.events[len(p.events)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakePublisher) eventCount() int {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return len(p.events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestNoSubscriberFailsClosed is the core fail-closed invariant:
|
||||||
|
// when the UI is not subscribed, the broker must refuse without emitting
|
||||||
|
// an event or arming a waiter. A regression here is a silent bypass.
|
||||||
|
func TestRequestNoSubscriberFailsClosed(t *testing.T) {
|
||||||
|
pub := &fakePublisher{subscribers: false}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||||
|
assert.ErrorIs(t, err, ErrNoSubscriber)
|
||||||
|
assert.Equal(t, 0, pub.eventCount(), "no event must be emitted when fail-closed")
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
pending := len(b.pending)
|
||||||
|
b.mu.Unlock()
|
||||||
|
assert.Equal(t, 0, pending, "no waiter must be registered on fail-closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestTimeoutDenies verifies that a request without a UI response
|
||||||
|
// returns ErrTimeout (deny) rather than nil (silent accept). Uses a short
|
||||||
|
// per-test broker timeout via Respond after the fact to keep the test fast.
|
||||||
|
func TestRequestTimeoutDenies(t *testing.T) {
|
||||||
|
// Replace DefaultTimeout for the lifetime of this test.
|
||||||
|
orig := DefaultTimeout
|
||||||
|
defaultTimeout(t, 60*time.Millisecond)
|
||||||
|
defer defaultTimeout(t, orig)
|
||||||
|
|
||||||
|
pub := &fakePublisher{subscribers: true}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||||
|
assert.ErrorIs(t, err, ErrTimeout, "missing user response must yield ErrTimeout, not nil")
|
||||||
|
assert.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond, "timeout fired prematurely")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestDenied returns ErrDenied when the UI responds with false.
|
||||||
|
func TestRequestDenied(t *testing.T) {
|
||||||
|
pub := &fakePublisher{subscribers: true}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
var requestID string
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||||
|
}()
|
||||||
|
|
||||||
|
requestID = waitForRequestID(t, pub)
|
||||||
|
require.True(t, b.Respond(requestID, Decision{Accept: false}))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.ErrorIs(t, err, ErrDenied)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Request did not return after Respond(false)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestAccepted is the happy path. Failure here doesn't bypass the
|
||||||
|
// gate but breaks the feature.
|
||||||
|
func TestRequestAccepted(t *testing.T) {
|
||||||
|
pub := &fakePublisher{subscribers: true}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
|
||||||
|
}()
|
||||||
|
|
||||||
|
id := waitForRequestID(t, pub)
|
||||||
|
require.True(t, b.Respond(id, Decision{Accept: true}))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Request did not return after Respond(true)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestCtxCancelDenies verifies that an upstream cancel (e.g. the
|
||||||
|
// engine shutting down mid-prompt) returns the cancel error rather than
|
||||||
|
// nil. A nil here would be a silent bypass on shutdown races.
|
||||||
|
func TestRequestCtxCancelDenies(t *testing.T) {
|
||||||
|
pub := &fakePublisher{subscribers: true}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- requestErr(b, ctx, Prompt{Kind: KindVNC, Subject: "test"})
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait until the prompt is in flight so cancel races a live waiter.
|
||||||
|
_ = waitForRequestID(t, pub)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Request did not return after ctx cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespondUnknownIsNoop ensures a stray RespondApproval RPC cannot
|
||||||
|
// affect or accidentally accept any in-flight request whose id it doesn't
|
||||||
|
// match. Also confirms it doesn't panic.
|
||||||
|
func TestRespondUnknownIsNoop(t *testing.T) {
|
||||||
|
pub := &fakePublisher{subscribers: true}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
// No in-flight prompts: Respond returns false.
|
||||||
|
assert.False(t, b.Respond("does-not-exist", Decision{Accept: true}))
|
||||||
|
|
||||||
|
// With an in-flight prompt, a wrong id still returns false and the
|
||||||
|
// prompt remains armed (eventually timing out as a deny).
|
||||||
|
defaultTimeout(t, 60*time.Millisecond)
|
||||||
|
defer defaultTimeout(t, DefaultTimeout)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||||
|
}()
|
||||||
|
realID := waitForRequestID(t, pub)
|
||||||
|
assert.False(t, b.Respond("totally-bogus", Decision{Accept: true}), "unknown id must not match")
|
||||||
|
assert.NotEqual(t, "totally-bogus", realID)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.ErrorIs(t, err, ErrTimeout, "armed prompt must still time out, not accept")
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("prompt did not resolve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespondAfterTimeoutNoop confirms a late accept response can't
|
||||||
|
// retroactively flip a denied (timed-out) request. The dropPending defer
|
||||||
|
// in Request must have removed the entry by the time Respond races in.
|
||||||
|
func TestRespondAfterTimeoutNoop(t *testing.T) {
|
||||||
|
defaultTimeout(t, 30*time.Millisecond)
|
||||||
|
defer defaultTimeout(t, DefaultTimeout)
|
||||||
|
|
||||||
|
pub := &fakePublisher{subscribers: true}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||||
|
}()
|
||||||
|
id := waitForRequestID(t, pub)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.ErrorIs(t, err, ErrTimeout)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("prompt did not time out")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.False(t, b.Respond(id, Decision{Accept: true}), "late respond must be no-op")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespondDoubleNoop ensures a duplicate ack from the UI doesn't leak
|
||||||
|
// past the matched waiter or panic on a closed/full channel.
|
||||||
|
func TestRespondDoubleNoop(t *testing.T) {
|
||||||
|
pub := &fakePublisher{subscribers: true}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||||
|
}()
|
||||||
|
id := waitForRequestID(t, pub)
|
||||||
|
require.True(t, b.Respond(id, Decision{Accept: true}))
|
||||||
|
assert.False(t, b.Respond(id, Decision{Accept: false}), "second response must be no-op")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("prompt did not resolve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNilBrokerRequestErrors guards the engine pre-init path where the
|
||||||
|
// broker may not yet exist (or its publisher is nil): Request must
|
||||||
|
// error, never silently accept.
|
||||||
|
func TestNilBrokerRequestErrors(t *testing.T) {
|
||||||
|
var b *Broker
|
||||||
|
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC})
|
||||||
|
assert.Error(t, err, "nil broker must error, never silently accept")
|
||||||
|
|
||||||
|
b2 := New(nil)
|
||||||
|
_, err = b2.Request(context.Background(), Prompt{Kind: KindVNC})
|
||||||
|
assert.Error(t, err, "broker with nil publisher must error, never silently accept")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPromptMetadataInjected confirms the broker stamps request_id, kind,
|
||||||
|
// and expires_at on the emitted event. The UI relies on these keys; if
|
||||||
|
// they are dropped, the user cannot route the prompt and the response
|
||||||
|
// path breaks (which fails closed via timeout).
|
||||||
|
func TestPromptMetadataInjected(t *testing.T) {
|
||||||
|
pub := &fakePublisher{subscribers: true}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- requestErr(b, context.Background(), Prompt{
|
||||||
|
Kind: KindVNC,
|
||||||
|
Subject: "VNC connection from peerA",
|
||||||
|
Metadata: map[string]string{"peer_name": "peerA"},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
id := waitForRequestID(t, pub)
|
||||||
|
ev := pub.lastEvent(t)
|
||||||
|
|
||||||
|
assert.Equal(t, proto.SystemEvent_APPROVAL, ev.Category)
|
||||||
|
assert.Equal(t, KindVNC, ev.Metadata[MetaKind])
|
||||||
|
assert.Equal(t, id, ev.Metadata[MetaRequestID])
|
||||||
|
assert.NotEmpty(t, ev.Metadata[MetaExpiresAt])
|
||||||
|
assert.Equal(t, "peerA", ev.Metadata["peer_name"], "caller metadata must pass through")
|
||||||
|
|
||||||
|
require.True(t, b.Respond(id, Decision{Accept: true}))
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentRequests verifies that two concurrent prompts are tracked
|
||||||
|
// independently. A bug that aliases ids would let one Respond unblock
|
||||||
|
// the wrong waiter (a silent accept across prompts).
|
||||||
|
func TestConcurrentRequests(t *testing.T) {
|
||||||
|
pub := &fakePublisher{subscribers: true}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
const n = 20
|
||||||
|
results := make(chan error, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
go func() {
|
||||||
|
results <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := waitForNRequestIDs(t, pub, n)
|
||||||
|
require.Len(t, ids, n)
|
||||||
|
|
||||||
|
// Deny exactly half, accept the rest. Track outcome per id so we can
|
||||||
|
// match each Request's return value against the response we sent.
|
||||||
|
denySet := make(map[string]bool, n)
|
||||||
|
for i, id := range ids {
|
||||||
|
deny := i%2 == 0
|
||||||
|
denySet[id] = deny
|
||||||
|
require.True(t, b.Respond(id, Decision{Accept: !deny}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all returns and check no nil errors slipped past a deny.
|
||||||
|
var accepted, denied atomic.Int32
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
select {
|
||||||
|
case err := <-results:
|
||||||
|
if err == nil {
|
||||||
|
accepted.Add(1)
|
||||||
|
} else {
|
||||||
|
assert.ErrorIs(t, err, ErrDenied)
|
||||||
|
denied.Add(1)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("only got %d/%d responses", i, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, int32(n/2), denied.Load())
|
||||||
|
assert.Equal(t, int32(n/2), accepted.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForRequestID blocks until the publisher sees its next event and
|
||||||
|
// returns the request_id stamped on it.
|
||||||
|
func waitForRequestID(t *testing.T, pub *fakePublisher) string {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
pub.mu.Lock()
|
||||||
|
count := len(pub.events)
|
||||||
|
var id string
|
||||||
|
if count > 0 {
|
||||||
|
id = pub.events[count-1].Metadata[MetaRequestID]
|
||||||
|
}
|
||||||
|
pub.mu.Unlock()
|
||||||
|
if id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("timeout waiting for emitted event")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForNRequestIDs(t *testing.T, pub *fakePublisher, n int) []string {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
pub.mu.Lock()
|
||||||
|
count := len(pub.events)
|
||||||
|
pub.mu.Unlock()
|
||||||
|
if count >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
pub.mu.Lock()
|
||||||
|
defer pub.mu.Unlock()
|
||||||
|
out := make([]string, 0, len(pub.events))
|
||||||
|
seen := make(map[string]struct{}, len(pub.events))
|
||||||
|
for _, ev := range pub.events {
|
||||||
|
id := ev.Metadata[MetaRequestID]
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seen[id]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
if len(out) < n {
|
||||||
|
t.Fatalf("only got %d/%d request ids", len(out), n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultTimeout swaps the broker's per-request wall-clock window so the
|
||||||
|
// timeout tests run quickly. Restores the prior value on the next call.
|
||||||
|
func defaultTimeout(t *testing.T, d time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
if d <= 0 {
|
||||||
|
t.Fatal("defaultTimeout must be > 0")
|
||||||
|
}
|
||||||
|
timeoutValue = func() time.Duration { return d }
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestErr wraps Broker.Request to drop the Decision when tests only
|
||||||
|
// care about the error path. Keeps the goroutine bodies tight.
|
||||||
|
func requestErr(b *Broker, ctx context.Context, p Prompt) error {
|
||||||
|
_, err := b.Request(ctx, p)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestViewOnly checks the view-only outcome flows through Request's
|
||||||
|
// Decision return without being silently swallowed.
|
||||||
|
func TestRequestViewOnly(t *testing.T) {
|
||||||
|
pub := &fakePublisher{subscribers: true}
|
||||||
|
b := New(pub)
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
d Decision
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
done := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
d, err := b.Request(context.Background(), Prompt{Kind: KindVNC})
|
||||||
|
done <- result{d, err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
id := waitForRequestID(t, pub)
|
||||||
|
require.True(t, b.Respond(id, Decision{Accept: true, ViewOnly: true}))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r := <-done:
|
||||||
|
assert.NoError(t, r.err)
|
||||||
|
assert.True(t, r.d.Accept)
|
||||||
|
assert.True(t, r.d.ViewOnly, "ViewOnly must survive the round-trip")
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("view-only request did not resolve")
|
||||||
|
}
|
||||||
|
}
|
||||||
62
client/internal/approval/fingerprint_test.go
Normal file
62
client/internal/approval/fingerprint_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package approval
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestShortKeyFingerprint locks in the format the VNC approval prompt
|
||||||
|
// shows to the user. The fingerprint is the user's only cryptographic
|
||||||
|
// anchor against a malicious management server that pushes a spoofed
|
||||||
|
// display name, so accidental changes to its format would silently
|
||||||
|
// undermine that defence.
|
||||||
|
func TestShortKeyFingerprint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "full_32_byte_pubkey",
|
||||||
|
in: "0123456789abcdeffedcba9876543210ffeeddccbbaa99887766554433221100",
|
||||||
|
want: "0123-4567-89ab-cdef",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exactly_16_chars",
|
||||||
|
in: "0123456789abcdef",
|
||||||
|
want: "0123-4567-89ab-cdef",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "borderline_8_chars",
|
||||||
|
in: "01234567",
|
||||||
|
want: "0123-4567",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too_short_returns_empty",
|
||||||
|
in: "0123",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty_returns_empty",
|
||||||
|
in: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := ShortKeyFingerprint(tc.in)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("ShortKeyFingerprint(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShortKeyFingerprint_DistinctKeysDistinctOutputs guards against a
|
||||||
|
// formatting bug that would collapse different prefixes onto the same
|
||||||
|
// displayed fingerprint and let an attacker substitute their pubkey for
|
||||||
|
// a victim's while keeping the prompt visually identical.
|
||||||
|
func TestShortKeyFingerprint_DistinctKeysDistinctOutputs(t *testing.T) {
|
||||||
|
a := ShortKeyFingerprint("0123456789abcdef" + "rest_of_pubkey_ignored")
|
||||||
|
b := ShortKeyFingerprint("0123456789abcde0" + "rest_of_pubkey_ignored")
|
||||||
|
if a == b {
|
||||||
|
t.Fatalf("expected distinct outputs for distinct prefixes, both = %q", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package auth
|
|||||||
import (
|
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
|
||||||
}
|
}
|
||||||
@@ -344,6 +315,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
|||||||
a.config.RosenpassEnabled,
|
a.config.RosenpassEnabled,
|
||||||
a.config.RosenpassPermissive,
|
a.config.RosenpassPermissive,
|
||||||
a.config.ServerSSHAllowed,
|
a.config.ServerSSHAllowed,
|
||||||
|
a.config.ServerVNCAllowed,
|
||||||
a.config.DisableClientRoutes,
|
a.config.DisableClientRoutes,
|
||||||
a.config.DisableServerRoutes,
|
a.config.DisableServerRoutes,
|
||||||
a.config.DisableDNS,
|
a.config.DisableDNS,
|
||||||
@@ -503,16 +475,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) {
|
||||||
@@ -579,6 +562,8 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
|||||||
RosenpassEnabled: config.RosenpassEnabled,
|
RosenpassEnabled: config.RosenpassEnabled,
|
||||||
RosenpassPermissive: config.RosenpassPermissive,
|
RosenpassPermissive: config.RosenpassPermissive,
|
||||||
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
||||||
|
ServerVNCAllowed: config.ServerVNCAllowed != nil && *config.ServerVNCAllowed,
|
||||||
|
DisableVNCApproval: config.DisableVNCApproval,
|
||||||
EnableSSHRoot: config.EnableSSHRoot,
|
EnableSSHRoot: config.EnableSSHRoot,
|
||||||
EnableSSHSFTP: config.EnableSSHSFTP,
|
EnableSSHSFTP: config.EnableSSHSFTP,
|
||||||
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
||||||
@@ -661,6 +646,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
|||||||
config.RosenpassEnabled,
|
config.RosenpassEnabled,
|
||||||
config.RosenpassPermissive,
|
config.RosenpassPermissive,
|
||||||
config.ServerSSHAllowed,
|
config.ServerSSHAllowed,
|
||||||
|
config.ServerVNCAllowed,
|
||||||
config.DisableClientRoutes,
|
config.DisableClientRoutes,
|
||||||
config.DisableServerRoutes,
|
config.DisableServerRoutes,
|
||||||
config.DisableDNS,
|
config.DisableDNS,
|
||||||
|
|||||||
@@ -636,6 +636,12 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
|||||||
if g.internalConfig.SSHJWTCacheTTL != nil {
|
if g.internalConfig.SSHJWTCacheTTL != nil {
|
||||||
configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL))
|
configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL))
|
||||||
}
|
}
|
||||||
|
if g.internalConfig.ServerVNCAllowed != nil {
|
||||||
|
configContent.WriteString(fmt.Sprintf("ServerVNCAllowed: %v\n", *g.internalConfig.ServerVNCAllowed))
|
||||||
|
}
|
||||||
|
if g.internalConfig.DisableVNCApproval != nil {
|
||||||
|
configContent.WriteString(fmt.Sprintf("DisableVNCApproval: %v\n", *g.internalConfig.DisableVNCApproval))
|
||||||
|
}
|
||||||
|
|
||||||
configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes))
|
configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes))
|
||||||
configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes))
|
configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes))
|
||||||
|
|||||||
@@ -862,6 +862,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
|||||||
RosenpassEnabled: true,
|
RosenpassEnabled: true,
|
||||||
RosenpassPermissive: true,
|
RosenpassPermissive: true,
|
||||||
ServerSSHAllowed: &bTrue,
|
ServerSSHAllowed: &bTrue,
|
||||||
|
ServerVNCAllowed: &bTrue,
|
||||||
|
DisableVNCApproval: &bTrue,
|
||||||
EnableSSHRoot: &bTrue,
|
EnableSSHRoot: &bTrue,
|
||||||
EnableSSHSFTP: &bTrue,
|
EnableSSHSFTP: &bTrue,
|
||||||
EnableSSHLocalPortForwarding: &bTrue,
|
EnableSSHLocalPortForwarding: &bTrue,
|
||||||
|
|||||||
@@ -339,7 +339,8 @@ func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool {
|
|||||||
case entry.Pattern == ".":
|
case entry.Pattern == ".":
|
||||||
return true
|
return true
|
||||||
case entry.IsWildcard:
|
case entry.IsWildcard:
|
||||||
return strings.HasSuffix(qname, "."+entry.Pattern)
|
parts := strings.Split(strings.TrimSuffix(qname, entry.Pattern), ".")
|
||||||
|
return len(parts) >= 2 && strings.HasSuffix(qname, entry.Pattern)
|
||||||
default:
|
default:
|
||||||
// For non-wildcard patterns:
|
// For non-wildcard patterns:
|
||||||
// If handler wants subdomain matching, allow suffix match
|
// If handler wants subdomain matching, allow suffix match
|
||||||
|
|||||||
@@ -164,54 +164,6 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) {
|
|||||||
matchSubdomains: true,
|
matchSubdomains: true,
|
||||||
shouldMatch: true,
|
shouldMatch: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "wildcard label-boundary mismatch (suffix overlap)",
|
|
||||||
handlerDomain: "*.b.test.",
|
|
||||||
queryDomain: "x.ab.test.",
|
|
||||||
isWildcard: true,
|
|
||||||
matchSubdomains: false,
|
|
||||||
shouldMatch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard label-boundary match",
|
|
||||||
handlerDomain: "*.b.test.",
|
|
||||||
queryDomain: "x.b.test.",
|
|
||||||
isWildcard: true,
|
|
||||||
matchSubdomains: false,
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard multi-label match",
|
|
||||||
handlerDomain: "*.b.test.",
|
|
||||||
queryDomain: "x.y.b.test.",
|
|
||||||
isWildcard: true,
|
|
||||||
matchSubdomains: false,
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard no match on multi-label apex",
|
|
||||||
handlerDomain: "*.b.test.",
|
|
||||||
queryDomain: "b.test.",
|
|
||||||
isWildcard: true,
|
|
||||||
matchSubdomains: false,
|
|
||||||
shouldMatch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard no match on unrelated suffix containment",
|
|
||||||
handlerDomain: "*.example.com.",
|
|
||||||
queryDomain: "notexample.com.",
|
|
||||||
isWildcard: true,
|
|
||||||
matchSubdomains: false,
|
|
||||||
shouldMatch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard accepts pattern registered without trailing dot",
|
|
||||||
handlerDomain: "*.b.test",
|
|
||||||
queryDomain: "x.b.test.",
|
|
||||||
isWildcard: true,
|
|
||||||
matchSubdomains: false,
|
|
||||||
shouldMatch: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -321,19 +273,6 @@ func TestHandlerChain_ServeDNS_OverlappingDomains(t *testing.T) {
|
|||||||
expectedCalls: 1,
|
expectedCalls: 1,
|
||||||
expectedHandler: 2, // highest priority matching handler should be called
|
expectedHandler: 2, // highest priority matching handler should be called
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "overlapping wildcard suffixes route to correct handler",
|
|
||||||
handlers: []struct {
|
|
||||||
pattern string
|
|
||||||
priority int
|
|
||||||
}{
|
|
||||||
{pattern: "*.b.test.", priority: nbdns.PriorityDNSRoute},
|
|
||||||
{pattern: "*.ab.test.", priority: nbdns.PriorityDNSRoute},
|
|
||||||
},
|
|
||||||
queryDomain: "app.ab.test.",
|
|
||||||
expectedCalls: 1,
|
|
||||||
expectedHandler: 1,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "root zone with specific domain",
|
name: "root zone with specific domain",
|
||||||
handlers: []struct {
|
handlers: []struct {
|
||||||
|
|||||||
@@ -26,19 +26,6 @@ type resolver interface {
|
|||||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeerConnectivity reports whether a tunnel IP belongs to a peer the
|
|
||||||
// client knows about and whether that peer is currently connected. The
|
|
||||||
// local resolver uses this to suppress A/AAAA answers whose RDATA points
|
|
||||||
// at a disconnected peer (typical case: a synthesized private-service
|
|
||||||
// record pointing at an embedded proxy peer that just went offline).
|
|
||||||
//
|
|
||||||
// known=false means the IP isn't in the local peerstore at all — the
|
|
||||||
// record is left alone (it points at something outside our mesh, e.g.
|
|
||||||
// a non-peer upstream).
|
|
||||||
type PeerConnectivity interface {
|
|
||||||
IsConnectedByIP(ip string) (known, connected bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
records map[dns.Question][]dns.RR
|
records map[dns.Question][]dns.RR
|
||||||
@@ -46,11 +33,6 @@ type Resolver struct {
|
|||||||
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
|
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
|
||||||
zones map[domain.Domain]bool
|
zones map[domain.Domain]bool
|
||||||
resolver resolver
|
resolver resolver
|
||||||
// peerConn, when non-nil, is consulted on every A/AAAA answer to
|
|
||||||
// drop records pointing at disconnected peers. nil disables the
|
|
||||||
// filter and preserves the legacy "return whatever is registered"
|
|
||||||
// behaviour for callers that never wire a status source.
|
|
||||||
peerConn PeerConnectivity
|
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -67,15 +49,6 @@ func NewResolver() *Resolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPeerConnectivity wires the per-IP connectivity check used to filter
|
|
||||||
// out A/AAAA answers pointing at disconnected peers. Pass nil to disable.
|
|
||||||
// Safe to call multiple times; the latest value wins.
|
|
||||||
func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
|
|
||||||
d.mu.Lock()
|
|
||||||
defer d.mu.Unlock()
|
|
||||||
d.peerConn = p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Resolver) MatchSubdomains() bool {
|
func (d *Resolver) MatchSubdomains() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -122,7 +95,6 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
|||||||
replyMessage.RecursionAvailable = true
|
replyMessage.RecursionAvailable = true
|
||||||
|
|
||||||
result := d.lookupRecords(logger, question)
|
result := d.lookupRecords(logger, question)
|
||||||
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
|
|
||||||
replyMessage.Authoritative = !result.hasExternalData
|
replyMessage.Authoritative = !result.hasExternalData
|
||||||
replyMessage.Answer = result.records
|
replyMessage.Answer = result.records
|
||||||
replyMessage.Rcode = d.determineRcode(question, result)
|
replyMessage.Rcode = d.determineRcode(question, result)
|
||||||
@@ -464,78 +436,6 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterDisconnectedPeerAnswers drops A/AAAA records whose RDATA matches
|
|
||||||
// a known but disconnected peer. The synthesized private-service zones
|
|
||||||
// emit one A record per connected proxy peer in a cluster; when a peer
|
|
||||||
// goes offline, the server-side refresh removes the record from the
|
|
||||||
// next netmap, but the client may still hold the previous netmap for a
|
|
||||||
// short window. This filter is the local belt to that braces — even on
|
|
||||||
// the stale netmap, the resolver hides the offline target.
|
|
||||||
//
|
|
||||||
// Records pointing at unknown IPs (outside the local peerstore, e.g.
|
|
||||||
// non-mesh upstreams) are never dropped. Non-A/AAAA records pass
|
|
||||||
// through untouched.
|
|
||||||
//
|
|
||||||
// Escape hatch: if filtering would leave the answer empty AND at least
|
|
||||||
// one record was filtered, the original list is returned. Better to
|
|
||||||
// hand the client a record that may not respond than NXDOMAIN it
|
|
||||||
// completely when every proxy peer is offline (the upstream may still
|
|
||||||
// be reachable some other way, or the peerstore may be stale).
|
|
||||||
func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
|
|
||||||
if len(records) == 0 {
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
d.mu.RLock()
|
|
||||||
checker := d.peerConn
|
|
||||||
d.mu.RUnlock()
|
|
||||||
if checker == nil {
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
|
|
||||||
kept := make([]dns.RR, 0, len(records))
|
|
||||||
var dropped int
|
|
||||||
for _, rr := range records {
|
|
||||||
ip := extractRecordIP(rr)
|
|
||||||
if ip == "" {
|
|
||||||
kept = append(kept, rr)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
known, connected := checker.IsConnectedByIP(ip)
|
|
||||||
if known && !connected {
|
|
||||||
dropped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, rr)
|
|
||||||
}
|
|
||||||
if dropped == 0 {
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
if len(kept) == 0 {
|
|
||||||
logger.Debugf("all %d answers for %s point at disconnected peers; returning the original list", dropped, question.Name)
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
logger.Tracef("dropped %d disconnected-peer answer(s) for %s, returning %d", dropped, question.Name, len(kept))
|
|
||||||
return kept
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
|
|
||||||
// an A or AAAA record, or "" for any other record type.
|
|
||||||
func extractRecordIP(rr dns.RR) string {
|
|
||||||
switch r := rr.(type) {
|
|
||||||
case *dns.A:
|
|
||||||
if r.A == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return r.A.String()
|
|
||||||
case *dns.AAAA:
|
|
||||||
if r.AAAA == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return r.AAAA.String()
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update replaces all zones and their records
|
// Update replaces all zones and their records
|
||||||
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
|
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
|
|||||||
@@ -30,21 +30,6 @@ func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mockPeerConnectivity returns canned (known, connected) results per IP.
|
|
||||||
// Used by the disconnected-peer filter tests below. IPs not in the map
|
|
||||||
// are reported as unknown so the filter leaves them alone.
|
|
||||||
type mockPeerConnectivity struct {
|
|
||||||
byIP map[string]struct{ known, connected bool }
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
|
||||||
v, ok := m.byIP[ip]
|
|
||||||
if !ok {
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
return v.known, v.connected
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLocalResolver_ServeDNS(t *testing.T) {
|
func TestLocalResolver_ServeDNS(t *testing.T) {
|
||||||
recordA := nbdns.SimpleRecord{
|
recordA := nbdns.SimpleRecord{
|
||||||
Name: "peera.netbird.cloud.",
|
Name: "peera.netbird.cloud.",
|
||||||
@@ -2667,114 +2652,3 @@ func BenchmarkIsInManagedZone_ManyZones(b *testing.B) {
|
|||||||
resolver.isInManagedZone(qname)
|
resolver.isInManagedZone(qname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLocalResolver_FilterDisconnectedPeerAnswers verifies the
|
|
||||||
// connectivity-aware filtering layered on top of lookupRecords:
|
|
||||||
// when an A record's IP belongs to a known peer that's disconnected,
|
|
||||||
// the record is dropped from the answer. Records for unknown IPs pass
|
|
||||||
// through. If filtering would empty the answer entirely and at least
|
|
||||||
// one record was dropped, the original list is restored (escape hatch
|
|
||||||
// for the "all proxies offline" case).
|
|
||||||
func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
|
|
||||||
zone := "svc.cluster.netbird."
|
|
||||||
connectedRec := nbdns.SimpleRecord{
|
|
||||||
Name: zone,
|
|
||||||
Type: int(dns.TypeA),
|
|
||||||
Class: nbdns.DefaultClass,
|
|
||||||
TTL: 5,
|
|
||||||
RData: "100.64.0.10",
|
|
||||||
}
|
|
||||||
disconnectedRec := nbdns.SimpleRecord{
|
|
||||||
Name: zone,
|
|
||||||
Type: int(dns.TypeA),
|
|
||||||
Class: nbdns.DefaultClass,
|
|
||||||
TTL: 5,
|
|
||||||
RData: "100.64.0.11",
|
|
||||||
}
|
|
||||||
unknownRec := nbdns.SimpleRecord{
|
|
||||||
Name: zone,
|
|
||||||
Type: int(dns.TypeA),
|
|
||||||
Class: nbdns.DefaultClass,
|
|
||||||
TTL: 5,
|
|
||||||
RData: "203.0.113.5",
|
|
||||||
}
|
|
||||||
|
|
||||||
type ipState struct{ known, connected bool }
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
records []nbdns.SimpleRecord
|
|
||||||
connByIP map[string]ipState
|
|
||||||
wantInOrder []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "drops disconnected peer, keeps connected",
|
|
||||||
records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
|
|
||||||
connByIP: map[string]ipState{
|
|
||||||
"100.64.0.10": {known: true, connected: true},
|
|
||||||
"100.64.0.11": {known: true, connected: false},
|
|
||||||
},
|
|
||||||
wantInOrder: []string{"100.64.0.10"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown IPs pass through untouched",
|
|
||||||
records: []nbdns.SimpleRecord{unknownRec, disconnectedRec},
|
|
||||||
connByIP: map[string]ipState{
|
|
||||||
"100.64.0.11": {known: true, connected: false},
|
|
||||||
},
|
|
||||||
wantInOrder: []string{"203.0.113.5"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "all disconnected falls back to original list",
|
|
||||||
records: []nbdns.SimpleRecord{disconnectedRec, connectedRec},
|
|
||||||
connByIP: map[string]ipState{
|
|
||||||
"100.64.0.10": {known: true, connected: false},
|
|
||||||
"100.64.0.11": {known: true, connected: false},
|
|
||||||
},
|
|
||||||
wantInOrder: []string{"100.64.0.11", "100.64.0.10"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no checker wired returns all records",
|
|
||||||
records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
|
|
||||||
connByIP: nil,
|
|
||||||
wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
resolver := NewResolver()
|
|
||||||
if tc.connByIP != nil {
|
|
||||||
cm := mockPeerConnectivity{byIP: make(map[string]struct{ known, connected bool }, len(tc.connByIP))}
|
|
||||||
for ip, st := range tc.connByIP {
|
|
||||||
cm.byIP[ip] = struct{ known, connected bool }{st.known, st.connected}
|
|
||||||
}
|
|
||||||
resolver.SetPeerConnectivity(cm)
|
|
||||||
}
|
|
||||||
resolver.Update([]nbdns.CustomZone{{
|
|
||||||
Domain: strings.TrimSuffix(zone, "."),
|
|
||||||
Records: tc.records,
|
|
||||||
NonAuthoritative: true,
|
|
||||||
}})
|
|
||||||
|
|
||||||
var got *dns.Msg
|
|
||||||
writer := &test.MockResponseWriter{
|
|
||||||
WriteMsgFunc: func(m *dns.Msg) error {
|
|
||||||
got = m
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
req := new(dns.Msg).SetQuestion(zone, dns.TypeA)
|
|
||||||
resolver.ServeDNS(writer, req)
|
|
||||||
|
|
||||||
require.NotNil(t, got, "resolver must produce a response")
|
|
||||||
require.Len(t, got.Answer, len(tc.wantInOrder),
|
|
||||||
"answer count must match expected: %v", tc.wantInOrder)
|
|
||||||
for i, want := range tc.wantInOrder {
|
|
||||||
a, ok := got.Answer[i].(*dns.A)
|
|
||||||
require.True(t, ok, "answer[%d] must be an A record", i)
|
|
||||||
assert.Equal(t, want, a.A.String(),
|
|
||||||
"answer[%d] expected %s got %s", i, want, a.A.String())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -301,11 +301,6 @@ func newDefaultServer(
|
|||||||
warningDelayBase: defaultWarningDelayBase,
|
warningDelayBase: defaultWarningDelayBase,
|
||||||
healthRefresh: make(chan struct{}, 1),
|
healthRefresh: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
// Wire the local resolver against the peer status recorder so it can
|
|
||||||
// suppress A/AAAA answers that point at disconnected peers (typical
|
|
||||||
// case: synthesised private-service records pointing at an embedded
|
|
||||||
// proxy peer that just went offline).
|
|
||||||
defaultServer.localResolver.SetPeerConnectivity(localPeerConnectivity{statusRecorder})
|
|
||||||
|
|
||||||
// register with root zone, handler chain takes care of the routing
|
// register with root zone, handler chain takes care of the routing
|
||||||
dnsService.RegisterMux(".", handlerChain)
|
dnsService.RegisterMux(".", handlerChain)
|
||||||
@@ -1391,25 +1386,3 @@ func (s *DefaultServer) PopulateManagementDomain(mgmtURL *url.URL) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// localPeerConnectivity adapts *peer.Status to local.PeerConnectivity so
|
|
||||||
// the local resolver can ask "is this IP a known peer and is it
|
|
||||||
// connected?" without taking on the peer package as a dependency.
|
|
||||||
// A nil status recorder always reports known=false so the resolver
|
|
||||||
// short-circuits to the legacy "return everything" path.
|
|
||||||
type localPeerConnectivity struct {
|
|
||||||
status *peer.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
|
|
||||||
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
|
|
||||||
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
|
||||||
if l.status == nil {
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
state, ok := l.status.PeerStateByIP(ip)
|
|
||||||
if !ok {
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
return true, state.ConnStatus == peer.StatusConnected
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
"github.com/netbirdio/netbird/client/internal/acl"
|
"github.com/netbirdio/netbird/client/internal/acl"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/approval"
|
||||||
"github.com/netbirdio/netbird/client/internal/debug"
|
"github.com/netbirdio/netbird/client/internal/debug"
|
||||||
"github.com/netbirdio/netbird/client/internal/dns"
|
"github.com/netbirdio/netbird/client/internal/dns"
|
||||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||||
@@ -123,6 +124,8 @@ type EngineConfig struct {
|
|||||||
RosenpassPermissive bool
|
RosenpassPermissive bool
|
||||||
|
|
||||||
ServerSSHAllowed bool
|
ServerSSHAllowed bool
|
||||||
|
ServerVNCAllowed bool
|
||||||
|
DisableVNCApproval *bool
|
||||||
EnableSSHRoot *bool
|
EnableSSHRoot *bool
|
||||||
EnableSSHSFTP *bool
|
EnableSSHSFTP *bool
|
||||||
EnableSSHLocalPortForwarding *bool
|
EnableSSHLocalPortForwarding *bool
|
||||||
@@ -204,7 +207,9 @@ type Engine struct {
|
|||||||
|
|
||||||
networkMonitor *networkmonitor.NetworkMonitor
|
networkMonitor *networkmonitor.NetworkMonitor
|
||||||
|
|
||||||
sshServer sshServer
|
sshServer sshServer
|
||||||
|
vncSrv vncServer
|
||||||
|
approvalBroker *approval.Broker
|
||||||
|
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
|
|
||||||
@@ -250,20 +255,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
|
||||||
@@ -299,6 +290,7 @@ func NewEngine(
|
|||||||
TURNs: []*stun.URI{},
|
TURNs: []*stun.URI{},
|
||||||
networkSerial: 0,
|
networkSerial: 0,
|
||||||
statusRecorder: services.StatusRecorder,
|
statusRecorder: services.StatusRecorder,
|
||||||
|
approvalBroker: approval.New(services.StatusRecorder),
|
||||||
stateManager: services.StateManager,
|
stateManager: services.StateManager,
|
||||||
portForwardManager: portforward.NewManager(),
|
portForwardManager: portforward.NewManager(),
|
||||||
checks: services.Checks,
|
checks: services.Checks,
|
||||||
@@ -307,17 +299,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
|
||||||
@@ -345,6 +326,10 @@ func (e *Engine) Stop() error {
|
|||||||
log.Warnf("failed to stop SSH server: %v", err)
|
log.Warnf("failed to stop SSH server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := e.stopVNCServer(); err != nil {
|
||||||
|
log.Warnf("failed to stop VNC server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
e.cleanupSSHConfig()
|
e.cleanupSSHConfig()
|
||||||
|
|
||||||
if e.ingressGatewayMgr != nil {
|
if e.ingressGatewayMgr != nil {
|
||||||
@@ -358,10 +343,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 +875,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)
|
||||||
}
|
}
|
||||||
@@ -1041,6 +1020,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
|||||||
e.config.RosenpassEnabled,
|
e.config.RosenpassEnabled,
|
||||||
e.config.RosenpassPermissive,
|
e.config.RosenpassPermissive,
|
||||||
&e.config.ServerSSHAllowed,
|
&e.config.ServerSSHAllowed,
|
||||||
|
&e.config.ServerVNCAllowed,
|
||||||
e.config.DisableClientRoutes,
|
e.config.DisableClientRoutes,
|
||||||
e.config.DisableServerRoutes,
|
e.config.DisableServerRoutes,
|
||||||
e.config.DisableDNS,
|
e.config.DisableDNS,
|
||||||
@@ -1088,6 +1068,10 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := e.updateVNC(); err != nil {
|
||||||
|
log.Warnf("failed handling VNC server setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
state := e.statusRecorder.GetLocalPeerState()
|
state := e.statusRecorder.GetLocalPeerState()
|
||||||
state.IP = e.wgInterface.Address().String()
|
state.IP = e.wgInterface.Address().String()
|
||||||
state.IPv6 = e.wgInterface.Address().IPv6String()
|
state.IPv6 = e.wgInterface.Address().IPv6String()
|
||||||
@@ -1213,6 +1197,7 @@ func (e *Engine) receiveManagementEvents() {
|
|||||||
e.config.RosenpassEnabled,
|
e.config.RosenpassEnabled,
|
||||||
e.config.RosenpassPermissive,
|
e.config.RosenpassPermissive,
|
||||||
&e.config.ServerSSHAllowed,
|
&e.config.ServerSSHAllowed,
|
||||||
|
&e.config.ServerVNCAllowed,
|
||||||
e.config.DisableClientRoutes,
|
e.config.DisableClientRoutes,
|
||||||
e.config.DisableServerRoutes,
|
e.config.DisableServerRoutes,
|
||||||
e.config.DisableDNS,
|
e.config.DisableDNS,
|
||||||
@@ -1402,6 +1387,11 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
|||||||
e.updateSSHServerAuth(networkMap.GetSshAuth())
|
e.updateSSHServerAuth(networkMap.GetSshAuth())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VNC auth: always sync, including nil so cleared auth on the management
|
||||||
|
// side is applied locally, and so it isn't skipped on the RemotePeersIsEmpty
|
||||||
|
// cleanup path.
|
||||||
|
e.updateVNCServerAuth(networkMap.GetVncAuth())
|
||||||
|
|
||||||
// must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
|
// must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
|
||||||
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
|
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
|
||||||
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
||||||
@@ -1857,6 +1847,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
|||||||
e.config.RosenpassEnabled,
|
e.config.RosenpassEnabled,
|
||||||
e.config.RosenpassPermissive,
|
e.config.RosenpassPermissive,
|
||||||
&e.config.ServerSSHAllowed,
|
&e.config.ServerSSHAllowed,
|
||||||
|
&e.config.ServerVNCAllowed,
|
||||||
e.config.DisableClientRoutes,
|
e.config.DisableClientRoutes,
|
||||||
e.config.DisableServerRoutes,
|
e.config.DisableServerRoutes,
|
||||||
e.config.DisableDNS,
|
e.config.DisableDNS,
|
||||||
@@ -1998,29 +1989,6 @@ func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
|
|||||||
return e.clientMetrics
|
return e.clientMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// Performance bundles runtime-adjustable tunnel pool knobs.
|
|
||||||
// See Engine.SetPerformance. Nil fields are ignored.
|
|
||||||
type Performance struct {
|
|
||||||
PreallocatedBuffersPerPool *uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPerformance applies the given tuning to this engine's live Device.
|
|
||||||
func (e *Engine) SetPerformance(t Performance) error {
|
|
||||||
e.syncMsgMux.Lock()
|
|
||||||
defer e.syncMsgMux.Unlock()
|
|
||||||
if e.wgInterface == nil {
|
|
||||||
return fmt.Errorf("wg interface not initialized")
|
|
||||||
}
|
|
||||||
dev := e.wgInterface.GetWGDevice()
|
|
||||||
if dev == nil {
|
|
||||||
return fmt.Errorf("wg device not initialized")
|
|
||||||
}
|
|
||||||
if t.PreallocatedBuffersPerPool != nil {
|
|
||||||
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
||||||
iface, err := net.InterfaceByName(ifaceName)
|
iface, err := net.InterfaceByName(ifaceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2644,3 +2612,16 @@ func decodeRelayIP(b []byte) netip.Addr {
|
|||||||
}
|
}
|
||||||
return ip.Unmap()
|
return ip.Unmap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RespondApproval relays the user's decision for a pending approval to
|
||||||
|
// the broker. viewOnly is honoured only when accept is true. Returns
|
||||||
|
// true when the request_id matched a live prompt.
|
||||||
|
func (e *Engine) RespondApproval(requestID string, accept, viewOnly bool) bool {
|
||||||
|
if e == nil || e.approvalBroker == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return e.approvalBroker.Respond(requestID, approval.Decision{
|
||||||
|
Accept: accept,
|
||||||
|
ViewOnly: accept && viewOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {}
|
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||||
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
||||||
sshserver "github.com/netbirdio/netbird/client/ssh/server"
|
sshserver "github.com/netbirdio/netbird/client/ssh/server"
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
@@ -237,22 +237,18 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
|
|||||||
return errors.New("wg interface not initialized")
|
return errors.New("wg interface not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wgAddr := e.wgInterface.Address()
|
||||||
serverConfig := &sshserver.Config{
|
serverConfig := &sshserver.Config{
|
||||||
HostKeyPEM: e.config.SSHKey,
|
HostKeyPEM: e.config.SSHKey,
|
||||||
JWT: jwtConfig,
|
JWT: jwtConfig,
|
||||||
|
NetstackNet: e.wgInterface.GetNet(),
|
||||||
|
NetworkValidation: wgAddr,
|
||||||
}
|
}
|
||||||
server := sshserver.New(serverConfig)
|
server := sshserver.New(serverConfig)
|
||||||
|
|
||||||
wgAddr := e.wgInterface.Address()
|
|
||||||
server.SetNetworkValidation(wgAddr)
|
|
||||||
|
|
||||||
netbirdIP := wgAddr.IP
|
netbirdIP := wgAddr.IP
|
||||||
listenAddr := netip.AddrPortFrom(netbirdIP, sshserver.InternalSSHPort)
|
listenAddr := netip.AddrPortFrom(netbirdIP, sshserver.InternalSSHPort)
|
||||||
|
|
||||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
|
||||||
server.SetNetstackNet(netstackNet)
|
|
||||||
}
|
|
||||||
|
|
||||||
e.configureSSHServer(server)
|
e.configureSSHServer(server)
|
||||||
|
|
||||||
if err := server.Start(e.ctx, listenAddr); err != nil {
|
if err := server.Start(e.ctx, listenAddr); err != nil {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
"github.com/netbirdio/netbird/management/server/job"
|
"github.com/netbirdio/netbird/management/server/job"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
"github.com/netbirdio/management-integrations/integrations"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||||
@@ -66,8 +66,8 @@ import (
|
|||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
||||||
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
"github.com/netbirdio/netbird/shared/netiputil"
|
|
||||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||||
|
"github.com/netbirdio/netbird/shared/netiputil"
|
||||||
signal "github.com/netbirdio/netbird/shared/signal/client"
|
signal "github.com/netbirdio/netbird/shared/signal/client"
|
||||||
"github.com/netbirdio/netbird/shared/signal/proto"
|
"github.com/netbirdio/netbird/shared/signal/proto"
|
||||||
signalServer "github.com/netbirdio/netbird/signal/server"
|
signalServer "github.com/netbirdio/netbird/signal/server"
|
||||||
@@ -1641,7 +1641,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
||||||
|
|
||||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
303
client/internal/engine_vnc.go
Normal file
303
client/internal/engine_vnc.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
//go:build !js && !ios && !android
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/approval"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||||
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
"github.com/netbirdio/netbird/client/vnc"
|
||||||
|
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||||
|
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type vncServer interface {
|
||||||
|
Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error
|
||||||
|
Stop() error
|
||||||
|
ActiveSessions() []vncserver.ActiveSessionInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) setupVNCPortRedirection() error {
|
||||||
|
if e.firewall == nil || e.wgInterface == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
localAddr := e.wgInterface.Address().IP
|
||||||
|
if !localAddr.IsValid() {
|
||||||
|
return errors.New("invalid local NetBird address")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
|
||||||
|
return fmt.Errorf("add VNC port redirection: %w", err)
|
||||||
|
}
|
||||||
|
log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vnc.ExternalPort, localAddr, vnc.InternalPort)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) cleanupVNCPortRedirection() error {
|
||||||
|
if e.firewall == nil || e.wgInterface == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
localAddr := e.wgInterface.Address().IP
|
||||||
|
if !localAddr.IsValid() {
|
||||||
|
return errors.New("invalid local NetBird address")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
|
||||||
|
return fmt.Errorf("remove VNC port redirection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateVNC handles starting/stopping the VNC server based on the config flag.
|
||||||
|
func (e *Engine) updateVNC() error {
|
||||||
|
if !e.config.ServerVNCAllowed {
|
||||||
|
if e.vncSrv != nil {
|
||||||
|
log.Info("VNC server disabled, stopping")
|
||||||
|
}
|
||||||
|
return e.stopVNCServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.config.BlockInbound {
|
||||||
|
log.Info("VNC server disabled because inbound connections are blocked")
|
||||||
|
return e.stopVNCServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.vncSrv != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.startVNCServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) startVNCServer() error {
|
||||||
|
if e.wgInterface == nil {
|
||||||
|
return errors.New("wg interface not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
capturer, injector, ok := newPlatformVNC()
|
||||||
|
if !ok {
|
||||||
|
log.Debug("VNC server not supported on this platform")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
netbirdIP := e.wgInterface.Address().IP
|
||||||
|
|
||||||
|
var sessionRecorder func(vncserver.SessionTick)
|
||||||
|
if e.clientMetrics != nil {
|
||||||
|
sessionRecorder = func(t vncserver.SessionTick) {
|
||||||
|
e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{
|
||||||
|
Period: t.Period,
|
||||||
|
BytesOut: t.BytesOut,
|
||||||
|
Writes: t.Writes,
|
||||||
|
FBUs: t.FBUs,
|
||||||
|
MaxFBUBytes: t.MaxFBUBytes,
|
||||||
|
MaxFBURects: t.MaxFBURects,
|
||||||
|
MaxWriteBytes: t.MaxWriteBytes,
|
||||||
|
WriteNanos: t.WriteNanos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serviceMode := vncNeedsServiceMode()
|
||||||
|
if serviceMode {
|
||||||
|
log.Info("VNC: running as system service, enabling service mode (per-session agent proxy)")
|
||||||
|
}
|
||||||
|
requireApproval := e.config.DisableVNCApproval == nil || !*e.config.DisableVNCApproval
|
||||||
|
srv := vncserver.New(vncserver.Config{
|
||||||
|
Capturer: capturer,
|
||||||
|
Injector: injector,
|
||||||
|
IdentityKey: e.config.WgPrivateKey[:],
|
||||||
|
ServiceMode: serviceMode,
|
||||||
|
SessionRecorder: sessionRecorder,
|
||||||
|
NetstackNet: e.wgInterface.GetNet(),
|
||||||
|
RequireApproval: requireApproval,
|
||||||
|
Approver: &vncApprover{broker: e.approvalBroker, statusRecorder: e.statusRecorder},
|
||||||
|
})
|
||||||
|
|
||||||
|
listenAddr := netip.AddrPortFrom(netbirdIP, vnc.InternalPort)
|
||||||
|
network := e.wgInterface.Address().Network
|
||||||
|
if err := srv.Start(e.ctx, listenAddr, network); err != nil {
|
||||||
|
return fmt.Errorf("start VNC server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.vncSrv = srv
|
||||||
|
|
||||||
|
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||||
|
if registrar, ok := e.firewall.(interface {
|
||||||
|
RegisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||||
|
}); ok {
|
||||||
|
registrar.RegisterNetstackService(nftypes.TCP, vnc.InternalPort)
|
||||||
|
log.Debugf("registered VNC service with netstack for TCP:%d", vnc.InternalPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.setupVNCPortRedirection(); err != nil {
|
||||||
|
log.Warnf("setup VNC port redirection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("VNC server enabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateVNCServerAuth updates VNC fine-grained access control from management.
|
||||||
|
// A nil vncAuth clears all authorized users and session pubkeys so management
|
||||||
|
// can revoke access by omitting the field on the next sync.
|
||||||
|
func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) {
|
||||||
|
if e.vncSrv == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vncSrv, ok := e.vncSrv.(*vncserver.Server)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if vncAuth == nil {
|
||||||
|
vncSrv.UpdateVNCAuth(&sshauth.Config{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
protoUsers := vncAuth.GetAuthorizedUsers()
|
||||||
|
authorizedUsers := make([]sshuserhash.UserIDHash, len(protoUsers))
|
||||||
|
for i, hash := range protoUsers {
|
||||||
|
if len(hash) != 16 {
|
||||||
|
log.Warnf("invalid VNC auth hash length %d, expected 16", len(hash))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authorizedUsers[i] = sshuserhash.UserIDHash(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
machineUsers := make(map[string][]uint32)
|
||||||
|
for osUser, indexes := range vncAuth.GetMachineUsers() {
|
||||||
|
machineUsers[osUser] = indexes.GetIndexes()
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionPubKeys := make([]sshauth.SessionPubKey, 0, len(vncAuth.GetSessionPubKeys()))
|
||||||
|
for _, e := range vncAuth.GetSessionPubKeys() {
|
||||||
|
pub := e.GetPubKey()
|
||||||
|
if len(pub) != 32 {
|
||||||
|
log.Warnf("VNC session pubkey wrong length %d", len(pub))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hash := e.GetUserIdHash()
|
||||||
|
if len(hash) != 16 {
|
||||||
|
log.Warnf("VNC session user id hash wrong length %d", len(hash))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sessionPubKeys = append(sessionPubKeys, sshauth.SessionPubKey{
|
||||||
|
PubKey: pub,
|
||||||
|
UserIDHash: sshuserhash.UserIDHash(hash),
|
||||||
|
DisplayName: e.GetDisplayName(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
vncSrv.UpdateVNCAuth(&sshauth.Config{
|
||||||
|
AuthorizedUsers: authorizedUsers,
|
||||||
|
MachineUsers: machineUsers,
|
||||||
|
SessionPubKeys: sessionPubKeys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVNCServerStatus returns whether the VNC server is running and the list
|
||||||
|
// of active VNC sessions. The pointer is captured under syncMsgMux so a
|
||||||
|
// concurrent updateVNC/stopVNCServer cannot swap it out between the nil
|
||||||
|
// check and the ActiveSessions call.
|
||||||
|
func (e *Engine) GetVNCServerStatus() (enabled bool, sessions []vncserver.ActiveSessionInfo) {
|
||||||
|
e.syncMsgMux.Lock()
|
||||||
|
vncSrv := e.vncSrv
|
||||||
|
e.syncMsgMux.Unlock()
|
||||||
|
if vncSrv == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, vncSrv.ActiveSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) stopVNCServer() error {
|
||||||
|
if e.vncSrv == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.cleanupVNCPortRedirection(); err != nil {
|
||||||
|
log.Warnf("cleanup VNC port redirection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||||
|
if registrar, ok := e.firewall.(interface {
|
||||||
|
UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||||
|
}); ok {
|
||||||
|
registrar.UnregisterNetstackService(nftypes.TCP, vnc.InternalPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("stopping VNC server")
|
||||||
|
err := e.vncSrv.Stop()
|
||||||
|
e.vncSrv = nil
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stop VNC server: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// vncApprover adapts the generic approval.Broker for the VNC server.
|
||||||
|
type vncApprover struct {
|
||||||
|
broker *approval.Broker
|
||||||
|
statusRecorder *peer.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *vncApprover) Request(ctx context.Context, info vncserver.ApprovalInfo) (vncserver.ApprovalDecision, error) {
|
||||||
|
// Resolve the source overlay IP to a peer FQDN for the prompt label.
|
||||||
|
if info.PeerName == "" && info.SourceIP != "" && a.statusRecorder != nil {
|
||||||
|
if fqdn, ok := a.statusRecorder.PeerByIP(info.SourceIP); ok {
|
||||||
|
info.PeerName = fqdn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("VNC connection from %s", displayPeer(info))
|
||||||
|
meta := map[string]string{
|
||||||
|
"peer_name": info.PeerName,
|
||||||
|
"peer_pubkey": info.PeerPubKey,
|
||||||
|
"source_ip": info.SourceIP,
|
||||||
|
"mode": info.Mode,
|
||||||
|
"username": info.Username,
|
||||||
|
"initiator": info.Initiator,
|
||||||
|
}
|
||||||
|
d, err := a.broker.Request(ctx, approval.Prompt{
|
||||||
|
Kind: approval.KindVNC,
|
||||||
|
Subject: subject,
|
||||||
|
Metadata: meta,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return vncserver.ApprovalDecision{}, err
|
||||||
|
}
|
||||||
|
return vncserver.ApprovalDecision{ViewOnly: d.ViewOnly}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayPeer(info vncserver.ApprovalInfo) string {
|
||||||
|
if info.Initiator != "" {
|
||||||
|
return info.Initiator
|
||||||
|
}
|
||||||
|
if info.PeerName != "" {
|
||||||
|
return info.PeerName
|
||||||
|
}
|
||||||
|
if info.SourceIP != "" {
|
||||||
|
return info.SourceIP
|
||||||
|
}
|
||||||
|
if info.PeerPubKey != "" {
|
||||||
|
return info.PeerPubKey
|
||||||
|
}
|
||||||
|
return "unknown peer"
|
||||||
|
}
|
||||||
31
client/internal/engine_vnc_console_freebsd.go
Normal file
31
client/internal/engine_vnc_console_freebsd.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
//go:build freebsd
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newConsoleVNC builds the FreeBSD console fallback: vt(4) framebuffer
|
||||||
|
// for capture, /dev/uinput for input. The uinput device requires the
|
||||||
|
// `uinput` kernel module (`kldload uinput`); without it, input init
|
||||||
|
// fails and we drop to a stub injector so the user still gets a
|
||||||
|
// view-only screen mirror.
|
||||||
|
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||||
|
poller := vncserver.NewFBPoller("")
|
||||||
|
w, h := poller.Width(), poller.Height()
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
poller.Close()
|
||||||
|
return nil, nil, fmt.Errorf("vt framebuffer init failed (vt may not allow mmap on this driver)")
|
||||||
|
}
|
||||||
|
if inj, err := vncserver.NewUInputInjector(w, h); err == nil {
|
||||||
|
return poller, inj, nil
|
||||||
|
} else {
|
||||||
|
log.Infof("VNC console: uinput unavailable (%v); view-only mode. Run `kldload uinput` to enable input.", err)
|
||||||
|
return poller, &vncserver.StubInputInjector{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
30
client/internal/engine_vnc_console_linux.go
Normal file
30
client/internal/engine_vnc_console_linux.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//go:build linux && !android
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newConsoleVNC builds a framebuffer + uinput VNC backend for boxes
|
||||||
|
// without a running X server. Used as the auto-fallback when
|
||||||
|
// newPlatformVNC can't reach X. Returns an error when /dev/fb0 or
|
||||||
|
// /dev/uinput aren't usable so the caller can drop back to a stub.
|
||||||
|
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||||
|
poller := vncserver.NewFBPoller("")
|
||||||
|
w, h := poller.Width(), poller.Height()
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
poller.Close()
|
||||||
|
return nil, nil, fmt.Errorf("framebuffer capturer init failed (is /dev/fb0 readable?)")
|
||||||
|
}
|
||||||
|
inj, err := vncserver.NewUInputInjector(w, h)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("uinput unavailable, falling back to view-only VNC: %v", err)
|
||||||
|
return poller, &vncserver.StubInputInjector{}, nil
|
||||||
|
}
|
||||||
|
return poller, inj, nil
|
||||||
|
}
|
||||||
34
client/internal/engine_vnc_darwin.go
Normal file
34
client/internal/engine_vnc_darwin.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
//go:build darwin && !ios
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||||
|
capturer := vncserver.NewMacPoller()
|
||||||
|
// Prompt for Screen Recording at server-enable time rather than first
|
||||||
|
// client-connect. The native prompt is far easier for users to act on
|
||||||
|
// in the moment they toggled VNC on than later when "the screen looks
|
||||||
|
// like wallpaper" would otherwise be the only clue.
|
||||||
|
vncserver.PrimeScreenCapturePermission()
|
||||||
|
injector, err := vncserver.NewMacInputInjector()
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("VNC: macOS input injector: %v", err)
|
||||||
|
return capturer, &vncserver.StubInputInjector{}, true
|
||||||
|
}
|
||||||
|
return capturer, injector, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// vncNeedsServiceMode reports whether the running process is a system
|
||||||
|
// LaunchDaemon (root, parented by launchd). Daemons sit in the global
|
||||||
|
// bootstrap namespace and cannot talk to WindowServer; we route capture
|
||||||
|
// through a per-user agent in that case.
|
||||||
|
func vncNeedsServiceMode() bool {
|
||||||
|
return os.Geteuid() == 0 && os.Getppid() == 1
|
||||||
|
}
|
||||||
23
client/internal/engine_vnc_stub.go
Normal file
23
client/internal/engine_vnc_stub.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build js || ios || android
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type vncServer interface{}
|
||||||
|
|
||||||
|
func (e *Engine) updateVNC() error { return nil }
|
||||||
|
|
||||||
|
func (e *Engine) updateVNCServerAuth(auth *mgmProto.VNCAuth) {
|
||||||
|
if auth == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debugf("ignoring VNC auth push on platform without a VNC server: %d session pubkeys, %d authorized users",
|
||||||
|
len(auth.GetSessionPubKeys()), len(auth.GetAuthorizedUsers()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) stopVNCServer() error { return nil }
|
||||||
13
client/internal/engine_vnc_windows.go
Normal file
13
client/internal/engine_vnc_windows.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||||
|
|
||||||
|
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||||
|
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func vncNeedsServiceMode() bool {
|
||||||
|
return vncserver.GetCurrentSessionID() == 0
|
||||||
|
}
|
||||||
35
client/internal/engine_vnc_x11.go
Normal file
35
client/internal/engine_vnc_x11.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build (linux && !android) || freebsd
|
||||||
|
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||||
|
// Prefer X11 when an X server is reachable. NewX11InputInjector probes
|
||||||
|
// DISPLAY (and /proc) eagerly, so a non-nil error here means no X.
|
||||||
|
injector, err := vncserver.NewX11InputInjector("", "", "")
|
||||||
|
if err == nil {
|
||||||
|
return vncserver.NewX11Poller("", ""), injector, true
|
||||||
|
}
|
||||||
|
log.Debugf("VNC: X11 not available: %v", err)
|
||||||
|
|
||||||
|
// Fallback for headless / pre-X states (kernel console, login manager
|
||||||
|
// without X, physical server in recovery): stream the framebuffer and
|
||||||
|
// inject input via /dev/uinput.
|
||||||
|
consoleCap, consoleInj, err := newConsoleVNC()
|
||||||
|
if err == nil {
|
||||||
|
log.Infof("VNC: using framebuffer console capture (%dx%d)", consoleCap.Width(), consoleCap.Height())
|
||||||
|
return consoleCap, consoleInj, true
|
||||||
|
}
|
||||||
|
log.Debugf("VNC: framebuffer console fallback unavailable: %v", err)
|
||||||
|
|
||||||
|
return &vncserver.StubCapturer{}, &vncserver.StubInputInjector{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func vncNeedsServiceMode() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -120,6 +120,36 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
|
|||||||
m.trimLocked()
|
m.trimLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *influxDBMetrics) RecordVNCSessionTick(_ context.Context, agentInfo AgentInfo, tick VNCSessionTick) {
|
||||||
|
tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s",
|
||||||
|
agentInfo.DeploymentType.String(),
|
||||||
|
agentInfo.Version,
|
||||||
|
agentInfo.OS,
|
||||||
|
agentInfo.Arch,
|
||||||
|
agentInfo.peerID,
|
||||||
|
)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.samples = append(m.samples, influxSample{
|
||||||
|
measurement: "netbird_vnc_traffic",
|
||||||
|
tags: tags,
|
||||||
|
fields: map[string]float64{
|
||||||
|
"period_seconds": tick.Period.Seconds(),
|
||||||
|
"bytes_out": float64(tick.BytesOut),
|
||||||
|
"writes": float64(tick.Writes),
|
||||||
|
"fbus": float64(tick.FBUs),
|
||||||
|
"max_fbu_bytes": float64(tick.MaxFBUBytes),
|
||||||
|
"max_fbu_rects": float64(tick.MaxFBURects),
|
||||||
|
"max_write_bytes": float64(tick.MaxWriteBytes),
|
||||||
|
"write_time_seconds": float64(tick.WriteNanos) / 1e9,
|
||||||
|
},
|
||||||
|
timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
m.trimLocked()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
|
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
|
||||||
result := "success"
|
result := "success"
|
||||||
if !success {
|
if !success {
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ type metricsImplementation interface {
|
|||||||
// RecordLoginDuration records how long the login to management took
|
// RecordLoginDuration records how long the login to management took
|
||||||
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
|
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
|
||||||
|
|
||||||
|
// RecordVNCSessionTick records a periodic snapshot of one VNC
|
||||||
|
// session's wire activity. Called once per metricsConn tick interval
|
||||||
|
// (and once at session close), only when the tick saw activity.
|
||||||
|
RecordVNCSessionTick(ctx context.Context, agentInfo AgentInfo, tick VNCSessionTick)
|
||||||
|
|
||||||
// Export exports metrics in InfluxDB line protocol format
|
// Export exports metrics in InfluxDB line protocol format
|
||||||
Export(w io.Writer) error
|
Export(w io.Writer) error
|
||||||
|
|
||||||
@@ -78,6 +83,21 @@ type ClientMetrics struct {
|
|||||||
pushCancel context.CancelFunc
|
pushCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VNCSessionTick is one sampling slice of a VNC session's wire activity.
|
||||||
|
// BytesOut / Writes / FBUs / WriteNanos are deltas observed during this
|
||||||
|
// tick; Max* fields are the high-water marks observed during the tick.
|
||||||
|
// Period is the wall-clock duration the deltas cover.
|
||||||
|
type VNCSessionTick struct {
|
||||||
|
Period time.Duration
|
||||||
|
BytesOut uint64
|
||||||
|
Writes uint64
|
||||||
|
FBUs uint64
|
||||||
|
MaxFBUBytes uint64
|
||||||
|
MaxFBURects uint64
|
||||||
|
MaxWriteBytes uint64
|
||||||
|
WriteNanos uint64
|
||||||
|
}
|
||||||
|
|
||||||
// ConnectionStageTimestamps holds timestamps for each connection stage
|
// ConnectionStageTimestamps holds timestamps for each connection stage
|
||||||
type ConnectionStageTimestamps struct {
|
type ConnectionStageTimestamps struct {
|
||||||
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
|
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
|
||||||
@@ -127,6 +147,17 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
|
|||||||
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
|
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordVNCSessionTick records a periodic snapshot of one VNC session.
|
||||||
|
func (c *ClientMetrics) RecordVNCSessionTick(ctx context.Context, tick VNCSessionTick) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
agentInfo := c.agentInfo
|
||||||
|
c.mu.RUnlock()
|
||||||
|
c.impl.RecordVNCSessionTick(ctx, agentInfo, tick)
|
||||||
|
}
|
||||||
|
|
||||||
// RecordLoginDuration records how long the login to management server took
|
// RecordLoginDuration records how long the login to management server took
|
||||||
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
|
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.
|
|||||||
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
|
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockMetrics) RecordVNCSessionTick(_ context.Context, _ AgentInfo, _ VNCSessionTick) {
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockMetrics) Export(w io.Writer) error {
|
func (m *mockMetrics) Export(w io.Writer) error {
|
||||||
if m.exportData != "" {
|
if m.exportData != "" {
|
||||||
_, err := w.Write([]byte(m.exportData))
|
_, err := w.Write([]byte(m.exportData))
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
|||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
// handle route changes
|
// handle route changes
|
||||||
case unix.RTM_ADD, syscall.RTM_DELETE:
|
case unix.RTM_ADD, syscall.RTM_DELETE:
|
||||||
route, flags, err := parseRouteMessage(buf[:n])
|
route, err := parseRouteMessage(buf[:n])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("Network monitor: error parsing routing message: %v", err)
|
log.Debugf("Network monitor: error parsing routing message: %v", err)
|
||||||
continue
|
continue
|
||||||
@@ -66,10 +66,6 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
|||||||
}
|
}
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case unix.RTM_ADD:
|
case unix.RTM_ADD:
|
||||||
if systemops.IgnoreAddedDefaultRoute(flags) {
|
|
||||||
log.Debugf("Network monitor: ignoring added default route via %s, interface %s, flags %#x", route.Gw, intf, flags)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
|
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
|
||||||
return nil
|
return nil
|
||||||
case unix.RTM_DELETE:
|
case unix.RTM_DELETE:
|
||||||
@@ -82,26 +78,22 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRouteMessage(buf []byte) (*systemops.Route, int, error) {
|
func parseRouteMessage(buf []byte) (*systemops.Route, error) {
|
||||||
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
|
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("parse RIB: %v", err)
|
return nil, fmt.Errorf("parse RIB: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(msgs) != 1 {
|
if len(msgs) != 1 {
|
||||||
return nil, 0, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
|
return nil, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, ok := msgs[0].(*route.RouteMessage)
|
msg, ok := msgs[0].(*route.RouteMessage)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, 0, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
|
return nil, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := systemops.MsgToRoute(msg)
|
return systemops.MsgToRoute(msg)
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
return r, msg.Flags, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitReadable blocks until fd has data to read, or ctx is cancelled.
|
// waitReadable blocks until fd has data to read, or ctx is cancelled.
|
||||||
|
|||||||
@@ -23,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"runtime"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -187,32 +185,23 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
return s.eventsChan
|
return s.eventsChan
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status holds a state of peers, signal, management connections and relays.
|
// Status holds a state of peers, signal, management connections and relays
|
||||||
// mux is an RWMutex so hot read paths (notably PeerStateByIP, called for
|
|
||||||
// every private-service request) don't contend against each other.
|
|
||||||
// Pure read methods take RLock; anything that mutates state takes Lock.
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
mux sync.RWMutex
|
mux sync.Mutex
|
||||||
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
|
||||||
@@ -228,21 +217,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
|
||||||
@@ -256,7 +230,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,
|
||||||
@@ -310,8 +283,8 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
|
|||||||
|
|
||||||
// GetPeer adds peer to Daemon status map
|
// GetPeer adds peer to Daemon status map
|
||||||
func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
|
|
||||||
state, ok := d.peers[peerPubKey]
|
state, ok := d.peers[peerPubKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -321,8 +294,8 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) PeerByIP(ip string) (string, bool) {
|
func (d *Status) PeerByIP(ip string) (string, bool) {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
|
|
||||||
for _, state := range d.peers {
|
for _, state := range d.peers {
|
||||||
if state.IP == ip {
|
if state.IP == ip {
|
||||||
@@ -332,25 +305,6 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeerStateByIP returns the full peer State for the given tunnel IP.
|
|
||||||
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
|
||||||
// address so dual-stack peers are reachable on either family. Returns the
|
|
||||||
// zero State and false when no peer matches or the input is empty.
|
|
||||||
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
|
||||||
if ip == "" {
|
|
||||||
return State{}, false
|
|
||||||
}
|
|
||||||
d.mux.RLock()
|
|
||||||
defer d.mux.RUnlock()
|
|
||||||
|
|
||||||
for _, state := range d.peers {
|
|
||||||
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
|
|
||||||
return state, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return State{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemovePeer removes peer from Daemon status map
|
// RemovePeer removes peer from Daemon status map
|
||||||
func (d *Status) RemovePeer(peerPubKey string) error {
|
func (d *Status) RemovePeer(peerPubKey string) error {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
@@ -406,7 +360,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +385,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +410,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,7 +459,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,7 +495,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,7 +530,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +568,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,7 +661,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 {
|
||||||
@@ -756,8 +702,8 @@ func (d *Status) UnsubscribePeerStateChanges(subscription *StatusChangeSubscript
|
|||||||
|
|
||||||
// GetLocalPeerState returns the local peer state
|
// GetLocalPeerState returns the local peer state
|
||||||
func (d *Status) GetLocalPeerState() LocalPeerState {
|
func (d *Status) GetLocalPeerState() LocalPeerState {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
return d.localPeer.Clone()
|
return d.localPeer.Clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,41 +719,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
|
||||||
@@ -876,7 +787,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
|
||||||
@@ -889,7 +799,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
|
||||||
@@ -902,7 +811,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
|
||||||
@@ -943,7 +851,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
|
||||||
@@ -956,7 +863,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) {
|
||||||
@@ -1003,8 +909,8 @@ func (d *Status) DeleteResolvedDomainsStates(domain domain.Domain) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetRosenpassState() RosenpassState {
|
func (d *Status) GetRosenpassState() RosenpassState {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
return RosenpassState{
|
return RosenpassState{
|
||||||
d.rosenpassEnabled,
|
d.rosenpassEnabled,
|
||||||
d.rosenpassPermissive,
|
d.rosenpassPermissive,
|
||||||
@@ -1012,14 +918,14 @@ func (d *Status) GetRosenpassState() RosenpassState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetLazyConnection() bool {
|
func (d *Status) GetLazyConnection() bool {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
return d.lazyConnectionEnabled
|
return d.lazyConnectionEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetManagementState() ManagementState {
|
func (d *Status) GetManagementState() ManagementState {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
return ManagementState{
|
return ManagementState{
|
||||||
d.mgmAddress,
|
d.mgmAddress,
|
||||||
d.managementState,
|
d.managementState,
|
||||||
@@ -1045,8 +951,8 @@ func (d *Status) UpdateLatency(pubKey string, latency time.Duration) error {
|
|||||||
|
|
||||||
// IsLoginRequired determines if a peer's login has expired.
|
// IsLoginRequired determines if a peer's login has expired.
|
||||||
func (d *Status) IsLoginRequired() bool {
|
func (d *Status) IsLoginRequired() bool {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
|
|
||||||
// if peer is connected to the management then login is not expired
|
// if peer is connected to the management then login is not expired
|
||||||
if d.managementState {
|
if d.managementState {
|
||||||
@@ -1061,8 +967,8 @@ func (d *Status) IsLoginRequired() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetSignalState() SignalState {
|
func (d *Status) GetSignalState() SignalState {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
return SignalState{
|
return SignalState{
|
||||||
d.signalAddress,
|
d.signalAddress,
|
||||||
d.signalState,
|
d.signalState,
|
||||||
@@ -1072,8 +978,8 @@ func (d *Status) GetSignalState() SignalState {
|
|||||||
|
|
||||||
// GetRelayStates returns the stun/turn/permanent relay states
|
// GetRelayStates returns the stun/turn/permanent relay states
|
||||||
func (d *Status) GetRelayStates() []relay.ProbeResult {
|
func (d *Status) GetRelayStates() []relay.ProbeResult {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
if d.relayMgr == nil {
|
if d.relayMgr == nil {
|
||||||
return d.relayStates
|
return d.relayStates
|
||||||
}
|
}
|
||||||
@@ -1102,8 +1008,8 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
if d.ingressGwMgr == nil {
|
if d.ingressGwMgr == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1112,16 +1018,16 @@ func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetDNSStates() []NSGroupState {
|
func (d *Status) GetDNSStates() []NSGroupState {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
|
|
||||||
// shallow copy is good enough, as slices fields are currently not updated
|
// shallow copy is good enough, as slices fields are currently not updated
|
||||||
return slices.Clone(d.nsGroupStates)
|
return slices.Clone(d.nsGroupStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
|
func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
return maps.Clone(d.resolvedDomainsStates)
|
return maps.Clone(d.resolvedDomainsStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,8 +1043,8 @@ func (d *Status) GetFullStatus() FullStatus {
|
|||||||
LazyConnectionEnabled: d.GetLazyConnection(),
|
LazyConnectionEnabled: d.GetLazyConnection(),
|
||||||
}
|
}
|
||||||
|
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
|
|
||||||
fullStatus.LocalPeerState = d.localPeer
|
fullStatus.LocalPeerState = d.localPeer
|
||||||
|
|
||||||
@@ -1154,19 +1060,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
|
||||||
@@ -1288,6 +1191,15 @@ func (d *Status) SubscribeToEvents() *EventSubscription {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasEventSubscribers reports whether any client is currently subscribed
|
||||||
|
// to the daemon's SystemEvent stream. Used by the VNC approval broker to
|
||||||
|
// fail closed when no UI is connected to prompt the user.
|
||||||
|
func (d *Status) HasEventSubscribers() bool {
|
||||||
|
d.eventMux.Lock()
|
||||||
|
defer d.eventMux.Unlock()
|
||||||
|
return len(d.eventStreams) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// UnsubscribeFromEvents removes an event subscription
|
// UnsubscribeFromEvents removes an event subscription
|
||||||
func (d *Status) UnsubscribeFromEvents(sub *EventSubscription) {
|
func (d *Status) UnsubscribeFromEvents(sub *EventSubscription) {
|
||||||
if sub == nil {
|
if sub == nil {
|
||||||
@@ -1308,82 +1220,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() {
|
|
||||||
if _, file, line, ok := runtime.Caller(1); ok {
|
|
||||||
log.Infof("--- notifyStateChange from %s:%d", file, line)
|
|
||||||
}
|
|
||||||
d.stateChangeMux.Lock()
|
|
||||||
defer d.stateChangeMux.Unlock()
|
|
||||||
|
|
||||||
for _, ch := range d.stateChangeStreams {
|
|
||||||
select {
|
|
||||||
case ch <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotifyStateChange is the public wake-the-subscribers entry point used by
|
|
||||||
// callers that mutate state outside the peer recorder — most importantly
|
|
||||||
// the connect-state machine, which writes StatusNeedsLogin into the
|
|
||||||
// shared contextState (client/internal/state.go) without touching any
|
|
||||||
// recorder field. Without this push the SubscribeStatus stream stays on
|
|
||||||
// the previous snapshot until an unrelated peer/management/signal
|
|
||||||
// change happens to fire notifyStateChange, leaving the UI's status
|
|
||||||
// out of sync with the daemon.
|
|
||||||
func (d *Status) NotifyStateChange() {
|
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
// BumpNetworksRevision increments the routed-networks revision and wakes every
|
|
||||||
// SubscribeStatus subscriber. The route manager calls it when a network map
|
|
||||||
// changes the available routes or when a selection is applied — the peer
|
|
||||||
// status itself only records actively-routed (chosen) networks, so without
|
|
||||||
// this bump a candidate route appearing/disappearing would never reach the UI.
|
|
||||||
func (d *Status) BumpNetworksRevision() {
|
|
||||||
d.networksRevision.Add(1)
|
|
||||||
d.notifyStateChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNetworksRevision returns the current routed-networks revision, surfaced in
|
|
||||||
// the status snapshot so the UI can detect route/selection changes (see
|
|
||||||
// BumpNetworksRevision).
|
|
||||||
func (d *Status) GetNetworksRevision() uint64 {
|
|
||||||
return d.networksRevision.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.Unlock()
|
||||||
@@ -1392,8 +1228,8 @@ func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) PeersStatus() (*configurer.Stats, error) {
|
func (d *Status) PeersStatus() (*configurer.Stats, error) {
|
||||||
d.mux.RLock()
|
d.mux.Lock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.Unlock()
|
||||||
if d.wgIface == nil {
|
if d.wgIface == nil {
|
||||||
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
|
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,33 +63,6 @@ func TestUpdatePeerState(t *testing.T) {
|
|||||||
assert.Equal(t, ip, state.IP, "ip should be equal")
|
assert.Equal(t, ip, state.IP, "ip should be equal")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStatus_PeerStateByIP(t *testing.T) {
|
|
||||||
status := NewRecorder("https://mgm")
|
|
||||||
req := require.New(t)
|
|
||||||
|
|
||||||
req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", ""))
|
|
||||||
req.NoError(status.AddPeer("pk-2", "peer-2.netbird", "100.64.0.11", ""))
|
|
||||||
|
|
||||||
state, ok := status.PeerStateByIP("100.64.0.10")
|
|
||||||
req.True(ok, "known tunnel IP should resolve to a peer state")
|
|
||||||
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
|
||||||
req.Equal("peer-1.netbird", state.FQDN, "matching state must carry the right FQDN")
|
|
||||||
|
|
||||||
_, ok = status.PeerStateByIP("100.64.0.99")
|
|
||||||
req.False(ok, "unknown IP must report ok=false")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
|
|
||||||
status := NewRecorder("https://mgm")
|
|
||||||
req := require.New(t)
|
|
||||||
|
|
||||||
req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
|
|
||||||
|
|
||||||
state, ok := status.PeerStateByIP("fd00::1")
|
|
||||||
req.True(ok, "IPv6-only match must resolve to the peer state")
|
|
||||||
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
||||||
key := "abc"
|
key := "abc"
|
||||||
fqdn := "peer-a.netbird.local"
|
fqdn := "peer-a.netbird.local"
|
||||||
|
|||||||
@@ -179,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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ type ConfigInput struct {
|
|||||||
StateFilePath string
|
StateFilePath string
|
||||||
PreSharedKey *string
|
PreSharedKey *string
|
||||||
ServerSSHAllowed *bool
|
ServerSSHAllowed *bool
|
||||||
|
ServerVNCAllowed *bool
|
||||||
|
DisableVNCApproval *bool
|
||||||
EnableSSHRoot *bool
|
EnableSSHRoot *bool
|
||||||
EnableSSHSFTP *bool
|
EnableSSHSFTP *bool
|
||||||
EnableSSHLocalPortForwarding *bool
|
EnableSSHLocalPortForwarding *bool
|
||||||
@@ -116,6 +118,8 @@ type Config struct {
|
|||||||
RosenpassEnabled bool
|
RosenpassEnabled bool
|
||||||
RosenpassPermissive bool
|
RosenpassPermissive bool
|
||||||
ServerSSHAllowed *bool
|
ServerSSHAllowed *bool
|
||||||
|
ServerVNCAllowed *bool
|
||||||
|
DisableVNCApproval *bool
|
||||||
EnableSSHRoot *bool
|
EnableSSHRoot *bool
|
||||||
EnableSSHSFTP *bool
|
EnableSSHSFTP *bool
|
||||||
EnableSSHLocalPortForwarding *bool
|
EnableSSHLocalPortForwarding *bool
|
||||||
@@ -418,6 +422,33 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.ServerVNCAllowed != nil {
|
||||||
|
if config.ServerVNCAllowed == nil || *input.ServerVNCAllowed != *config.ServerVNCAllowed {
|
||||||
|
if *input.ServerVNCAllowed {
|
||||||
|
log.Infof("enabling VNC server")
|
||||||
|
} else {
|
||||||
|
log.Infof("disabling VNC server")
|
||||||
|
}
|
||||||
|
config.ServerVNCAllowed = input.ServerVNCAllowed
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
} else if config.ServerVNCAllowed == nil {
|
||||||
|
config.ServerVNCAllowed = util.False()
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.DisableVNCApproval != nil {
|
||||||
|
if config.DisableVNCApproval == nil || *input.DisableVNCApproval != *config.DisableVNCApproval {
|
||||||
|
if *input.DisableVNCApproval {
|
||||||
|
log.Infof("disabling VNC connection approval prompt")
|
||||||
|
} else {
|
||||||
|
log.Infof("enabling VNC connection approval prompt")
|
||||||
|
}
|
||||||
|
config.DisableVNCApproval = input.DisableVNCApproval
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot {
|
if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot {
|
||||||
if *input.EnableSSHRoot {
|
if *input.EnableSSHRoot {
|
||||||
log.Infof("enabling SSH root login")
|
log.Infof("enabling SSH root login")
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
//go:build dragonfly || freebsd || netbsd || openbsd
|
|
||||||
|
|
||||||
package systemops
|
|
||||||
|
|
||||||
// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
|
|
||||||
// given flags should be ignored by the network monitor.
|
|
||||||
func IgnoreAddedDefaultRoute(flags int) bool {
|
|
||||||
return filterRoutesByFlags(flags)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
//go:build darwin
|
|
||||||
|
|
||||||
package systemops
|
|
||||||
|
|
||||||
import "golang.org/x/sys/unix"
|
|
||||||
|
|
||||||
// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
|
|
||||||
// given flags should be ignored by the network monitor. Scoped routes
|
|
||||||
// (RTF_IFSCOPE) are tied to a specific interface index and cannot replace the
|
|
||||||
// unscoped default the kernel uses for general egress, so flapping ones (e.g.
|
|
||||||
// Wi-Fi calling IMS tunnels on ipsec0, Docker bridges, scoped utun defaults)
|
|
||||||
// must not trigger an engine restart.
|
|
||||||
func IgnoreAddedDefaultRoute(flags int) bool {
|
|
||||||
if filterRoutesByFlags(flags) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if flags&unix.RTF_IFSCOPE != 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -124,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()
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"runtime"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type StatusType string
|
type StatusType string
|
||||||
@@ -36,37 +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) {
|
||||||
if _, file, line, ok := runtime.Caller(1); ok {
|
|
||||||
log.Infof("--- state.Set(%s) from %s:%d", update, file, line)
|
|
||||||
}
|
|
||||||
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) {
|
||||||
@@ -80,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()
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ func New(filePath string) *Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FilePath returns the path of the underlying state file.
|
||||||
|
func (m *Manager) FilePath() string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.filePath
|
||||||
|
}
|
||||||
|
|
||||||
// Start starts the state manager periodic save routine
|
// Start starts the state manager periodic save routine
|
||||||
func (m *Manager) Start() {
|
func (m *Manager) Start() {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
@@ -96,19 +104,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
|
||||||
|
|||||||
@@ -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,16 +62,15 @@
|
|||||||
<Component Id="NetbirdAumidRegistry" Guid="*">
|
<Component Id="NetbirdAumidRegistry" Guid="*">
|
||||||
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
||||||
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
||||||
<!-- Pre-seed the CLSID the Wails notifications service reads on
|
|
||||||
first startup (notifications_windows.go:getGUID looks for
|
|
||||||
the CustomActivator value under this key). Without this
|
|
||||||
the service generates a fresh per-install UUID, which
|
|
||||||
diverges from the ToastActivatorCLSID set on the Start
|
|
||||||
Menu / Desktop shortcuts above and the COM activator
|
|
||||||
never fires when a toast is clicked. -->
|
|
||||||
<RegistryValue Name="CustomActivator" Type="string" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
|
|
||||||
</RegistryKey>
|
</RegistryKey>
|
||||||
</Component>
|
</Component>
|
||||||
|
<!-- Drop the HKCU Run\Netbird value written by legacy NSIS installers. -->
|
||||||
|
<Component Id="NetbirdLegacyHKCUCleanup" Guid="*">
|
||||||
|
<RegistryValue Root="HKCU" Key="Software\NetBird GmbH\Installer"
|
||||||
|
Name="LegacyHKCUCleanup" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
<RemoveRegistryValue Root="HKCU"
|
||||||
|
Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="Netbird" />
|
||||||
|
</Component>
|
||||||
</StandardDirectory>
|
</StandardDirectory>
|
||||||
|
|
||||||
<StandardDirectory Id="CommonAppDataFolder">
|
<StandardDirectory Id="CommonAppDataFolder">
|
||||||
@@ -81,49 +83,37 @@
|
|||||||
</Directory>
|
</Directory>
|
||||||
</StandardDirectory>
|
</StandardDirectory>
|
||||||
|
|
||||||
|
<!-- Drop Run, App Paths and Uninstall entries written by legacy NSIS
|
||||||
|
installers into the 32-bit registry view (HKLM\Software\Wow6432Node). -->
|
||||||
|
<Component Id="NetbirdLegacyWow6432Cleanup" Directory="NetbirdInstallDir"
|
||||||
|
Guid="bda5d628-16bd-4086-b2c1-5099d8d51763" Bitness="always32">
|
||||||
|
<RegistryValue Root="HKLM" Key="Software\NetBird GmbH\Installer"
|
||||||
|
Name="LegacyWow6432Cleanup" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
<RemoveRegistryValue Root="HKLM"
|
||||||
|
Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="Netbird" />
|
||||||
|
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
||||||
|
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\Netbird" />
|
||||||
|
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
||||||
|
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\Netbird-ui" />
|
||||||
|
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
||||||
|
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Netbird" />
|
||||||
|
</Component>
|
||||||
|
|
||||||
<ComponentGroup Id="NetbirdFilesComponent">
|
<ComponentGroup Id="NetbirdFilesComponent">
|
||||||
<ComponentRef Id="NetbirdFiles" />
|
<ComponentRef Id="NetbirdFiles" />
|
||||||
<ComponentRef Id="NetbirdAumidRegistry" />
|
<ComponentRef Id="NetbirdAumidRegistry" />
|
||||||
<ComponentRef Id="NetbirdAutoStart" />
|
<ComponentRef Id="NetbirdAutoStart" />
|
||||||
|
<ComponentRef Id="NetbirdLegacyHKCUCleanup" />
|
||||||
|
<ComponentRef Id="NetbirdLegacyWow6432Cleanup" />
|
||||||
</ComponentGroup>
|
</ComponentGroup>
|
||||||
|
|
||||||
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
||||||
<util:CloseApplication Id="CloseNetBirdUI" CloseMessage="no" Target="netbird-ui.exe" RebootPrompt="no" TerminateProcess="0" />
|
<util:CloseApplication Id="CloseNetBirdUI" CloseMessage="no" Target="netbird-ui.exe" RebootPrompt="no" TerminateProcess="0" />
|
||||||
|
|
||||||
<!-- WebView2 evergreen runtime detection.
|
|
||||||
Probe both the per-machine and per-user EdgeUpdate keys; if either
|
|
||||||
reports a non-empty `pv` value the runtime is already installed
|
|
||||||
and we skip the bootstrapper. -->
|
|
||||||
<Property Id="WEBVIEW2_VERSION_HKLM">
|
|
||||||
<RegistrySearch Id="WV2HKLM" Root="HKLM"
|
|
||||||
Key="SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
|
||||||
Name="pv" Type="raw" Bitness="always64" />
|
|
||||||
</Property>
|
|
||||||
<Property Id="WEBVIEW2_VERSION_HKCU">
|
|
||||||
<RegistrySearch Id="WV2HKCU" Root="HKCU"
|
|
||||||
Key="Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
|
||||||
Name="pv" Type="raw" />
|
|
||||||
</Property>
|
|
||||||
|
|
||||||
<!-- Embed the bootstrapper payload. Path is relative to the WiX
|
|
||||||
working directory; sign-pipelines stages it next to client/
|
|
||||||
via `wails3 generate webview2bootstrapper`. -->
|
|
||||||
<Binary Id="WebView2Bootstrapper" SourceFile=".\client\MicrosoftEdgeWebview2Setup.exe" />
|
|
||||||
|
|
||||||
<CustomAction Id="InstallWebView2"
|
|
||||||
BinaryRef="WebView2Bootstrapper"
|
|
||||||
ExeCommand="/silent /install"
|
|
||||||
Execute="deferred"
|
|
||||||
Impersonate="no"
|
|
||||||
Return="check" />
|
|
||||||
|
|
||||||
<InstallExecuteSequence>
|
|
||||||
<Custom Action="InstallWebView2" Before="InstallFinalize"
|
|
||||||
Condition="NOT WEBVIEW2_VERSION_HKLM AND NOT WEBVIEW2_VERSION_HKCU AND NOT REMOVE" />
|
|
||||||
</InstallExecuteSequence>
|
|
||||||
|
|
||||||
<!-- Icons -->
|
<!-- Icons -->
|
||||||
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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) {}
|
||||||
|
|
||||||
@@ -144,6 +119,14 @@ service DaemonService {
|
|||||||
|
|
||||||
// ExposeService exposes a local port via the NetBird reverse proxy
|
// ExposeService exposes a local port via the NetBird reverse proxy
|
||||||
rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {}
|
rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {}
|
||||||
|
|
||||||
|
// RespondApproval delivers the user's accept/deny decision for a
|
||||||
|
// pending user-approval prompt. The daemon pushes the prompt as a
|
||||||
|
// SystemEvent with category APPROVAL and metadata key "request_id";
|
||||||
|
// the UI calls this RPC with the same request_id to unblock whichever
|
||||||
|
// subsystem (VNC, SSH, ...) is waiting. The "kind" metadata key tells
|
||||||
|
// the UI which subsystem the prompt belongs to.
|
||||||
|
rpc RespondApproval(RespondApprovalRequest) returns (RespondApprovalResponse) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -230,6 +213,10 @@ message LoginRequest {
|
|||||||
optional bool disableSSHAuth = 38;
|
optional bool disableSSHAuth = 38;
|
||||||
optional int32 sshJWTCacheTTL = 39;
|
optional int32 sshJWTCacheTTL = 39;
|
||||||
optional bool disable_ipv6 = 40;
|
optional bool disable_ipv6 = 40;
|
||||||
|
|
||||||
|
optional bool serverVNCAllowed = 41;
|
||||||
|
|
||||||
|
optional bool disableVNCApproval = 42;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LoginResponse {
|
message LoginResponse {
|
||||||
@@ -252,12 +239,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 +256,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 {}
|
||||||
@@ -349,6 +326,10 @@ message GetConfigResponse {
|
|||||||
int32 sshJWTCacheTTL = 26;
|
int32 sshJWTCacheTTL = 26;
|
||||||
|
|
||||||
bool disable_ipv6 = 27;
|
bool disable_ipv6 = 27;
|
||||||
|
|
||||||
|
bool serverVNCAllowed = 28;
|
||||||
|
|
||||||
|
bool disableVNCApproval = 29;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeerState contains the latest state of a peer
|
// PeerState contains the latest state of a peer
|
||||||
@@ -429,6 +410,25 @@ message SSHServerState {
|
|||||||
repeated SSHSessionInfo sessions = 2;
|
repeated SSHSessionInfo sessions = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VNCSessionInfo contains information about an active VNC session
|
||||||
|
message VNCSessionInfo {
|
||||||
|
string remoteAddress = 1;
|
||||||
|
string mode = 2;
|
||||||
|
string username = 3;
|
||||||
|
// userID is the Noise-verified session identity (hashed user ID from
|
||||||
|
// the ACL session-key entry), empty when auth is disabled.
|
||||||
|
string userID = 4;
|
||||||
|
// initiator is the human-readable display name of the dashboard user
|
||||||
|
// who minted the SessionPubKey, when known.
|
||||||
|
string initiator = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VNCServerState contains the latest state of the VNC server
|
||||||
|
message VNCServerState {
|
||||||
|
bool enabled = 1;
|
||||||
|
repeated VNCSessionInfo sessions = 2;
|
||||||
|
}
|
||||||
|
|
||||||
// FullStatus contains the full state held by the Status instance
|
// FullStatus contains the full state held by the Status instance
|
||||||
message FullStatus {
|
message FullStatus {
|
||||||
ManagementState managementState = 1;
|
ManagementState managementState = 1;
|
||||||
@@ -443,12 +443,7 @@ message FullStatus {
|
|||||||
|
|
||||||
bool lazyConnectionEnabled = 9;
|
bool lazyConnectionEnabled = 9;
|
||||||
SSHServerState sshServerState = 10;
|
SSHServerState sshServerState = 10;
|
||||||
|
VNCServerState vncServerState = 11;
|
||||||
// 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
|
||||||
@@ -636,6 +631,7 @@ message SystemEvent {
|
|||||||
AUTHENTICATION = 2;
|
AUTHENTICATION = 2;
|
||||||
CONNECTIVITY = 3;
|
CONNECTIVITY = 3;
|
||||||
SYSTEM = 4;
|
SYSTEM = 4;
|
||||||
|
APPROVAL = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
string id = 1;
|
string id = 1;
|
||||||
@@ -719,6 +715,10 @@ message SetConfigRequest {
|
|||||||
optional bool disableSSHAuth = 33;
|
optional bool disableSSHAuth = 33;
|
||||||
optional int32 sshJWTCacheTTL = 34;
|
optional int32 sshJWTCacheTTL = 34;
|
||||||
optional bool disable_ipv6 = 35;
|
optional bool disable_ipv6 = 35;
|
||||||
|
|
||||||
|
optional bool serverVNCAllowed = 36;
|
||||||
|
|
||||||
|
optional bool disableVNCApproval = 37;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SetConfigResponse{}
|
message SetConfigResponse{}
|
||||||
@@ -839,55 +839,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 {}
|
||||||
|
|
||||||
@@ -962,3 +913,18 @@ message StartBundleCaptureRequest {
|
|||||||
message StartBundleCaptureResponse {}
|
message StartBundleCaptureResponse {}
|
||||||
message StopBundleCaptureRequest {}
|
message StopBundleCaptureRequest {}
|
||||||
message StopBundleCaptureResponse {}
|
message StopBundleCaptureResponse {}
|
||||||
|
|
||||||
|
message RespondApprovalRequest {
|
||||||
|
// request_id matches the SystemEvent metadata key emitted by the daemon
|
||||||
|
// when a subsystem awaits user approval for an inbound connection.
|
||||||
|
string request_id = 1;
|
||||||
|
// accept is true if the user approved the request, false if they
|
||||||
|
// denied it. A missing or unknown request_id is treated as a no-op.
|
||||||
|
bool accept = 2;
|
||||||
|
// view_only signals that the user granted the connection but withheld
|
||||||
|
// input control. Only meaningful when accept is true; ignored when
|
||||||
|
// accept is false.
|
||||||
|
bool view_only = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RespondApprovalResponse {}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -111,7 +111,7 @@ func (s *Server) StartCapture(req *proto.StartCaptureRequest, stream proto.Daemo
|
|||||||
return status.Errorf(codes.Internal, "create capture session: %v", err)
|
return status.Errorf(codes.Internal, "create capture session: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
engine, err := s.claimCapture(sess)
|
engine, err := s.claimCapture(sess, func() { pw.Close() })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sess.Stop()
|
sess.Stop()
|
||||||
pw.Close()
|
pw.Close()
|
||||||
@@ -190,10 +190,7 @@ func (s *Server) StartBundleCapture(_ context.Context, req *proto.StartBundleCap
|
|||||||
|
|
||||||
s.stopBundleCaptureLocked()
|
s.stopBundleCaptureLocked()
|
||||||
s.cleanupBundleCapture()
|
s.cleanupBundleCapture()
|
||||||
|
s.evictActiveCaptureLocked()
|
||||||
if s.activeCapture != nil {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
|
|
||||||
}
|
|
||||||
|
|
||||||
engine, err := s.getCaptureEngineLocked()
|
engine, err := s.getCaptureEngineLocked()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -304,29 +301,58 @@ func (s *Server) cleanupBundleCapture() {
|
|||||||
s.bundleCapture = nil
|
s.bundleCapture = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// claimCapture reserves the engine's capture slot for sess. Returns
|
// claimCapture reserves the engine's capture slot for sess. If another
|
||||||
// FailedPrecondition if another capture is already active.
|
// capture is already running it is evicted: a previous streaming session
|
||||||
func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) {
|
// whose gRPC client died and never freed the slot stays stuck otherwise,
|
||||||
|
// and a bundle capture is just informational state.
|
||||||
|
func (s *Server) claimCapture(sess *capture.Session, cancel func()) (*internal.Engine, error) {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
defer s.mutex.Unlock()
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
if s.activeCapture != nil {
|
s.evictActiveCaptureLocked()
|
||||||
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
|
|
||||||
}
|
|
||||||
engine, err := s.getCaptureEngineLocked()
|
engine, err := s.getCaptureEngineLocked()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.activeCapture = sess
|
s.activeCapture = sess
|
||||||
|
s.activeCaptureCancel = cancel
|
||||||
return engine, nil
|
return engine, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evictActiveCaptureLocked tears down whatever capture currently owns
|
||||||
|
// the engine slot so a fresh claim can succeed. Caller must hold mutex.
|
||||||
|
func (s *Server) evictActiveCaptureLocked() {
|
||||||
|
if s.activeCapture == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.bundleCapture != nil && s.bundleCapture.sess == s.activeCapture {
|
||||||
|
log.Infof("evicting running bundle capture to start a new capture")
|
||||||
|
s.stopBundleCaptureLocked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("evicting previous streaming capture to start a new one")
|
||||||
|
prev := s.activeCapture
|
||||||
|
cancel := s.activeCaptureCancel
|
||||||
|
if engine, err := s.getCaptureEngineLocked(); err == nil {
|
||||||
|
if err := engine.SetCapture(nil); err != nil {
|
||||||
|
log.Debugf("clear previous capture: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.activeCapture = nil
|
||||||
|
s.activeCaptureCancel = nil
|
||||||
|
prev.Stop()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// releaseCapture clears the active-capture owner if it still matches sess.
|
// releaseCapture clears the active-capture owner if it still matches sess.
|
||||||
func (s *Server) releaseCapture(sess *capture.Session) {
|
func (s *Server) releaseCapture(sess *capture.Session) {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
defer s.mutex.Unlock()
|
defer s.mutex.Unlock()
|
||||||
if s.activeCapture == sess {
|
if s.activeCapture == sess {
|
||||||
s.activeCapture = nil
|
s.activeCapture = nil
|
||||||
|
s.activeCaptureCancel = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +367,7 @@ func (s *Server) clearCaptureIfOwner(sess *capture.Session, engine *internal.Eng
|
|||||||
log.Debugf("clear capture: %v", err)
|
log.Debugf("clear capture: %v", err)
|
||||||
}
|
}
|
||||||
s.activeCapture = nil
|
s.activeCapture = nil
|
||||||
|
s.activeCaptureCancel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) {
|
func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -100,8 +93,12 @@ type Server struct {
|
|||||||
captureEnabled bool
|
captureEnabled bool
|
||||||
bundleCapture *bundleCapture
|
bundleCapture *bundleCapture
|
||||||
// activeCapture is the session currently installed on the engine; guarded by s.mutex.
|
// activeCapture is the session currently installed on the engine; guarded by s.mutex.
|
||||||
activeCapture *capture.Session
|
activeCapture *capture.Session
|
||||||
networksDisabled bool
|
// activeCaptureCancel tears down the streaming pipe/cancel for the
|
||||||
|
// active streaming capture so eviction unblocks the StartCapture RPC
|
||||||
|
// handler. Nil for bundle captures (they own their own context).
|
||||||
|
activeCaptureCancel func()
|
||||||
|
networksDisabled bool
|
||||||
|
|
||||||
sleepHandler *sleephandler.SleepHandler
|
sleepHandler *sleephandler.SleepHandler
|
||||||
|
|
||||||
@@ -130,7 +127,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 +144,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 +224,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 +262,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 +273,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 +345,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 {
|
||||||
@@ -406,6 +380,8 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
|||||||
config.RosenpassPermissive = msg.RosenpassPermissive
|
config.RosenpassPermissive = msg.RosenpassPermissive
|
||||||
config.DisableAutoConnect = msg.DisableAutoConnect
|
config.DisableAutoConnect = msg.DisableAutoConnect
|
||||||
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
||||||
|
config.ServerVNCAllowed = msg.ServerVNCAllowed
|
||||||
|
config.DisableVNCApproval = msg.DisableVNCApproval
|
||||||
config.NetworkMonitor = msg.NetworkMonitor
|
config.NetworkMonitor = msg.NetworkMonitor
|
||||||
config.DisableClientRoutes = msg.DisableClientRoutes
|
config.DisableClientRoutes = msg.DisableClientRoutes
|
||||||
config.DisableServerRoutes = msg.DisableServerRoutes
|
config.DisableServerRoutes = msg.DisableServerRoutes
|
||||||
@@ -539,6 +515,8 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
return &proto.LoginResponse{}, nil
|
return &proto.LoginResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.Set(internal.StatusConnecting)
|
||||||
|
|
||||||
if msg.SetupKey == "" {
|
if msg.SetupKey == "" {
|
||||||
hint := ""
|
hint := ""
|
||||||
if msg.Hint != nil {
|
if msg.Hint != nil {
|
||||||
@@ -553,7 +531,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) {
|
if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) {
|
||||||
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
|
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
|
||||||
log.Debugf("using previous oauth flow info")
|
log.Debugf("using previous oauth flow info")
|
||||||
state.Set(internal.StatusNeedsLogin)
|
|
||||||
return &proto.LoginResponse{
|
return &proto.LoginResponse{
|
||||||
NeedsSSOLogin: true,
|
NeedsSSOLogin: true,
|
||||||
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
|
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
|
||||||
@@ -590,11 +567,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup-key path: we are about to dial Management with the key, so the
|
|
||||||
// Connecting paint is meaningful here — unlike the SSO branch above,
|
|
||||||
// which returns NeedsLogin and parks on the browser leg.
|
|
||||||
state.Set(internal.StatusConnecting)
|
|
||||||
|
|
||||||
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
|
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
|
||||||
state.Set(loginStatus)
|
state.Set(loginStatus)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -603,43 +575,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
|
||||||
//
|
|
||||||
// The daemon holds StatusNeedsLogin for the whole browser wait (set on
|
|
||||||
// entry): the login is not done until the token returns, so a client that
|
|
||||||
// (re)attaches mid-wait — a restarted UI, a second `netbird up` — reads
|
|
||||||
// "login required" and offers the affordance, instead of a Connecting that
|
|
||||||
// never resolves. The wait is also tied to the caller's context (see the
|
|
||||||
// goroutine below), so a client that goes away cancels the wait instead of
|
|
||||||
// orphaning it on rootCtx until the device-code window expires.
|
|
||||||
//
|
|
||||||
// State transitions on exit:
|
|
||||||
//
|
|
||||||
// ┌──────────────────────────────────────────┬──────────────────────────────────┐
|
|
||||||
// │ Outcome │ contextState │
|
|
||||||
// ├──────────────────────────────────────────┼──────────────────────────────────┤
|
|
||||||
// │ Success → loginAttempt ok │ NeedsLogin held; the caller's Up │
|
|
||||||
// │ │ drives Connecting → Connected │
|
|
||||||
// │ Success → loginAttempt → still-NeedsLogin│ StatusNeedsLogin (loginAttempt) │
|
|
||||||
// │ Success → loginAttempt error │ StatusLoginFailed (loginAttempt) │
|
|
||||||
// │ UserCode mismatch │ StatusLoginFailed │
|
|
||||||
// │ WaitToken: context.Canceled │ NeedsLogin held. Caller gone │
|
|
||||||
// │ (caller went away — UI restart / │ (UI/CLI) → a fresh client │
|
|
||||||
// │ Ctrl+C — or internal abort: profile │ shows the login affordance; │
|
|
||||||
// │ switch / app quit / another │ internal aborts are │
|
|
||||||
// │ WaitSSOLogin via actCancel/waitCancel) │ overwritten by the next Up. │
|
|
||||||
// │ WaitToken: context.DeadlineExceeded │ StatusNeedsLogin │
|
|
||||||
// │ (OAuth device-code window expired │ (retryable; the UI's "Connect" │
|
|
||||||
// │ while waiting on the browser leg) │ re-enters the Login flow) │
|
|
||||||
// │ WaitToken: any other error │ StatusLoginFailed │
|
|
||||||
// │ (access_denied, expired_token, HTTP │ (genuine auth/IO failure; │
|
|
||||||
// │ failure, token validation rejection) │ surfaced verbatim to caller) │
|
|
||||||
// └──────────────────────────────────────────┴──────────────────────────────────┘
|
|
||||||
//
|
|
||||||
// The defer still applies a StatusIdle fallback for the early
|
|
||||||
// oauth-flow-not-initialized return (before the entry Set), so a half state
|
|
||||||
// doesn't leak when there is nothing to wait on.
|
|
||||||
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 {
|
||||||
@@ -647,21 +584,6 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(s.rootCtx)
|
ctx, cancel := context.WithCancel(s.rootCtx)
|
||||||
|
|
||||||
// Tie the in-flight browser wait to the caller. ctx stays rooted in
|
|
||||||
// rootCtx so CtxGetState resolves the daemon's contextState, but if the
|
|
||||||
// UI window or CLI that drove the login goes away mid-flow (restart,
|
|
||||||
// Ctrl+C) the gRPC callerCtx cancels and we cancel the wait instead of
|
|
||||||
// orphaning it on rootCtx until the OAuth device-code window expires.
|
|
||||||
// The goroutine exits as soon as either context completes, so it can't
|
|
||||||
// outlive the RPC.
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-callerCtx.Done():
|
|
||||||
cancel()
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
md, ok := metadata.FromIncomingContext(callerCtx)
|
md, ok := metadata.FromIncomingContext(callerCtx)
|
||||||
if ok {
|
if ok {
|
||||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||||
@@ -687,11 +609,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Hold NeedsLogin for the whole browser wait — the login is not done
|
state.Set(internal.StatusConnecting)
|
||||||
// until the token returns, so a client that (re)attaches mid-wait
|
|
||||||
// (restarted UI, second `netbird up`) reads "login required" and offers
|
|
||||||
// the affordance instead of a Connecting that never resolves.
|
|
||||||
state.Set(internal.StatusNeedsLogin)
|
|
||||||
|
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
flowInfo := s.oauthAuthFlow.info
|
flowInfo := s.oauthAuthFlow.info
|
||||||
@@ -718,30 +636,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. If our caller cancelled (the client closed
|
|
||||||
// the browser-login popup, or the UI went away — callerCtx is
|
|
||||||
// done), clear the abandoned OAuth flow so a fresh Login starts
|
|
||||||
// a new device code instead of reusing this one. The entry
|
|
||||||
// NeedsLogin stays in place, so a reattaching client shows the
|
|
||||||
// login affordance. An internal abort (actCancel from a new
|
|
||||||
// Login/WaitSSOLogin, callerCtx still live) leaves the flow for
|
|
||||||
// the new owner — don't clobber it.
|
|
||||||
if callerCtx.Err() != nil {
|
|
||||||
s.mutex.Lock()
|
|
||||||
s.oauthAuthFlow = oauthAuthFlow{}
|
|
||||||
s.mutex.Unlock()
|
|
||||||
}
|
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
// OAuth device-code window expired with no user action.
|
|
||||||
// Retryable — leave the daemon in NeedsLogin so the UI
|
|
||||||
// keeps the Login affordance instead of reading as a
|
|
||||||
// hard failure.
|
|
||||||
state.Set(internal.StatusNeedsLogin)
|
|
||||||
default:
|
|
||||||
state.Set(internal.StatusLoginFailed)
|
|
||||||
}
|
|
||||||
log.Errorf("waiting for browser login failed: %v", err)
|
log.Errorf("waiting for browser login failed: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -856,9 +751,6 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
|||||||
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,37 +850,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1242,23 +1120,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() {
|
||||||
@@ -1269,10 +1133,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)
|
||||||
|
|
||||||
@@ -1282,7 +1142,7 @@ 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()
|
pbFullStatus.VncServerState = s.getVNCServerState()
|
||||||
statusResponse.FullStatus = pbFullStatus
|
statusResponse.FullStatus = pbFullStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,6 +1182,38 @@ func (s *Server) getSSHServerState() *proto.SSHServerState {
|
|||||||
return sshServerState
|
return sshServerState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getVNCServerState retrieves the current VNC server state.
|
||||||
|
func (s *Server) getVNCServerState() *proto.VNCServerState {
|
||||||
|
s.mutex.Lock()
|
||||||
|
connectClient := s.connectClient
|
||||||
|
s.mutex.Unlock()
|
||||||
|
|
||||||
|
if connectClient == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := connectClient.Engine()
|
||||||
|
if engine == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled, sessions := engine.GetVNCServerStatus()
|
||||||
|
pbSessions := make([]*proto.VNCSessionInfo, 0, len(sessions))
|
||||||
|
for _, sess := range sessions {
|
||||||
|
pbSessions = append(pbSessions, &proto.VNCSessionInfo{
|
||||||
|
RemoteAddress: sess.RemoteAddress,
|
||||||
|
Mode: sess.Mode,
|
||||||
|
Username: sess.Username,
|
||||||
|
UserID: sess.UserID,
|
||||||
|
Initiator: sess.Initiator,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &proto.VNCServerState{
|
||||||
|
Enabled: enabled,
|
||||||
|
Sessions: pbSessions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
|
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
|
||||||
func (s *Server) GetPeerSSHHostKey(
|
func (s *Server) GetPeerSSHHostKey(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -1503,144 +1395,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()
|
||||||
@@ -1700,6 +1454,27 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RespondApproval relays the user's accept/deny decision for a pending
|
||||||
|
// approval prompt to the engine's broker. Unknown or already-resolved
|
||||||
|
// request_ids are silently no-op'd so a slow UI cannot deny a prompt the
|
||||||
|
// user already handled (or that already timed out).
|
||||||
|
func (s *Server) RespondApproval(_ context.Context, msg *proto.RespondApprovalRequest) (*proto.RespondApprovalResponse, error) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
connectClient := s.connectClient
|
||||||
|
s.mutex.Unlock()
|
||||||
|
if connectClient == nil {
|
||||||
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "client not initialized")
|
||||||
|
}
|
||||||
|
engine := connectClient.Engine()
|
||||||
|
if engine == nil {
|
||||||
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "engine not running")
|
||||||
|
}
|
||||||
|
if !engine.RespondApproval(msg.GetRequestId(), msg.GetAccept(), msg.GetViewOnly()) {
|
||||||
|
log.Debugf("approval response for unknown request_id %s", msg.GetRequestId())
|
||||||
|
}
|
||||||
|
return &proto.RespondApprovalResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func isUnixRunningDesktop() bool {
|
func isUnixRunningDesktop() bool {
|
||||||
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
||||||
return false
|
return false
|
||||||
@@ -1816,6 +1591,8 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
|||||||
Mtu: int64(cfg.MTU),
|
Mtu: int64(cfg.MTU),
|
||||||
DisableAutoConnect: cfg.DisableAutoConnect,
|
DisableAutoConnect: cfg.DisableAutoConnect,
|
||||||
ServerSSHAllowed: *cfg.ServerSSHAllowed,
|
ServerSSHAllowed: *cfg.ServerSSHAllowed,
|
||||||
|
ServerVNCAllowed: cfg.ServerVNCAllowed != nil && *cfg.ServerVNCAllowed,
|
||||||
|
DisableVNCApproval: cfg.DisableVNCApproval != nil && *cfg.DisableVNCApproval,
|
||||||
RosenpassEnabled: cfg.RosenpassEnabled,
|
RosenpassEnabled: cfg.RosenpassEnabled,
|
||||||
RosenpassPermissive: cfg.RosenpassPermissive,
|
RosenpassPermissive: cfg.RosenpassPermissive,
|
||||||
LazyConnectionEnabled: cfg.LazyConnectionEnabled,
|
LazyConnectionEnabled: cfg.LazyConnectionEnabled,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user