mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-23 08:19:56 +00:00
Compare commits
92 Commits
task/align
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
520370a8b0 | ||
|
|
af3b7e4497 | ||
|
|
e84f6527f7 | ||
|
|
ac9529ea8c | ||
|
|
f736ef9647 | ||
|
|
cf58bf1ba9 | ||
|
|
522b8ed969 | ||
|
|
c9e99659ea | ||
|
|
58c79f5878 | ||
|
|
15a0504fb1 | ||
|
|
883a1a8961 | ||
|
|
54192a94b7 | ||
|
|
8511687270 | ||
|
|
35b465fa4a | ||
|
|
fb87f751a5 | ||
|
|
679c7182a4 | ||
|
|
8c031ea6f0 | ||
|
|
60a9544656 | ||
|
|
d3710d4bb2 | ||
|
|
ee360963f9 | ||
|
|
8d9580e491 | ||
|
|
5bd7c6c7ea | ||
|
|
8ae2cd0a08 | ||
|
|
e4397d4d46 | ||
|
|
6fbc90b4d3 | ||
|
|
5095e17cc5 | ||
|
|
6df0175607 | ||
|
|
3c23700e56 | ||
|
|
38ad2b67e8 | ||
|
|
01aa49433e | ||
|
|
08a2b63675 | ||
|
|
b3f9e6588a | ||
|
|
967e2d6864 | ||
|
|
e7c1d364c3 | ||
|
|
a44198fd77 | ||
|
|
b57f714350 | ||
|
|
f893abc41d | ||
|
|
60067619a1 | ||
|
|
cd777395f2 | ||
|
|
b19467e3af | ||
|
|
2bcea9d582 | ||
|
|
8ff3b06cf1 | ||
|
|
d7703767d5 | ||
|
|
7feda907ca | ||
|
|
62da482133 | ||
|
|
079bce3c2f | ||
|
|
1a09aa6715 | ||
|
|
61abf5b9ea | ||
|
|
e229050ba3 | ||
|
|
e919b2d55d | ||
|
|
a40028092d | ||
|
|
13200265d8 | ||
|
|
ed7a9363aa | ||
|
|
d56859dc5d | ||
|
|
367d37050b | ||
|
|
106527182f | ||
|
|
8e1d5b78c2 | ||
|
|
d3b63c6be9 | ||
|
|
60d2fa08b0 | ||
|
|
1e7b16db0a | ||
|
|
b377d99933 | ||
|
|
512899d82d | ||
|
|
5993ec6e43 | ||
|
|
b5a16a1898 | ||
|
|
449b5cbb80 | ||
|
|
eac6d501c3 | ||
|
|
deeae30612 | ||
|
|
f3cdf163e1 | ||
|
|
3e61ccb162 | ||
|
|
a48c20d8d8 | ||
|
|
2b57a7d43b | ||
|
|
fa1e241aea | ||
|
|
e7c9182ff9 | ||
|
|
9189625487 | ||
|
|
e9dbf9db6f | ||
|
|
5a9e9e7bc9 | ||
|
|
43e041cf9f | ||
|
|
77e5693200 | ||
|
|
174dc24867 | ||
|
|
7ea5e37dd4 | ||
|
|
9d7ef9b255 | ||
|
|
944a258459 | ||
|
|
1f9a829f2c | ||
|
|
14af179556 | ||
|
|
1fbb5e6d5d | ||
|
|
6771e35d57 | ||
|
|
e89b1e0596 | ||
|
|
d542c60e21 | ||
|
|
4983b5cf17 | ||
|
|
b3b0feb3b8 | ||
|
|
7aebdd69dd | ||
|
|
0358be2313 |
45
.github/dependabot.yml
vendored
Normal file
45
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 15
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
ignore:
|
||||||
|
# git-town/action v1.3.x crashes on cyclic PR graphs (self-loop main->main
|
||||||
|
# fork PRs) via its topological-sort visualization. Pinned to v1.2.1 in
|
||||||
|
# git-town.yml; block v1.3.x until upstream tolerates cyclic edges.
|
||||||
|
- dependency-name: "git-town/action"
|
||||||
|
update-types:
|
||||||
|
- "version-update:semver-minor"
|
||||||
|
- "version-update:semver-major"
|
||||||
|
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directories:
|
||||||
|
- "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 15
|
||||||
|
groups:
|
||||||
|
aws-sdk:
|
||||||
|
patterns:
|
||||||
|
- "github.com/aws/aws-sdk-go-v2/*"
|
||||||
|
pion:
|
||||||
|
patterns:
|
||||||
|
- "github.com/pion/*"
|
||||||
|
gorm:
|
||||||
|
patterns:
|
||||||
|
- "gorm.io/*"
|
||||||
|
otel:
|
||||||
|
patterns:
|
||||||
|
- "go.opentelemetry.io/*"
|
||||||
|
testcontainers:
|
||||||
|
patterns:
|
||||||
|
- "github.com/testcontainers/testcontainers-go/*"
|
||||||
|
wireguard:
|
||||||
|
patterns:
|
||||||
|
- "golang.zx2c4.com/wireguard*"
|
||||||
109
.github/workflows/check-license-dependencies.yml
vendored
109
.github/workflows/check-license-dependencies.yml
vendored
@@ -2,16 +2,16 @@ name: Check License Dependencies
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'go.mod'
|
- "go.mod"
|
||||||
- 'go.sum'
|
- "go.sum"
|
||||||
- '.github/workflows/check-license-dependencies.yml'
|
- ".github/workflows/check-license-dependencies.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'go.mod'
|
- "go.mod"
|
||||||
- 'go.sum'
|
- "go.sum"
|
||||||
- '.github/workflows/check-license-dependencies.yml'
|
- ".github/workflows/check-license-dependencies.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-internal-dependencies:
|
check-internal-dependencies:
|
||||||
@@ -19,7 +19,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for problematic license dependencies
|
- name: Check for problematic license dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -56,55 +59,57 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: "go.mod"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Install go-licenses
|
- name: Install go-licenses
|
||||||
run: go install github.com/google/go-licenses@v1.6.0
|
run: go install github.com/google/go-licenses@v1.6.0
|
||||||
|
|
||||||
- name: Check for GPL/AGPL licensed dependencies
|
- name: Check for GPL/AGPL licensed dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
|
echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check all Go packages for copyleft licenses, excluding internal netbird packages
|
|
||||||
COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
|
|
||||||
|
|
||||||
if [ -n "$COPYLEFT_DEPS" ]; then
|
|
||||||
echo "Found copyleft licensed dependencies:"
|
|
||||||
echo "$COPYLEFT_DEPS"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Filter out dependencies that are only pulled in by internal AGPL packages
|
# Check all Go packages for copyleft licenses, excluding internal netbird packages
|
||||||
INCOMPATIBLE=""
|
COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
|
||||||
while IFS=',' read -r package url license; do
|
|
||||||
if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
|
|
||||||
# Find ALL packages that import this GPL package using go list
|
|
||||||
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
|
||||||
|
|
||||||
# Check if any importer is NOT in management/signal/relay
|
if [ -n "$COPYLEFT_DEPS" ]; then
|
||||||
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
echo "Found copyleft licensed dependencies:"
|
||||||
|
echo "$COPYLEFT_DEPS"
|
||||||
if [ -n "$BSD_IMPORTER" ]; then
|
|
||||||
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
|
||||||
INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
|
|
||||||
else
|
|
||||||
echo "✓ $package ($license) is only used by internal AGPL packages - OK"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<< "$COPYLEFT_DEPS"
|
|
||||||
|
|
||||||
if [ -n "$INCOMPATIBLE" ]; then
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
|
|
||||||
echo -e "$INCOMPATIBLE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ All external license dependencies are compatible with BSD-3-Clause"
|
# Filter out dependencies that are only pulled in by internal AGPL packages
|
||||||
|
INCOMPATIBLE=""
|
||||||
|
while IFS=',' read -r package url license; do
|
||||||
|
if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
|
||||||
|
# Find ALL packages that import this GPL package using go list
|
||||||
|
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
||||||
|
|
||||||
|
# Check if any importer is NOT in management/signal/relay
|
||||||
|
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
||||||
|
|
||||||
|
if [ -n "$BSD_IMPORTER" ]; then
|
||||||
|
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
||||||
|
INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
|
||||||
|
else
|
||||||
|
echo "✓ $package ($license) is only used by internal AGPL packages - OK"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<< "$COPYLEFT_DEPS"
|
||||||
|
|
||||||
|
if [ -n "$INCOMPATIBLE" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
|
||||||
|
echo -e "$INCOMPATIBLE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ All external license dependencies are compatible with BSD-3-Clause"
|
||||||
|
|||||||
2
.github/workflows/docs-ack.yml
vendored
2
.github/workflows/docs-ack.yml
vendored
@@ -83,7 +83,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Verify docs PR exists (and is open or merged)
|
- name: Verify docs PR exists (and is open or merged)
|
||||||
if: steps.validate.outputs.mode == 'added'
|
if: steps.validate.outputs.mode == 'added'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
id: verify
|
id: verify
|
||||||
with:
|
with:
|
||||||
pr_number: ${{ steps.extract.outputs.pr_number }}
|
pr_number: ${{ steps.extract.outputs.pr_number }}
|
||||||
|
|||||||
5
.github/workflows/forum.yml
vendored
5
.github/workflows/forum.yml
vendored
@@ -8,11 +8,10 @@ jobs:
|
|||||||
post:
|
post:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: roots/discourse-topic-github-release-action@main
|
- uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
|
||||||
with:
|
with:
|
||||||
discourse-api-key: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
|
discourse-api-key: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
|
||||||
discourse-base-url: https://forum.netbird.io
|
discourse-base-url: https://forum.netbird.io
|
||||||
discourse-author-username: NetBird
|
discourse-author-username: NetBird
|
||||||
discourse-category: 17
|
discourse-category: 17
|
||||||
discourse-tags:
|
discourse-tags: releases
|
||||||
releases
|
|
||||||
|
|||||||
8
.github/workflows/git-town.yml
vendored
8
.github/workflows/git-town.yml
vendored
@@ -3,7 +3,7 @@ name: Git Town
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- '**'
|
- "**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
git-town:
|
git-town:
|
||||||
@@ -15,7 +15,9 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: git-town/action@v1.2.1
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1
|
||||||
with:
|
with:
|
||||||
skip-single-stacks: true
|
skip-single-stacks: true
|
||||||
|
|||||||
16
.github/workflows/golang-test-darwin.yml
vendored
16
.github/workflows/golang-test-darwin.yml
vendored
@@ -16,16 +16,18 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: macos-gotest-${{ hashFiles('**/go.sum') }}
|
key: macos-gotest-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -43,5 +45,11 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -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)
|
||||||
|
|
||||||
|
- name: Upload coverage reports to Codecov
|
||||||
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
slug: netbirdio/netbird
|
||||||
|
flags: unit,client
|
||||||
|
|||||||
21
.github/workflows/golang-test-freebsd.yml
vendored
21
.github/workflows/golang-test-freebsd.yml
vendored
@@ -15,20 +15,31 @@ jobs:
|
|||||||
name: "Client / Unit"
|
name: "Client / Unit"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Read Go version from go.mod
|
||||||
|
id: goversion
|
||||||
|
run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Test in FreeBSD
|
- name: Test in FreeBSD
|
||||||
id: test
|
id: test
|
||||||
uses: vmactions/freebsd-vm@v1
|
env:
|
||||||
|
GO_VERSION: ${{ steps.goversion.outputs.version }}
|
||||||
|
uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8
|
||||||
with:
|
with:
|
||||||
usesh: true
|
usesh: true
|
||||||
copyback: false
|
copyback: false
|
||||||
release: "14.2"
|
release: "15.0"
|
||||||
|
envs: "GO_VERSION"
|
||||||
prepare: |
|
prepare: |
|
||||||
pkg install -y curl pkgconf xorg
|
pkg install -y curl pkgconf xorg
|
||||||
GO_TARBALL="go1.25.3.freebsd-amd64.tar.gz"
|
GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
|
||||||
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
||||||
curl -vLO "$GO_URL"
|
curl -vLO "$GO_URL"
|
||||||
tar -C /usr/local -vxzf "$GO_TARBALL"
|
tar -C /usr/local -vxzf "$GO_TARBALL"
|
||||||
|
|
||||||
# -x - to print all executed commands
|
# -x - to print all executed commands
|
||||||
# -e - to faile on first error
|
# -e - to faile on first error
|
||||||
|
|||||||
199
.github/workflows/golang-test-linux.yml
vendored
199
.github/workflows/golang-test-linux.yml
vendored
@@ -18,9 +18,11 @@ jobs:
|
|||||||
management: ${{ steps.filter.outputs.management }}
|
management: ${{ steps.filter.outputs.management }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||||
id: filter
|
id: filter
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
@@ -28,7 +30,7 @@ jobs:
|
|||||||
- 'management/**'
|
- 'management/**'
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -36,10 +38,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
id: cache
|
id: cache
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -113,14 +115,16 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ '386','amd64' ]
|
arch: ["386", "amd64"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -128,10 +132,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -154,18 +158,29 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
||||||
|
|
||||||
|
- name: Upload coverage reports to Codecov
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
slug: netbirdio/netbird
|
||||||
|
flags: unit,client
|
||||||
|
|
||||||
|
|
||||||
test_client_on_docker:
|
test_client_on_docker:
|
||||||
name: "Client (Docker) / Unit"
|
name: "Client (Docker) / Unit"
|
||||||
needs: [ build-cache ]
|
needs: [build-cache]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -177,7 +192,7 @@ jobs:
|
|||||||
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
id: cache-restore
|
id: cache-restore
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -231,10 +246,12 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -246,10 +263,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -268,23 +285,33 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||||
go test ${{ matrix.raceFlag }} \
|
go test ${{ matrix.raceFlag }} \
|
||||||
-exec 'sudo' \
|
-exec 'sudo' -coverprofile=coverage.txt \
|
||||||
-timeout 10m -p 1 ./relay/... ./shared/relay/...
|
-timeout 10m -p 1 ./relay/... ./shared/relay/...
|
||||||
|
|
||||||
|
- name: Upload coverage reports to Codecov
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
slug: netbirdio/netbird
|
||||||
|
flags: unit,relay
|
||||||
|
|
||||||
test_proxy:
|
test_proxy:
|
||||||
name: "Proxy / Unit"
|
name: "Proxy / Unit"
|
||||||
needs: [build-cache]
|
needs: [build-cache]
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ '386','amd64' ]
|
arch: ["386", "amd64"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -298,7 +325,7 @@ jobs:
|
|||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -316,7 +343,15 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||||
go test -timeout 10m -p 1 ./proxy/...
|
go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/...
|
||||||
|
|
||||||
|
- name: Upload coverage reports to Codecov
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
slug: netbirdio/netbird
|
||||||
|
flags: unit,proxy
|
||||||
|
|
||||||
test_signal:
|
test_signal:
|
||||||
name: "Signal / Unit"
|
name: "Signal / Unit"
|
||||||
@@ -324,14 +359,16 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ '386','amd64' ]
|
arch: ["386", "amd64"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -343,10 +380,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -365,24 +402,34 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||||
go test \
|
go test \
|
||||||
-exec 'sudo' \
|
-exec 'sudo' -coverprofile=coverage.txt \
|
||||||
-timeout 10m ./signal/... ./shared/signal/...
|
-timeout 10m ./signal/... ./shared/signal/...
|
||||||
|
|
||||||
|
- name: Upload coverage reports to Codecov
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
slug: netbirdio/netbird
|
||||||
|
flags: unit,signal
|
||||||
|
|
||||||
test_management:
|
test_management:
|
||||||
name: "Management / Unit"
|
name: "Management / Unit"
|
||||||
needs: [ build-cache ]
|
needs: [build-cache]
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ 'amd64' ]
|
arch: ["amd64"]
|
||||||
store: [ 'sqlite', 'postgres', 'mysql' ]
|
store: ["sqlite", "postgres", "mysql"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -390,10 +437,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -410,7 +457,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -427,23 +474,31 @@ 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 \
|
||||||
go test -tags=devcert \
|
go test -tags=devcert -coverprofile=coverage.txt \
|
||||||
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
|
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
|
||||||
-timeout 20m ./management/... ./shared/management/...
|
-timeout 20m ./management/... ./shared/management/...
|
||||||
|
|
||||||
|
- name: Upload coverage reports to Codecov
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
slug: netbirdio/netbird
|
||||||
|
flags: unit,management
|
||||||
|
|
||||||
benchmark:
|
benchmark:
|
||||||
name: "Management / Benchmark"
|
name: "Management / Benchmark"
|
||||||
needs: [ build-cache ]
|
needs: [build-cache]
|
||||||
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ 'amd64' ]
|
arch: ["amd64"]
|
||||||
store: [ 'sqlite', 'postgres' ]
|
store: ["sqlite", "postgres"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Create Docker network
|
- name: Create Docker network
|
||||||
@@ -474,10 +529,12 @@ jobs:
|
|||||||
prom/prometheus
|
prom/prometheus
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -485,10 +542,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -505,7 +562,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -529,13 +586,13 @@ jobs:
|
|||||||
|
|
||||||
api_benchmark:
|
api_benchmark:
|
||||||
name: "Management / Benchmark (API)"
|
name: "Management / Benchmark (API)"
|
||||||
needs: [ build-cache ]
|
needs: [build-cache]
|
||||||
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ 'amd64' ]
|
arch: ["amd64"]
|
||||||
store: [ 'sqlite', 'postgres' ]
|
store: ["sqlite", "postgres"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Create Docker network
|
- name: Create Docker network
|
||||||
@@ -566,10 +623,12 @@ jobs:
|
|||||||
prom/prometheus
|
prom/prometheus
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -577,10 +636,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -597,7 +656,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
@@ -623,20 +682,22 @@ jobs:
|
|||||||
|
|
||||||
api_integration_test:
|
api_integration_test:
|
||||||
name: "Management / Integration"
|
name: "Management / Integration"
|
||||||
needs: [ build-cache ]
|
needs: [build-cache]
|
||||||
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ 'amd64' ]
|
arch: ["amd64"]
|
||||||
store: [ 'sqlite', 'postgres']
|
store: ["sqlite", "postgres"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -644,10 +705,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -667,6 +728,14 @@ jobs:
|
|||||||
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 \
|
||||||
go test -tags=integration \
|
go test -tags=integration -coverprofile=coverage.txt \
|
||||||
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
|
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
|
||||||
-timeout 20m ./management/server/http/...
|
-timeout 20m ./management/server/http/...
|
||||||
|
|
||||||
|
- name: Upload coverage reports to Codecov
|
||||||
|
if: matrix.arch == 'amd64'
|
||||||
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
slug: netbirdio/netbird
|
||||||
|
flags: integration,management
|
||||||
|
|||||||
19
.github/workflows/golang-test-windows.yml
vendored
19
.github/workflows/golang-test-windows.yml
vendored
@@ -18,10 +18,12 @@ jobs:
|
|||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
id: go
|
id: go
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
@@ -33,7 +35,7 @@ jobs:
|
|||||||
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -44,16 +46,15 @@ jobs:
|
|||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Download wintun
|
- name: Download wintun
|
||||||
uses: carlosperate/download-file-action@v2
|
|
||||||
id: download-wintun
|
id: download-wintun
|
||||||
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||||
file-name: wintun.zip
|
destination: ${{ env.downloadPath }}\wintun.zip
|
||||||
location: ${{ env.downloadPath }}
|
sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
|
||||||
sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
|
|
||||||
|
|
||||||
- name: Decompressing wintun files
|
- name: Decompressing wintun files
|
||||||
run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
run: tar -xvf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
||||||
|
|
||||||
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
|
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
|
||||||
|
|
||||||
|
|||||||
14
.github/workflows/golangci-lint.yml
vendored
14
.github/workflows/golangci-lint.yml
vendored
@@ -15,9 +15,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: codespell
|
- name: codespell
|
||||||
uses: codespell-project/actions-codespell@v2
|
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
||||||
skip: go.mod,go.sum,**/proxy/web/**
|
skip: go.mod,go.sum,**/proxy/web/**
|
||||||
@@ -38,13 +40,15 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Check for duplicate constants
|
- name: Check for duplicate constants
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
|
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -52,7 +56,7 @@ jobs:
|
|||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
|
|||||||
4
.github/workflows/install-script-test.yml
vendored
4
.github/workflows/install-script-test.yml
vendored
@@ -22,7 +22,9 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
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,23 +16,25 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||||
with:
|
with:
|
||||||
cmdline-tools-version: 8512546
|
cmdline-tools-version: 8512546
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287
|
||||||
with:
|
with:
|
||||||
java-version: "11"
|
java-version: "11"
|
||||||
distribution: "adopt"
|
distribution: "adopt"
|
||||||
- name: NDK Cache
|
- name: NDK Cache
|
||||||
id: ndk-cache
|
id: ndk-cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: /usr/local/lib/android/sdk/ndk
|
path: /usr/local/lib/android/sdk/ndk
|
||||||
key: ndk-cache-23.1.7779620
|
key: ndk-cache-23.1.7779620
|
||||||
@@ -52,9 +54,11 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: install gomobile
|
- name: install gomobile
|
||||||
|
|||||||
2
.github/workflows/pr-title-check.yml
vendored
2
.github/workflows/pr-title-check.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Validate PR title prefix
|
- name: Validate PR title prefix
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const title = context.payload.pull_request.title;
|
const title = context.payload.pull_request.title;
|
||||||
|
|||||||
87
.github/workflows/proto-version-check.yml
vendored
87
.github/workflows/proto-version-check.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check for proto tool version changes
|
- name: Check for proto tool version changes
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||||
@@ -20,34 +20,83 @@ jobs:
|
|||||||
per_page: 100,
|
per_page: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pbFiles = files.filter(f => f.filename.endsWith('.pb.go'));
|
// Cover renamed .pb.go files in addition to plain edits.
|
||||||
const missingPatch = pbFiles.filter(f => !f.patch).map(f => f.filename);
|
// Renamed entries land under the new path with previous_filename
|
||||||
if (missingPatch.length > 0) {
|
// pointing at the base-side name, so we read the base content
|
||||||
core.setFailed(
|
// from the old path when present.
|
||||||
`Cannot inspect patch data for:\n` +
|
const changedPbFiles = files
|
||||||
missingPatch.map(f => `- ${f}`).join('\n') +
|
.filter(f => (f.status === 'modified' || f.status === 'renamed')
|
||||||
`\nThis can happen with very large PRs. Verify proto versions manually.`
|
&& f.filename.endsWith('.pb.go'))
|
||||||
);
|
.map(f => ({
|
||||||
|
headPath: f.filename,
|
||||||
|
basePath: f.previous_filename || f.filename,
|
||||||
|
}));
|
||||||
|
if (changedPbFiles.length === 0) {
|
||||||
|
console.log('No modified or renamed .pb.go files to check');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const versionPattern = /^[+-]\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
|
||||||
const violations = [];
|
|
||||||
|
|
||||||
for (const file of pbFiles) {
|
// Matches the generator version headers protoc writes at the top
|
||||||
const changed = file.patch
|
// of generated files:
|
||||||
.split('\n')
|
// // protoc v3.21.12
|
||||||
.filter(line => versionPattern.test(line));
|
// // protoc-gen-go v1.26.0
|
||||||
if (changed.length > 0) {
|
// // - protoc-gen-go-grpc v1.6.1 (grpc files prefix with "- ")
|
||||||
|
// The optional "- " prefix and the optional -gen-go / -gen-go-grpc
|
||||||
|
// suffixes keep the *_grpc.pb.go headers in scope.
|
||||||
|
const versionPattern = /^\s*\/\/\s+(?:-\s+)?protoc(?:-gen-go(?:-grpc)?)?\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 = [];
|
||||||
|
for (const file of changedPbFiles) {
|
||||||
|
const [base, head] = await Promise.all([
|
||||||
|
getVersionHeader(file.basePath, baseSha),
|
||||||
|
getVersionHeader(file.headPath, headSha),
|
||||||
|
]);
|
||||||
|
if (!base.ok || !head.ok) {
|
||||||
|
core.warning(
|
||||||
|
`Skipping ${file.headPath}: 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.basePath === file.headPath
|
||||||
lines: changed,
|
? file.headPath
|
||||||
|
: `${file.basePath} → ${file.headPath}`,
|
||||||
|
base: base.lines,
|
||||||
|
head: head.lines,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (violations.length > 0) {
|
if (violations.length > 0) {
|
||||||
const details = violations.map(v =>
|
const details = violations.map(v =>
|
||||||
`${v.file}:\n${v.lines.map(l => ' ' + l).join('\n')}`
|
`${v.file}:\n` +
|
||||||
|
` base:\n${v.base.map(l => ' ' + l).join('\n') || ' (none)'}\n` +
|
||||||
|
` head:\n${v.head.map(l => ' ' + l).join('\n') || ' (none)'}`
|
||||||
).join('\n\n');
|
).join('\n\n');
|
||||||
|
|
||||||
core.setFailed(
|
core.setFailed(
|
||||||
|
|||||||
225
.github/workflows/release.yml
vendored
225
.github/workflows/release.yml
vendored
@@ -9,10 +9,13 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SIGN_PIPE_VER: "v0.1.4"
|
SIGN_PIPE_VER: "v0.1.6"
|
||||||
GORELEASER_VER: "v2.14.3"
|
GORELEASER_VER: "v2.16.0"
|
||||||
PRODUCT_NAME: "NetBird"
|
PRODUCT_NAME: "NetBird"
|
||||||
COPYRIGHT: "NetBird GmbH"
|
COPYRIGHT: "NetBird GmbH"
|
||||||
|
flags: ""
|
||||||
|
SKIP_PUBLISH: "true"
|
||||||
|
SKIP_DOCKER_PUSH: "false"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||||
@@ -24,13 +27,15 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Generate FreeBSD port diff
|
- name: Generate FreeBSD port diff
|
||||||
run: bash release_files/freebsd-port-diff.sh
|
run: bash -x release_files/freebsd-port-diff.sh
|
||||||
|
|
||||||
- name: Generate FreeBSD port issue body
|
- name: Generate FreeBSD port issue body
|
||||||
run: bash release_files/freebsd-port-issue-body.sh
|
run: bash -x release_files/freebsd-port-issue-body.sh
|
||||||
|
|
||||||
- name: Check if diff was generated
|
- name: Check if diff was generated
|
||||||
id: check_diff
|
id: check_diff
|
||||||
@@ -51,19 +56,26 @@ jobs:
|
|||||||
echo "Generated files for version: $VERSION"
|
echo "Generated files for version: $VERSION"
|
||||||
cat netbird-*.diff
|
cat netbird-*.diff
|
||||||
|
|
||||||
|
- name: Read Go version from go.mod
|
||||||
|
id: goversion
|
||||||
|
run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Test FreeBSD port
|
- name: Test FreeBSD port
|
||||||
if: steps.check_diff.outputs.diff_exists == 'true'
|
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||||
uses: vmactions/freebsd-vm@v1
|
env:
|
||||||
|
GO_VERSION: ${{ steps.goversion.outputs.version }}
|
||||||
|
uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8
|
||||||
with:
|
with:
|
||||||
usesh: true
|
usesh: true
|
||||||
copyback: false
|
copyback: false
|
||||||
release: "15.0"
|
release: "15.0"
|
||||||
|
envs: "GO_VERSION"
|
||||||
prepare: |
|
prepare: |
|
||||||
# Install required packages
|
# Install required packages
|
||||||
pkg install -y git curl portlint go
|
pkg install -y git curl portlint
|
||||||
|
|
||||||
# Install Go for building
|
# Install Go for building
|
||||||
GO_TARBALL="go1.25.5.freebsd-amd64.tar.gz"
|
GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
|
||||||
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
||||||
curl -LO "$GO_URL"
|
curl -LO "$GO_URL"
|
||||||
tar -C /usr/local -xzf "$GO_TARBALL"
|
tar -C /usr/local -xzf "$GO_TARBALL"
|
||||||
@@ -93,19 +105,19 @@ jobs:
|
|||||||
|
|
||||||
# Show patched Makefile
|
# Show patched Makefile
|
||||||
version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}')
|
version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}')
|
||||||
|
|
||||||
cd /usr/ports/security/netbird
|
cd /usr/ports/security/netbird
|
||||||
export BATCH=yes
|
export BATCH=yes
|
||||||
make package
|
make package
|
||||||
pkg add ./work/pkg/netbird-*.pkg
|
pkg add ./work/pkg/netbird-*.pkg
|
||||||
|
|
||||||
netbird version | grep "$version"
|
netbird version | grep "$version"
|
||||||
|
|
||||||
echo "FreeBSD port test completed successfully!"
|
echo "FreeBSD port test completed successfully!"
|
||||||
|
|
||||||
- name: Upload FreeBSD port files
|
- name: Upload FreeBSD port files
|
||||||
if: steps.check_diff.outputs.diff_exists == 'true'
|
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: freebsd-port-files
|
name: freebsd-port-files
|
||||||
path: |
|
path: |
|
||||||
@@ -121,29 +133,45 @@ jobs:
|
|||||||
windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }}
|
windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }}
|
||||||
macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }}
|
macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }}
|
||||||
ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }}
|
ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }}
|
||||||
env:
|
|
||||||
flags: ""
|
|
||||||
steps:
|
steps:
|
||||||
- name: Parse semver string
|
|
||||||
id: semver_parser
|
|
||||||
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') }}
|
|
||||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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: Parse semver string
|
||||||
|
id: semver_parser
|
||||||
|
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
|
|
||||||
|
- name: Set snapshot flag
|
||||||
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
run: |
|
||||||
|
echo "flags=--snapshot" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set build vars
|
||||||
|
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
run: |
|
||||||
|
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
|
||||||
|
echo "x-${{ github.repository }}"
|
||||||
|
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||||
|
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "x-${{ github.repository }}"
|
||||||
|
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "x-${{ github.repository }}" != "x-netbirdio/netbird" ]]; then
|
||||||
|
echo "SKIP_DOCKER_PUSH=true" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -153,21 +181,23 @@ jobs:
|
|||||||
${{ runner.os }}-go-releaser-
|
${{ runner.os }}-go-releaser-
|
||||||
- name: Install modules
|
- name: Install modules
|
||||||
run: go mod tidy
|
run: go mod tidy
|
||||||
|
- name: run openapi generator
|
||||||
|
run: bash shared/management/http/api/generate.sh
|
||||||
- name: check git status
|
- name: check git status
|
||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
- name: Log in to the GitHub container registry
|
- name: Log in to the GitHub container registry
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -191,7 +221,7 @@ jobs:
|
|||||||
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/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@v4
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
with:
|
with:
|
||||||
version: ${{ env.GORELEASER_VER }}
|
version: ${{ env.GORELEASER_VER }}
|
||||||
args: release --clean ${{ env.flags }}
|
args: release --clean ${{ env.flags }}
|
||||||
@@ -202,6 +232,8 @@ jobs:
|
|||||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||||
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||||
|
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||||
|
SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }}
|
||||||
- name: Verify RPM signatures
|
- name: Verify RPM signatures
|
||||||
run: |
|
run: |
|
||||||
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||||
@@ -282,28 +314,28 @@ jobs:
|
|||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
id: upload_release
|
id: upload_release
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: dist/
|
path: dist/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: upload linux packages
|
- name: upload linux packages
|
||||||
id: upload_linux_packages
|
id: upload_linux_packages
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: linux-packages
|
name: linux-packages
|
||||||
path: dist/netbird_linux**
|
path: dist/netbird_linux**
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: upload windows packages
|
- name: upload windows packages
|
||||||
id: upload_windows_packages
|
id: upload_windows_packages
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: windows-packages
|
name: windows-packages
|
||||||
path: dist/netbird_windows**
|
path: dist/netbird_windows**
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: upload macos packages
|
- name: upload macos packages
|
||||||
id: upload_macos_packages
|
id: upload_macos_packages
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: macos-packages
|
name: macos-packages
|
||||||
path: dist/netbird_darwin**
|
path: dist/netbird_darwin**
|
||||||
@@ -314,27 +346,40 @@ 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: Parse semver string
|
|
||||||
id: semver_parser
|
|
||||||
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') }}
|
|
||||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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: Parse semver string
|
||||||
|
id: semver_parser
|
||||||
|
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
|
|
||||||
|
- name: Set snapshot flag
|
||||||
|
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
run: |
|
||||||
|
echo "flags=--snapshot" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set build vars
|
||||||
|
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
run: |
|
||||||
|
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
|
||||||
|
echo "x-${{ github.repository }}"
|
||||||
|
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||||
|
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "x-${{ github.repository }}"
|
||||||
|
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -375,7 +420,7 @@ jobs:
|
|||||||
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/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@v4
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
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 }}
|
||||||
@@ -386,6 +431,7 @@ jobs:
|
|||||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||||
NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||||
|
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||||
- name: Verify RPM signatures
|
- name: Verify RPM signatures
|
||||||
run: |
|
run: |
|
||||||
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||||
@@ -404,7 +450,7 @@ jobs:
|
|||||||
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
id: upload_release_ui
|
id: upload_release_ui
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: release-ui
|
name: release-ui
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -418,16 +464,17 @@ jobs:
|
|||||||
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -441,7 +488,7 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v4
|
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||||
with:
|
with:
|
||||||
version: ${{ env.GORELEASER_VER }}
|
version: ${{ env.GORELEASER_VER }}
|
||||||
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
|
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
|
||||||
@@ -449,7 +496,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
id: upload_release_ui_darwin
|
id: upload_release_ui_darwin
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: release-ui-darwin
|
name: release-ui-darwin
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -474,27 +521,26 @@ jobs:
|
|||||||
PackageWorkdir: netbird_windows_${{ matrix.arch }}
|
PackageWorkdir: netbird_windows_${{ matrix.arch }}
|
||||||
downloadPath: '${{ github.workspace }}\temp'
|
downloadPath: '${{ github.workspace }}\temp'
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Parse semver string
|
- name: Parse semver string
|
||||||
id: semver_parser
|
id: semver_parser
|
||||||
uses: booxmedialtd/ws-action-parse-semver@v1
|
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
|
||||||
input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
|
|
||||||
version_extractor_regex: '\/v(.*)$'
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Add 7-Zip to PATH
|
- name: Add 7-Zip to PATH
|
||||||
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
|
||||||
- name: Download release artifacts
|
- name: Download release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: release
|
path: release
|
||||||
|
|
||||||
- name: Download UI release artifacts
|
- name: Download UI release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: release-ui
|
name: release-ui
|
||||||
path: release-ui
|
path: release-ui
|
||||||
@@ -514,29 +560,27 @@ jobs:
|
|||||||
Get-ChildItem $workdir
|
Get-ChildItem $workdir
|
||||||
|
|
||||||
- name: Download wintun
|
- name: Download wintun
|
||||||
uses: carlosperate/download-file-action@v2
|
|
||||||
id: download-wintun
|
id: download-wintun
|
||||||
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||||
file-name: wintun.zip
|
destination: ${{ env.downloadPath }}\wintun.zip
|
||||||
location: ${{ env.downloadPath }}
|
sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
|
||||||
sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
|
|
||||||
|
|
||||||
- name: Decompress wintun files
|
- name: Decompress wintun files
|
||||||
run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
run: tar -xvf "${{ env.downloadPath }}\wintun.zip" -C ${{ env.downloadPath }}
|
||||||
|
|
||||||
- name: Move wintun.dll into dist
|
- name: Move wintun.dll into dist
|
||||||
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||||
|
|
||||||
- name: Download Mesa3D (amd64 only)
|
- name: Download Mesa3D (amd64 only)
|
||||||
uses: carlosperate/download-file-action@v2
|
|
||||||
id: download-mesa3d
|
id: download-mesa3d
|
||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z
|
url: https://pkgs.netbird.io/mesa3d/MesaForWindows-x64-20.1.8.7z
|
||||||
file-name: mesa3d.7z
|
destination: ${{ env.downloadPath }}\mesa3d.7z
|
||||||
location: ${{ env.downloadPath }}
|
sha256: 71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9
|
||||||
sha256: '71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9'
|
|
||||||
|
|
||||||
- name: Extract Mesa3D driver (amd64 only)
|
- name: Extract Mesa3D driver (amd64 only)
|
||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
@@ -547,35 +591,38 @@ jobs:
|
|||||||
run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||||
|
|
||||||
- name: Download EnVar plugin for NSIS
|
- name: Download EnVar plugin for NSIS
|
||||||
uses: carlosperate/download-file-action@v2
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
file-url: https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip
|
url: https://pkgs.netbird.io/nsis/EnVar_plugin.zip
|
||||||
file-name: envar_plugin.zip
|
destination: ${{ github.workspace }}\envar_plugin.zip
|
||||||
location: ${{ github.workspace }}
|
sha256: e9aa92de351345ed82795251d838f1ae9041ba35af9d381a5780c7843b01f56a
|
||||||
|
|
||||||
- name: Extract EnVar plugin
|
- name: Extract EnVar plugin
|
||||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip"
|
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip"
|
||||||
|
|
||||||
- name: Download ShellExecAsUser plugin for NSIS (amd64 only)
|
- name: Download ShellExecAsUser plugin for NSIS (amd64 only)
|
||||||
uses: carlosperate/download-file-action@v2
|
|
||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
file-url: https://nsis.sourceforge.io/mediawiki/images/6/68/ShellExecAsUser_amd64-Unicode.7z
|
url: https://pkgs.netbird.io/nsis/ShellExecAsUser_amd64-Unicode.7z
|
||||||
file-name: ShellExecAsUser_amd64-Unicode.7z
|
destination: ${{ github.workspace }}\ShellExecAsUser_amd64-Unicode.7z
|
||||||
location: ${{ github.workspace }}
|
sha256: 0a55ea25c7330a92cec028eda8afcaf1b1a7092e0dfb77c21c8f654564b4ff9d
|
||||||
|
|
||||||
- name: Extract ShellExecAsUser plugin (amd64 only)
|
- name: Extract ShellExecAsUser plugin (amd64 only)
|
||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
||||||
|
|
||||||
- name: Build NSIS installer
|
- name: Build NSIS installer
|
||||||
uses: joncloud/makensis-action@v3.3
|
shell: pwsh
|
||||||
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
|
||||||
@@ -592,7 +639,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload installer artifacts
|
- name: Upload installer artifacts
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||||
with:
|
with:
|
||||||
name: windows-installer-test-${{ matrix.arch }}
|
name: windows-installer-test-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
@@ -611,7 +658,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Create or update PR comment
|
- name: Create or update PR comment
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
env:
|
env:
|
||||||
RELEASE_RESULT: ${{ needs.release.result }}
|
RELEASE_RESULT: ${{ needs.release.result }}
|
||||||
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
||||||
@@ -703,7 +750,7 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger binaries sign pipelines
|
- name: Trigger binaries sign pipelines
|
||||||
uses: benc-uk/workflow-dispatch@v1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: Sign bin and installer
|
workflow: Sign bin and installer
|
||||||
repo: netbirdio/sign-pipelines
|
repo: netbirdio/sign-pipelines
|
||||||
|
|||||||
4
.github/workflows/sync-main.yml
vendored
4
.github/workflows/sync-main.yml
vendored
@@ -14,9 +14,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger main branch sync
|
- name: Trigger main branch sync
|
||||||
uses: benc-uk/workflow-dispatch@v1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: sync-main.yml
|
workflow: sync-main.yml
|
||||||
repo: ${{ secrets.UPSTREAM_REPO }}
|
repo: ${{ secrets.UPSTREAM_REPO }}
|
||||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
inputs: '{ "sha": "${{ github.sha }}" }'
|
inputs: '{ "sha": "${{ github.sha }}" }'
|
||||||
|
|||||||
10
.github/workflows/sync-tag.yml
vendored
10
.github/workflows/sync-tag.yml
vendored
@@ -3,7 +3,7 @@ name: sync tag
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger release tag sync
|
- name: Trigger release tag sync
|
||||||
uses: benc-uk/workflow-dispatch@v1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: sync-tag.yml
|
workflow: sync-tag.yml
|
||||||
ref: main
|
ref: main
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger android-client submodule bump
|
- name: Trigger android-client submodule bump
|
||||||
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: bump-netbird.yml
|
workflow: bump-netbird.yml
|
||||||
ref: main
|
ref: main
|
||||||
@@ -42,10 +42,10 @@ jobs:
|
|||||||
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger ios-client submodule bump
|
- name: Trigger ios-client submodule bump
|
||||||
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: bump-netbird.yml
|
workflow: bump-netbird.yml
|
||||||
ref: main
|
ref: main
|
||||||
repo: netbirdio/ios-client
|
repo: netbirdio/ios-client
|
||||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||||
|
|||||||
32
.github/workflows/test-infrastructure-files.yml
vendored
32
.github/workflows/test-infrastructure-files.yml
vendored
@@ -6,10 +6,10 @@ on:
|
|||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'infrastructure_files/**'
|
- "infrastructure_files/**"
|
||||||
- '.github/workflows/test-infrastructure-files.yml'
|
- ".github/workflows/test-infrastructure-files.yml"
|
||||||
- 'management/cmd/**'
|
- "management/cmd/**"
|
||||||
- 'signal/cmd/**'
|
- "signal/cmd/**"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
store: [ 'sqlite', 'postgres', 'mysql' ]
|
store: ["sqlite", "postgres", "mysql"]
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
||||||
@@ -68,15 +68,17 @@ jobs:
|
|||||||
run: sudo apt-get install -y curl
|
run: sudo apt-get install -y curl
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -139,8 +141,8 @@ jobs:
|
|||||||
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
|
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
|
||||||
CI_NETBIRD_SIGNAL_PORT: 12345
|
CI_NETBIRD_SIGNAL_PORT: 12345
|
||||||
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
||||||
NETBIRD_STORE_ENGINE_POSTGRES_DSN: '${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$'
|
NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
|
||||||
NETBIRD_STORE_ENGINE_MYSQL_DSN: '${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$'
|
NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
|
||||||
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
||||||
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
|
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
|
||||||
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
|
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
|
||||||
@@ -205,7 +207,7 @@ jobs:
|
|||||||
- name: Build management docker image
|
- name: Build management docker image
|
||||||
working-directory: management
|
working-directory: management
|
||||||
run: |
|
run: |
|
||||||
docker build -t netbirdio/management:latest .
|
docker build -t netbirdio/management:latest --build-arg TARGETPLATFORM=. .
|
||||||
|
|
||||||
- name: Build signal binary
|
- name: Build signal binary
|
||||||
working-directory: signal
|
working-directory: signal
|
||||||
@@ -214,7 +216,7 @@ jobs:
|
|||||||
- name: Build signal docker image
|
- name: Build signal docker image
|
||||||
working-directory: signal
|
working-directory: signal
|
||||||
run: |
|
run: |
|
||||||
docker build -t netbirdio/signal:latest .
|
docker build -t netbirdio/signal:latest --build-arg TARGETPLATFORM=. .
|
||||||
|
|
||||||
- name: Build relay binary
|
- name: Build relay binary
|
||||||
working-directory: relay
|
working-directory: relay
|
||||||
@@ -223,7 +225,7 @@ jobs:
|
|||||||
- name: Build relay docker image
|
- name: Build relay docker image
|
||||||
working-directory: relay
|
working-directory: relay
|
||||||
run: |
|
run: |
|
||||||
docker build -t netbirdio/relay:latest .
|
docker build -t netbirdio/relay:latest --build-arg TARGETPLATFORM=. .
|
||||||
|
|
||||||
- name: run docker compose up
|
- name: run docker compose up
|
||||||
working-directory: infrastructure_files/artifacts
|
working-directory: infrastructure_files/artifacts
|
||||||
@@ -254,7 +256,9 @@ jobs:
|
|||||||
run: sudo apt-get install -y jq
|
run: sudo apt-get install -y jq
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
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@v1
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
with:
|
with:
|
||||||
workflow: generate api pages
|
workflow: generate api pages
|
||||||
repo: netbirdio/docs
|
repo: netbirdio/docs
|
||||||
ref: "refs/heads/main"
|
ref: "refs/heads/main"
|
||||||
token: ${{ secrets.SIGN_GITHUB_TOKEN }}
|
token: ${{ secrets.SIGN_GITHUB_TOKEN }}
|
||||||
inputs: '{ "tag": "${{ github.ref }}" }'
|
inputs: '{ "tag": "${{ github.ref }}" }'
|
||||||
|
|||||||
19
.github/workflows/wasm-build-validation.yml
vendored
19
.github/workflows/wasm-build-validation.yml
vendored
@@ -19,15 +19,17 @@ jobs:
|
|||||||
GOARCH: wasm
|
GOARCH: wasm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-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@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
install-mode: binary
|
install-mode: binary
|
||||||
@@ -42,9 +44,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Build Wasm client
|
- name: Build Wasm client
|
||||||
@@ -61,8 +65,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
866
.goreleaser.yaml
866
.goreleaser.yaml
@@ -1,5 +1,7 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
env:
|
||||||
|
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
||||||
|
- SKIP_DOCKER_PUSH={{ if index .Env "SKIP_DOCKER_PUSH" }}{{ .Env.SKIP_DOCKER_PUSH }}{{ else }}false{{ end }}
|
||||||
project_name: netbird
|
project_name: netbird
|
||||||
builds:
|
builds:
|
||||||
- id: netbird-wasm
|
- id: netbird-wasm
|
||||||
@@ -74,6 +76,8 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
|
goarm:
|
||||||
|
- 7
|
||||||
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 }}"
|
||||||
@@ -88,6 +92,8 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
|
goarm:
|
||||||
|
- 7
|
||||||
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 }}"
|
||||||
@@ -102,6 +108,8 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
|
goarm:
|
||||||
|
- 7
|
||||||
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 }}"
|
||||||
@@ -122,6 +130,8 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
|
goarm:
|
||||||
|
- 7
|
||||||
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 }}"
|
||||||
@@ -136,6 +146,8 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
|
goarm:
|
||||||
|
- 7
|
||||||
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 }}"
|
||||||
@@ -150,6 +162,8 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
|
goarm:
|
||||||
|
- 7
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
|
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
@@ -170,6 +184,8 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
|
goarm:
|
||||||
|
- 7
|
||||||
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 }}"
|
||||||
@@ -222,670 +238,192 @@ nfpms:
|
|||||||
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 }}'
|
||||||
dockers:
|
dockers_v2:
|
||||||
- image_templates:
|
- id: netbird
|
||||||
- netbirdio/netbird:{{ .Version }}-amd64
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
|
ids:
|
||||||
ids:
|
- netbird
|
||||||
- netbird
|
images:
|
||||||
goarch: amd64
|
- netbirdio/netbird
|
||||||
use: buildx
|
- ghcr.io/netbirdio/netbird
|
||||||
dockerfile: client/Dockerfile
|
tags:
|
||||||
extra_files:
|
- "{{ .Version }}"
|
||||||
- client/netbird-entrypoint.sh
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||||
build_flag_templates:
|
dockerfile: client/Dockerfile
|
||||||
- "--platform=linux/amd64"
|
extra_files:
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
- client/netbird-entrypoint.sh
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
platforms:
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
- linux/amd64
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
- linux/arm64
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
- linux/arm/6
|
||||||
- "--label=maintainer=dev@netbird.io"
|
annotations:
|
||||||
- image_templates:
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
- netbirdio/netbird:{{ .Version }}-arm64v8
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
ids:
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
- netbird
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
goarch: arm64
|
"maintainer": "dev@netbird.io"
|
||||||
use: buildx
|
- id: netbird-rootless
|
||||||
dockerfile: client/Dockerfile
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
extra_files:
|
ids:
|
||||||
- client/netbird-entrypoint.sh
|
- netbird
|
||||||
build_flag_templates:
|
images:
|
||||||
- "--platform=linux/arm64"
|
- netbirdio/netbird
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
- ghcr.io/netbirdio/netbird
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
tags:
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
- "v{{ .Version }}-rootless"
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
dockerfile: client/Dockerfile-rootless
|
||||||
- "--label=maintainer=dev@netbird.io"
|
extra_files:
|
||||||
- image_templates:
|
- client/netbird-entrypoint.sh
|
||||||
- netbirdio/netbird:{{ .Version }}-arm
|
platforms:
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm
|
- linux/amd64
|
||||||
ids:
|
- linux/arm64
|
||||||
- netbird
|
- linux/arm/6
|
||||||
goarch: arm
|
annotations:
|
||||||
goarm: 6
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
use: buildx
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
dockerfile: client/Dockerfile
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
extra_files:
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
- client/netbird-entrypoint.sh
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
build_flag_templates:
|
"maintainer": "dev@netbird.io"
|
||||||
- "--platform=linux/arm"
|
- id: relay
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
ids:
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
- netbird-relay
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
images:
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
- netbirdio/relay
|
||||||
- "--label=maintainer=dev@netbird.io"
|
- ghcr.io/netbirdio/relay
|
||||||
|
tags:
|
||||||
- image_templates:
|
- "{{ .Version }}"
|
||||||
- netbirdio/netbird:{{ .Version }}-rootless-amd64
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
|
dockerfile: relay/Dockerfile
|
||||||
ids:
|
platforms:
|
||||||
- netbird
|
- linux/amd64
|
||||||
goarch: amd64
|
- linux/arm64
|
||||||
use: buildx
|
- linux/arm
|
||||||
dockerfile: client/Dockerfile-rootless
|
annotations:
|
||||||
extra_files:
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
- client/netbird-entrypoint.sh
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
build_flag_templates:
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
- "--platform=linux/amd64"
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
"maintainer": "dev@netbird.io"
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
- id: signal
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
ids:
|
||||||
- "--label=maintainer=dev@netbird.io"
|
- netbird-signal
|
||||||
- image_templates:
|
images:
|
||||||
- netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
- netbirdio/signal
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
- ghcr.io/netbirdio/signal
|
||||||
ids:
|
tags:
|
||||||
- netbird
|
- "{{ .Version }}"
|
||||||
goarch: arm64
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||||
use: buildx
|
dockerfile: signal/Dockerfile
|
||||||
dockerfile: client/Dockerfile-rootless
|
platforms:
|
||||||
extra_files:
|
- linux/amd64
|
||||||
- client/netbird-entrypoint.sh
|
- linux/arm64
|
||||||
build_flag_templates:
|
- linux/arm
|
||||||
- "--platform=linux/arm64"
|
annotations:
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
- "--label=maintainer=dev@netbird.io"
|
"maintainer": "dev@netbird.io"
|
||||||
- image_templates:
|
- id: management
|
||||||
- netbirdio/netbird:{{ .Version }}-rootless-arm
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
|
ids:
|
||||||
ids:
|
- netbird-mgmt
|
||||||
- netbird
|
images:
|
||||||
goarch: arm
|
- netbirdio/management
|
||||||
goarm: 6
|
- ghcr.io/netbirdio/management
|
||||||
use: buildx
|
tags:
|
||||||
dockerfile: client/Dockerfile-rootless
|
- "{{ .Version }}"
|
||||||
extra_files:
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||||
- client/netbird-entrypoint.sh
|
dockerfile: management/Dockerfile
|
||||||
build_flag_templates:
|
platforms:
|
||||||
- "--platform=linux/arm"
|
- linux/amd64
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
- linux/arm64
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
- linux/arm
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
annotations:
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
- "--label=maintainer=dev@netbird.io"
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
- image_templates:
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
- netbirdio/relay:{{ .Version }}-amd64
|
"maintainer": "dev@netbird.io"
|
||||||
- ghcr.io/netbirdio/relay:{{ .Version }}-amd64
|
- id: upload
|
||||||
ids:
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
- netbird-relay
|
ids:
|
||||||
goarch: amd64
|
- netbird-upload
|
||||||
use: buildx
|
images:
|
||||||
dockerfile: relay/Dockerfile
|
- netbirdio/upload
|
||||||
build_flag_templates:
|
- ghcr.io/netbirdio/upload
|
||||||
- "--platform=linux/amd64"
|
tags:
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
- "{{ .Version }}"
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
dockerfile: upload-server/Dockerfile
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
platforms:
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
- linux/amd64
|
||||||
- "--label=maintainer=dev@netbird.io"
|
- linux/arm64
|
||||||
- image_templates:
|
- linux/arm
|
||||||
- netbirdio/relay:{{ .Version }}-arm64v8
|
annotations:
|
||||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
ids:
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
- netbird-relay
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
goarch: arm64
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
use: buildx
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
dockerfile: relay/Dockerfile
|
"maintainer": "dev@netbird.io"
|
||||||
build_flag_templates:
|
- id: netbird-server
|
||||||
- "--platform=linux/arm64"
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
ids:
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
- netbird-server
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
images:
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
- netbirdio/netbird-server
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
- ghcr.io/netbirdio/netbird-server
|
||||||
- "--label=maintainer=dev@netbird.io"
|
tags:
|
||||||
- image_templates:
|
- "{{ .Version }}"
|
||||||
- netbirdio/relay:{{ .Version }}-arm
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm
|
dockerfile: combined/Dockerfile
|
||||||
ids:
|
platforms:
|
||||||
- netbird-relay
|
- linux/amd64
|
||||||
goarch: arm
|
- linux/arm64
|
||||||
goarm: 6
|
- linux/arm
|
||||||
use: buildx
|
annotations:
|
||||||
dockerfile: relay/Dockerfile
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
build_flag_templates:
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
- "--platform=linux/arm"
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
"maintainer": "dev@netbird.io"
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
- id: netbird-proxy
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||||
- "--label=maintainer=dev@netbird.io"
|
ids:
|
||||||
- image_templates:
|
- netbird-proxy
|
||||||
- netbirdio/signal:{{ .Version }}-amd64
|
images:
|
||||||
- ghcr.io/netbirdio/signal:{{ .Version }}-amd64
|
- netbirdio/reverse-proxy
|
||||||
ids:
|
- ghcr.io/netbirdio/reverse-proxy
|
||||||
- netbird-signal
|
tags:
|
||||||
goarch: amd64
|
- "{{ .Version }}"
|
||||||
use: buildx
|
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||||
dockerfile: signal/Dockerfile
|
dockerfile: proxy/Dockerfile
|
||||||
build_flag_templates:
|
platforms:
|
||||||
- "--platform=linux/amd64"
|
- linux/amd64
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
- linux/arm64
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
- linux/arm
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
annotations:
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
"org.opencontainers.image.created": "{{.Date}}"
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||||
- "--label=maintainer=dev@netbird.io"
|
"org.opencontainers.image.version": "{{.Version}}"
|
||||||
- image_templates:
|
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||||
- netbirdio/signal:{{ .Version }}-arm64v8
|
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
|
"maintainer": "dev@netbird.io"
|
||||||
ids:
|
|
||||||
- netbird-signal
|
|
||||||
goarch: arm64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: signal/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/signal:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm
|
|
||||||
ids:
|
|
||||||
- netbird-signal
|
|
||||||
goarch: arm
|
|
||||||
goarm: 6
|
|
||||||
use: buildx
|
|
||||||
dockerfile: signal/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/management:{{ .Version }}-amd64
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-amd64
|
|
||||||
ids:
|
|
||||||
- netbird-mgmt
|
|
||||||
goarch: amd64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: management/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/amd64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/management:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
|
|
||||||
ids:
|
|
||||||
- netbird-mgmt
|
|
||||||
goarch: arm64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: management/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/management:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm
|
|
||||||
ids:
|
|
||||||
- netbird-mgmt
|
|
||||||
goarch: arm
|
|
||||||
goarm: 6
|
|
||||||
use: buildx
|
|
||||||
dockerfile: management/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/management:{{ .Version }}-debug-amd64
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64
|
|
||||||
ids:
|
|
||||||
- netbird-mgmt
|
|
||||||
goarch: amd64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: management/Dockerfile.debug
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/amd64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/management:{{ .Version }}-debug-arm64v8
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8
|
|
||||||
ids:
|
|
||||||
- netbird-mgmt
|
|
||||||
goarch: arm64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: management/Dockerfile.debug
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/management:{{ .Version }}-debug-arm
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-arm
|
|
||||||
ids:
|
|
||||||
- netbird-mgmt
|
|
||||||
goarch: arm
|
|
||||||
goarm: 6
|
|
||||||
use: buildx
|
|
||||||
dockerfile: management/Dockerfile.debug
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/upload:{{ .Version }}-amd64
|
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-amd64
|
|
||||||
ids:
|
|
||||||
- netbird-upload
|
|
||||||
goarch: amd64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: upload-server/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/amd64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/upload:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
|
|
||||||
ids:
|
|
||||||
- netbird-upload
|
|
||||||
goarch: arm64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: upload-server/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/upload:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm
|
|
||||||
ids:
|
|
||||||
- netbird-upload
|
|
||||||
goarch: arm
|
|
||||||
goarm: 6
|
|
||||||
use: buildx
|
|
||||||
dockerfile: upload-server/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/netbird-server:{{ .Version }}-amd64
|
|
||||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
|
|
||||||
ids:
|
|
||||||
- netbird-server
|
|
||||||
goarch: amd64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: combined/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/amd64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/netbird-server:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
|
|
||||||
ids:
|
|
||||||
- netbird-server
|
|
||||||
goarch: arm64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: combined/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/netbird-server:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
|
|
||||||
ids:
|
|
||||||
- netbird-server
|
|
||||||
goarch: arm
|
|
||||||
goarm: 6
|
|
||||||
use: buildx
|
|
||||||
dockerfile: combined/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/reverse-proxy:{{ .Version }}-amd64
|
|
||||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
|
|
||||||
ids:
|
|
||||||
- netbird-proxy
|
|
||||||
goarch: amd64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: proxy/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/amd64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
|
||||||
ids:
|
|
||||||
- netbird-proxy
|
|
||||||
goarch: arm64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: proxy/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm64"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
- image_templates:
|
|
||||||
- netbirdio/reverse-proxy:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
|
|
||||||
ids:
|
|
||||||
- netbird-proxy
|
|
||||||
goarch: arm
|
|
||||||
goarm: 6
|
|
||||||
use: buildx
|
|
||||||
dockerfile: proxy/Dockerfile
|
|
||||||
build_flag_templates:
|
|
||||||
- "--platform=linux/arm"
|
|
||||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
|
||||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
|
||||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
|
||||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
|
||||||
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
|
|
||||||
- "--label=maintainer=dev@netbird.io"
|
|
||||||
docker_manifests:
|
|
||||||
- name_template: netbirdio/netbird:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/netbird:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/netbird:{{ .Version }}-arm
|
|
||||||
- netbirdio/netbird:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/netbird:latest
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/netbird:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/netbird:{{ .Version }}-arm
|
|
||||||
- netbirdio/netbird:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/netbird:{{ .Version }}-rootless
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
|
||||||
- netbirdio/netbird:{{ .Version }}-rootless-arm
|
|
||||||
- netbirdio/netbird:{{ .Version }}-rootless-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/netbird:rootless-latest
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
|
||||||
- netbirdio/netbird:{{ .Version }}-rootless-arm
|
|
||||||
- netbirdio/netbird:{{ .Version }}-rootless-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/relay:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/relay:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/relay:{{ .Version }}-arm
|
|
||||||
- netbirdio/relay:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/relay:latest
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/relay:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/relay:{{ .Version }}-arm
|
|
||||||
- netbirdio/relay:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/signal:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/signal:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/signal:{{ .Version }}-arm
|
|
||||||
- netbirdio/signal:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/signal:latest
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/signal:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/signal:{{ .Version }}-arm
|
|
||||||
- netbirdio/signal:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/management:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/management:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/management:{{ .Version }}-arm
|
|
||||||
- netbirdio/management:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/management:latest
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/management:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/management:{{ .Version }}-arm
|
|
||||||
- netbirdio/management:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/management:debug-latest
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/management:{{ .Version }}-debug-arm64v8
|
|
||||||
- netbirdio/management:{{ .Version }}-debug-arm
|
|
||||||
- netbirdio/management:{{ .Version }}-debug-amd64
|
|
||||||
- name_template: netbirdio/upload:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/upload:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/upload:{{ .Version }}-arm
|
|
||||||
- netbirdio/upload:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/upload:latest
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/upload:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/upload:{{ .Version }}-arm
|
|
||||||
- netbirdio/upload:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/netbird-server:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/netbird-server:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/netbird-server:{{ .Version }}-arm
|
|
||||||
- netbirdio/netbird-server:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/netbird-server:latest
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/netbird-server:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/netbird-server:{{ .Version }}-arm
|
|
||||||
- netbirdio/netbird-server:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/netbird:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/netbird:latest
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/netbird:{{ .Version }}-rootless
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/netbird:rootless-latest
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
|
|
||||||
- ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/relay:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/relay:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/relay:latest
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/relay:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/relay:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/signal:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/signal:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/signal:latest
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/signal:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/signal:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/management:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/management:latest
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/management:debug-latest
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-arm
|
|
||||||
- ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/upload:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/upload:latest
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/upload:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/netbird-server:latest
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/reverse-proxy:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/reverse-proxy:{{ .Version }}-arm
|
|
||||||
- netbirdio/reverse-proxy:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: netbirdio/reverse-proxy:latest
|
|
||||||
image_templates:
|
|
||||||
- netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
|
||||||
- netbirdio/reverse-proxy:{{ .Version }}-arm
|
|
||||||
- netbirdio/reverse-proxy:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/reverse-proxy:{{ .Version }}
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
- name_template: ghcr.io/netbirdio/reverse-proxy:latest
|
|
||||||
image_templates:
|
|
||||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
|
|
||||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
|
|
||||||
- ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
|
|
||||||
|
|
||||||
brews:
|
brews:
|
||||||
- ids:
|
- ids:
|
||||||
- default
|
- default
|
||||||
|
skip_upload: "{{ .Env.SKIP_PUBLISH }}"
|
||||||
repository:
|
repository:
|
||||||
owner: netbirdio
|
owner: netbirdio
|
||||||
name: homebrew-tap
|
name: homebrew-tap
|
||||||
@@ -902,6 +440,7 @@ brews:
|
|||||||
|
|
||||||
uploads:
|
uploads:
|
||||||
- name: debian
|
- name: debian
|
||||||
|
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||||
ids:
|
ids:
|
||||||
- netbird_deb
|
- netbird_deb
|
||||||
mode: archive
|
mode: archive
|
||||||
@@ -910,6 +449,7 @@ uploads:
|
|||||||
method: PUT
|
method: PUT
|
||||||
|
|
||||||
- name: yum
|
- name: yum
|
||||||
|
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||||
ids:
|
ids:
|
||||||
- netbird_rpm
|
- netbird_rpm
|
||||||
mode: archive
|
mode: archive
|
||||||
@@ -922,9 +462,13 @@ checksum:
|
|||||||
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
|
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
|
||||||
- glob: ./release_files/install.sh
|
- glob: ./release_files/install.sh
|
||||||
- glob: ./infrastructure_files/getting-started.sh
|
- glob: ./infrastructure_files/getting-started.sh
|
||||||
|
- glob: ./infrastructure_files/getting-started-enterprise.sh
|
||||||
|
- glob: ./infrastructure_files/migrate-to-enterprise.sh
|
||||||
|
|
||||||
release:
|
release:
|
||||||
extra_files:
|
extra_files:
|
||||||
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
|
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
|
||||||
- glob: ./release_files/install.sh
|
- glob: ./release_files/install.sh
|
||||||
- glob: ./infrastructure_files/getting-started.sh
|
- glob: ./infrastructure_files/getting-started.sh
|
||||||
|
- glob: ./infrastructure_files/getting-started-enterprise.sh
|
||||||
|
- glob: ./infrastructure_files/migrate-to-enterprise.sh
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
env:
|
||||||
|
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
||||||
project_name: netbird-ui
|
project_name: netbird-ui
|
||||||
builds:
|
builds:
|
||||||
- id: netbird-ui
|
- id: netbird-ui
|
||||||
@@ -101,6 +102,7 @@ nfpms:
|
|||||||
|
|
||||||
uploads:
|
uploads:
|
||||||
- name: debian
|
- name: debian
|
||||||
|
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||||
ids:
|
ids:
|
||||||
- netbird_ui_deb
|
- netbird_ui_deb
|
||||||
mode: archive
|
mode: archive
|
||||||
@@ -109,6 +111,7 @@ uploads:
|
|||||||
method: PUT
|
method: PUT
|
||||||
|
|
||||||
- name: yum
|
- name: yum
|
||||||
|
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||||
ids:
|
ids:
|
||||||
- netbird_ui_rpm
|
- netbird_ui_rpm
|
||||||
mode: archive
|
mode: archive
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
|
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
|
||||||
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
|
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
|
||||||
|
|
||||||
FROM alpine:3.23.3
|
FROM alpine:3.24
|
||||||
# iproute2: busybox doesn't display ip rules properly
|
# iproute2: busybox doesn't display ip rules properly
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
bash \
|
bash \
|
||||||
@@ -21,7 +21,7 @@ ENV \
|
|||||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||||
|
|
||||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||||
|
ARG TARGETPLATFORM
|
||||||
ARG NETBIRD_BINARY=netbird
|
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
|
||||||
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
||||||
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
|
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
|
# podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
|
||||||
# podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
|
# podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
|
||||||
|
|
||||||
FROM alpine:3.22.0
|
FROM alpine:3.24
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
bash \
|
bash \
|
||||||
@@ -27,7 +27,7 @@ ENV \
|
|||||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||||
|
|
||||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||||
|
ARG TARGETPLATFORM
|
||||||
ARG NETBIRD_BINARY=netbird
|
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
|
||||||
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
||||||
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
|
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
@@ -24,6 +23,7 @@ const (
|
|||||||
|
|
||||||
// Profile represents a profile for gomobile
|
// Profile represents a profile for gomobile
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
IsActive bool
|
IsActive bool
|
||||||
}
|
}
|
||||||
@@ -53,10 +53,10 @@ func (p *ProfileArray) Get(i int) *Profile {
|
|||||||
├── state.json ← Default profile state
|
├── state.json ← Default profile state
|
||||||
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
|
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
|
||||||
└── profiles/ ← Subdirectory for non-default profiles
|
└── profiles/ ← Subdirectory for non-default profiles
|
||||||
├── work.json ← Work profile config
|
├── work.json ← Legacy work profile config
|
||||||
├── work.state.json ← Work profile state
|
├── work.state.json ← Legacy work profile state
|
||||||
├── personal.json ← Personal profile config
|
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
|
||||||
└── personal.state.json ← Personal profile state
|
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ProfileManager manages profiles for Android
|
// ProfileManager manages profiles for Android
|
||||||
@@ -99,6 +99,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
|||||||
var profiles []*Profile
|
var profiles []*Profile
|
||||||
for _, p := range internalProfiles {
|
for _, p := range internalProfiles {
|
||||||
profiles = append(profiles, &Profile{
|
profiles = append(profiles, &Profile{
|
||||||
|
ID: p.ID.String(),
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
IsActive: p.IsActive,
|
IsActive: p.IsActive,
|
||||||
})
|
})
|
||||||
@@ -108,55 +109,65 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveProfile returns the currently active profile name
|
// GetActiveProfile returns the currently active profile name
|
||||||
func (pm *ProfileManager) GetActiveProfile() (string, error) {
|
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||||
// Use ServiceManager to stay consistent with ListProfiles
|
// Use ServiceManager to stay consistent with ListProfiles
|
||||||
// ServiceManager uses active_profile.json
|
// ServiceManager uses active_profile.json
|
||||||
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
return nil, fmt.Errorf("failed to get active profile: %w", err)
|
||||||
}
|
}
|
||||||
return activeState.Name, nil
|
|
||||||
|
// ActiveProfileState only stores the ID (and username), not the display
|
||||||
|
// name. Resolve the ID to the full profile so callers get the real Name.
|
||||||
|
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||||
|
}
|
||||||
|
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchProfile switches to a different profile
|
// SwitchProfile switches to a different profile
|
||||||
func (pm *ProfileManager) SwitchProfile(profileName string) error {
|
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||||
// Use ServiceManager to stay consistent with ListProfiles
|
// Use ServiceManager to stay consistent with ListProfiles
|
||||||
// ServiceManager uses active_profile.json
|
// ServiceManager uses active_profile.json
|
||||||
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||||
Name: profileName,
|
ID: profilemanager.ID(id),
|
||||||
Username: androidUsername,
|
Username: androidUsername,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to switch profile: %w", err)
|
return fmt.Errorf("failed to switch profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("switched to profile: %s", profileName)
|
log.Infof("switched to profile: %s", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddProfile creates a new profile
|
// AddProfile creates a new profile
|
||||||
func (pm *ProfileManager) AddProfile(profileName string) error {
|
func (pm *ProfileManager) AddProfile(profileName string) error {
|
||||||
// Use ServiceManager (creates profile in profiles/ directory)
|
// Use ServiceManager (creates profile in profiles/ directory)
|
||||||
if err := pm.serviceMgr.AddProfile(profileName, androidUsername); err != nil {
|
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to add profile: %w", err)
|
return fmt.Errorf("failed to add profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("created new profile: %s", profileName)
|
log.Infof("created new profile: %s", profile.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogoutProfile logs out from a profile (clears authentication)
|
// LogoutProfile logs out from a profile (clears authentication)
|
||||||
func (pm *ProfileManager) LogoutProfile(profileName string) error {
|
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||||
profileName = sanitizeProfileName(profileName)
|
configPath, err := pm.getProfileConfigPath(id)
|
||||||
|
|
||||||
configPath, err := pm.getProfileConfigPath(profileName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||||
|
return fmt.Errorf("id '%s' is not valid", id)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if profile exists
|
// Check if profile exists
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("profile '%s' does not exist", profileName)
|
return fmt.Errorf("profile '%s' does not exist", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read current config using internal profilemanager
|
// Read current config using internal profilemanager
|
||||||
@@ -174,53 +185,57 @@ func (pm *ProfileManager) LogoutProfile(profileName string) error {
|
|||||||
return fmt.Errorf("failed to save config: %w", err)
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("logged out from profile: %s", profileName)
|
log.Infof("logged out from profile: %s", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveProfile deletes a profile
|
// RemoveProfile deletes a profile
|
||||||
func (pm *ProfileManager) RemoveProfile(profileName string) error {
|
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||||
// Use ServiceManager (removes profile from profiles/ directory)
|
// Use ServiceManager (removes profile from profiles/ directory)
|
||||||
if err := pm.serviceMgr.RemoveProfile(profileName, androidUsername); err != nil {
|
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
|
||||||
return fmt.Errorf("failed to remove profile: %w", err)
|
return fmt.Errorf("failed to remove profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("removed profile: %s", profileName)
|
log.Infof("removed profile: %s", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getProfileConfigPath returns the config file path for a profile
|
// getProfileConfigPath returns the config file path for a profile
|
||||||
// This is needed for Android-specific path handling (netbird.cfg for default profile)
|
// This is needed for Android-specific path handling (netbird.cfg for default profile)
|
||||||
func (pm *ProfileManager) getProfileConfigPath(profileName string) (string, error) {
|
func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
|
||||||
if profileName == "" || profileName == profilemanager.DefaultProfileName {
|
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||||
|
return "", fmt.Errorf("id %q is not valid", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id == profilemanager.DefaultProfileName {
|
||||||
// Android uses netbird.cfg for default profile instead of default.json
|
// Android uses netbird.cfg for default profile instead of default.json
|
||||||
// Default profile is stored in root configDir, not in profiles/
|
// Default profile is stored in root configDir, not in profiles/
|
||||||
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-default profiles are stored in profiles subdirectory
|
|
||||||
// This matches the Java Preferences.java expectation
|
|
||||||
profileName = sanitizeProfileName(profileName)
|
|
||||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||||
return filepath.Join(profilesDir, profileName+".json"), nil
|
return filepath.Join(profilesDir, id+".json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfigPath returns the config file path for a given profile
|
// GetConfigPath returns the config file path for a given profile id
|
||||||
// Java should call this instead of constructing paths with Preferences.configFile()
|
// Java should call this instead of constructing paths with Preferences.configFile()
|
||||||
func (pm *ProfileManager) GetConfigPath(profileName string) (string, error) {
|
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
|
||||||
return pm.getProfileConfigPath(profileName)
|
return pm.getProfileConfigPath(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStateFilePath returns the state file path for a given profile
|
// GetStateFilePath returns the state file path for a given profile
|
||||||
// Java should call this instead of constructing paths with Preferences.stateFile()
|
// Java should call this instead of constructing paths with Preferences.stateFile()
|
||||||
func (pm *ProfileManager) GetStateFilePath(profileName string) (string, error) {
|
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
||||||
if profileName == "" || profileName == profilemanager.DefaultProfileName {
|
if id == "" || id == profilemanager.DefaultProfileName {
|
||||||
return filepath.Join(pm.configDir, "state.json"), nil
|
return filepath.Join(pm.configDir, "state.json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
profileName = sanitizeProfileName(profileName)
|
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||||
|
return "", fmt.Errorf("id %q is not valid", id)
|
||||||
|
}
|
||||||
|
|
||||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||||
return filepath.Join(profilesDir, profileName+".state.json"), nil
|
return filepath.Join(profilesDir, id+".state.json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveConfigPath returns the config file path for the currently active profile
|
// GetActiveConfigPath returns the config file path for the currently active profile
|
||||||
@@ -230,7 +245,7 @@ func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||||
}
|
}
|
||||||
return pm.GetConfigPath(activeProfile)
|
return pm.GetConfigPath(activeProfile.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveStateFilePath returns the state file path for the currently active profile
|
// GetActiveStateFilePath returns the state file path for the currently active profile
|
||||||
@@ -240,18 +255,5 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||||
}
|
}
|
||||||
return pm.GetStateFilePath(activeProfile)
|
return pm.GetStateFilePath(activeProfile.ID)
|
||||||
}
|
|
||||||
|
|
||||||
// sanitizeProfileName removes invalid characters from profile name
|
|
||||||
func sanitizeProfileName(name string) string {
|
|
||||||
// Keep only alphanumeric, underscore, and hyphen
|
|
||||||
var result strings.Builder
|
|
||||||
for _, r := range name {
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
|
||||||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.String()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/user"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
"google.golang.org/protobuf/types/known/durationpb"
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/server"
|
"github.com/netbirdio/netbird/client/server"
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
"github.com/netbirdio/netbird/upload-server/types"
|
"github.com/netbirdio/netbird/upload-server/types"
|
||||||
|
"github.com/netbirdio/netbird/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
const errCloseConnection = "Failed to close connection: %v"
|
const errCloseConnection = "Failed to close connection: %v"
|
||||||
@@ -84,6 +87,73 @@ var persistenceCmd = &cobra.Command{
|
|||||||
RunE: setSyncResponsePersistence,
|
RunE: setSyncResponsePersistence,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugConfigCmd = &cobra.Command{
|
||||||
|
Use: "config",
|
||||||
|
Example: " netbird debug config",
|
||||||
|
Short: "Dump the effective configuration",
|
||||||
|
Long: "Prints the daemon's resolved configuration (after applying defaults, file, env, CLI input, and MDM policy overrides) as JSON. Includes the list of MDM-managed fields.",
|
||||||
|
RunE: debugConfigDump,
|
||||||
|
}
|
||||||
|
|
||||||
|
// debugConfigDump implements `netbird debug config`. It resolves the
|
||||||
|
// active profile, queries the daemon for the effective configuration
|
||||||
|
// via GetConfig, and prints the resulting GetConfigResponse as JSON
|
||||||
|
// (via protojson with EmitUnpopulated=true so the output is stable
|
||||||
|
// across runs and includes zero-valued fields).
|
||||||
|
//
|
||||||
|
// Useful for verifying MDM enforcement end-to-end: the response's
|
||||||
|
// mDMManagedFields array is the single source of truth for "which
|
||||||
|
// fields is the daemon currently enforcing from the MDM source", and
|
||||||
|
// every config field side-by-side with that list confirms the merge
|
||||||
|
// result. Secrets in the response (e.g. PreSharedKey) are already
|
||||||
|
// redacted by the daemon-side handler.
|
||||||
|
func debugConfigDump(cmd *cobra.Command, _ []string) error {
|
||||||
|
pm := profilemanager.NewProfileManager()
|
||||||
|
activeProf, err := pm.GetActiveProfile()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get active profile: %v", err)
|
||||||
|
}
|
||||||
|
currUser, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get current user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := getClient(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := conn.Close(); err != nil {
|
||||||
|
log.Errorf(errCloseConnection, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := proto.NewDaemonServiceClient(conn)
|
||||||
|
resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{
|
||||||
|
ProfileName: activeProf.Name,
|
||||||
|
Username: currUser.Username,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get config: %v", status.Convert(err).Message())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use protojson so well-known fields render correctly; emit defaults so
|
||||||
|
// the operator sees every field even when zero/empty.
|
||||||
|
m := protojson.MarshalOptions{Multiline: true, Indent: " ", EmitUnpopulated: true}
|
||||||
|
out, err := m.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal config: %w", err)
|
||||||
|
}
|
||||||
|
cmd.Println(string(out))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// debugBundle requests the daemon to create a debug bundle and prints
|
||||||
|
// the resulting local file path and, if uploaded, the uploaded file
|
||||||
|
// key. It uses the package flags (anonymize, system info, log file
|
||||||
|
// count, CLI version, optional upload URL) to configure the bundle
|
||||||
|
// request. Returns an error if the RPC fails or if the daemon reports
|
||||||
|
// an upload failure reason.
|
||||||
func debugBundle(cmd *cobra.Command, _ []string) error {
|
func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||||
conn, err := getClient(cmd)
|
conn, err := getClient(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,6 +170,7 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
|
|||||||
Anonymize: anonymizeFlag,
|
Anonymize: anonymizeFlag,
|
||||||
SystemInfo: systemInfoFlag,
|
SystemInfo: systemInfoFlag,
|
||||||
LogFileCount: logFileCount,
|
LogFileCount: logFileCount,
|
||||||
|
CliVersion: version.NetbirdVersion(),
|
||||||
}
|
}
|
||||||
if uploadBundleFlag {
|
if uploadBundleFlag {
|
||||||
request.UploadURL = uploadBundleURLFlag
|
request.UploadURL = uploadBundleURLFlag
|
||||||
@@ -298,6 +369,7 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
|||||||
Anonymize: anonymizeFlag,
|
Anonymize: anonymizeFlag,
|
||||||
SystemInfo: systemInfoFlag,
|
SystemInfo: systemInfoFlag,
|
||||||
LogFileCount: logFileCount,
|
LogFileCount: logFileCount,
|
||||||
|
CliVersion: version.NetbirdVersion(),
|
||||||
}
|
}
|
||||||
if uploadBundleFlag {
|
if uploadBundleFlag {
|
||||||
request.UploadURL = uploadBundleURLFlag
|
request.UploadURL = uploadBundleURLFlag
|
||||||
@@ -432,6 +504,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
|
|||||||
SyncResponse: syncResponse,
|
SyncResponse: syncResponse,
|
||||||
LogPath: logFilePath,
|
LogPath: logFilePath,
|
||||||
CPUProfile: nil,
|
CPUProfile: nil,
|
||||||
|
DaemonVersion: version.NetbirdVersion(), // acting as daemon
|
||||||
},
|
},
|
||||||
debug.BundleConfig{
|
debug.BundleConfig{
|
||||||
IncludeSystemInfo: true,
|
IncludeSystemInfo: true,
|
||||||
|
|||||||
301
client/cmd/kubernetes.go
Normal file
301
client/cmd/kubernetes.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
KubernetesDNSSuffix = "netbird-kubeapi-proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
var kubernetesCmd = &cobra.Command{
|
||||||
|
Use: "kubernetes",
|
||||||
|
Short: "Kubernetes cluster commands.",
|
||||||
|
Long: "Kubernetes cluster commands.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var kubernetesListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
RunE: kubernetesList,
|
||||||
|
Short: "List Kubernetes clusters.",
|
||||||
|
Long: "List Kubernetes clusters by discovering NetBird peers running netbird-kubeapi-proxy.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var kubernetesWriteKubeconfigCmd = &cobra.Command{
|
||||||
|
Use: "write-kubeconfig",
|
||||||
|
RunE: kubernetesWriteKubeconfig,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Short: "Write kubeconfig for a Kubernetes cluster.",
|
||||||
|
Long: "Updates kubeconfig in place to allow token-less access to the Kubernetes cluster through NetBird.",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
kubernetesWriteKubeconfigCmd.Flags().String("kubeconfig", "", "path to kubeconfig file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func kubernetesList(cmd *cobra.Command, _ []string) error {
|
||||||
|
conn, err := getClient(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
client := proto.NewDaemonServiceClient(conn)
|
||||||
|
statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(kcs) == 0 {
|
||||||
|
cmd.Println("No Kubernetes clusters available.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmd.Println("Available Kubernetes clusters:")
|
||||||
|
for _, k := range kcs {
|
||||||
|
cmd.Printf("\n - Name: %s\n FQDN: %s\n Version: %s\n", k.name, k.url.Host, k.version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kubernetesWriteKubeconfig(cmd *cobra.Command, args []string) error {
|
||||||
|
kubeconfigPath, err := resolveKubeconfigPath(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := getClient(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
client := proto.NewDaemonServiceClient(conn)
|
||||||
|
statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterName := args[0]
|
||||||
|
kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, clusterName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(kcs) == 0 {
|
||||||
|
return fmt.Errorf("kubernetes cluster named %s not found", clusterName)
|
||||||
|
}
|
||||||
|
if len(kcs) > 1 {
|
||||||
|
return fmt.Errorf("too many Kubernetes clusters returned")
|
||||||
|
}
|
||||||
|
err = writeKubeconfig(kubeconfigPath, kcs[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type kubernetesCluster struct {
|
||||||
|
name string
|
||||||
|
url *url.URL
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getKubernetesClusters(ctx context.Context, peers []*proto.PeerState, nameFilter string) ([]kubernetesCluster, error) {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.TLSClientConfig = &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
}
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
resolver := net.Resolver{
|
||||||
|
// Required so both DNS records are returned.
|
||||||
|
// https://github.com/golang/go/issues/17093
|
||||||
|
PreferGo: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
kcs := []kubernetesCluster{}
|
||||||
|
attempted := map[string]struct{}{}
|
||||||
|
for _, peer := range peers {
|
||||||
|
fqdns, err := resolver.LookupAddr(ctx, peer.IP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, fqdn := range fqdns {
|
||||||
|
if _, ok := attempted[fqdn]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
attempted[fqdn] = struct{}{}
|
||||||
|
comps := strings.Split(fqdn, ".")
|
||||||
|
if len(comps) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if comps[1] != KubernetesDNSSuffix {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nameFilter != "" && nameFilter != comps[0] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kc := kubernetesCluster{
|
||||||
|
name: comps[0],
|
||||||
|
url: clusterURL,
|
||||||
|
version: clusterVersion,
|
||||||
|
}
|
||||||
|
if nameFilter != "" {
|
||||||
|
return []kubernetesCluster{kc}, nil
|
||||||
|
}
|
||||||
|
kcs = append(kcs, kc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kcs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintClusters(ctx context.Context, httpClient *http.Client, fqdn string) (*url.URL, string, error) {
|
||||||
|
clusterURL, err := url.Parse("https://" + fqdn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
versionURL, err := clusterURL.Parse("/version")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, "", fmt.Errorf("expected %d response but got %s", http.StatusOK, resp.Status)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
versionData := map[string]string{}
|
||||||
|
err = json.Unmarshal(b, &versionData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
version, ok := versionData["gitVersion"]
|
||||||
|
if !ok {
|
||||||
|
return nil, "", errors.New("no version found in response")
|
||||||
|
}
|
||||||
|
return clusterURL, version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveKubeconfigPath(cmd *cobra.Command) (string, error) {
|
||||||
|
if cmd.Flags().Changed("kubeconfig") {
|
||||||
|
path, err := cmd.Flags().GetString("kubeconfig")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
if env := os.Getenv("KUBECONFIG"); env != "" {
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not determine home directory: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".kube", "config"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeKubeconfig(kubeconfigPath string, kc kubernetesCluster) error {
|
||||||
|
b, err := os.ReadFile(kubeconfigPath)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := yaml.Unmarshal(b, &cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = map[string]any{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Config",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg["clusters"] = appendWithName(cfg["clusters"], map[string]any{
|
||||||
|
"name": kc.name,
|
||||||
|
"cluster": map[string]any{
|
||||||
|
"server": kc.url.String(),
|
||||||
|
"insecure-skip-tls-verify": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
cfg["users"] = appendWithName(cfg["users"], map[string]any{
|
||||||
|
"name": "netbird",
|
||||||
|
"user": map[string]any{
|
||||||
|
"token": "none",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
cfg["contexts"] = appendWithName(cfg["contexts"], map[string]any{
|
||||||
|
"name": kc.name,
|
||||||
|
"context": map[string]any{
|
||||||
|
"cluster": kc.name,
|
||||||
|
"user": "netbird",
|
||||||
|
"namespace": "default",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
cfg["current-context"] = kc.name
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(kubeconfigPath, out, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendWithName(data any, add map[string]any) any {
|
||||||
|
if data == nil {
|
||||||
|
return []any{add}
|
||||||
|
}
|
||||||
|
v, ok := data.([]any)
|
||||||
|
if !ok {
|
||||||
|
return []any{add}
|
||||||
|
}
|
||||||
|
i := slices.IndexFunc(v, func(item any) bool {
|
||||||
|
m, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return m["name"] == add["name"]
|
||||||
|
})
|
||||||
|
if i == -1 {
|
||||||
|
return append(v, add)
|
||||||
|
}
|
||||||
|
v[i] = add
|
||||||
|
return v
|
||||||
|
}
|
||||||
120
client/cmd/kubernetes_test.go
Normal file
120
client/cmd/kubernetes_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFingerprintClusters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
//nolint: errcheck
|
||||||
|
w.Write([]byte(`{"gitVersion": "foobar"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
clusterURL, clusterVersion, err := fingerprintClusters(t.Context(), srv.Client(), srv.Listener.Addr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, srv.URL, clusterURL.String())
|
||||||
|
require.Equal(t, "foobar", clusterVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveKubeconfigPath(t *testing.T) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not determine home directory: %v", err)
|
||||||
|
}
|
||||||
|
defaultPath := filepath.Join(home, ".kube", "config")
|
||||||
|
path, err := resolveKubeconfigPath(&cobra.Command{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, defaultPath, path)
|
||||||
|
|
||||||
|
flagPath := "flag-path"
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().String("kubeconfig", "", "")
|
||||||
|
err = cmd.Flags().Set("kubeconfig", flagPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
path, err = resolveKubeconfigPath(cmd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, flagPath, path)
|
||||||
|
|
||||||
|
envPath := "env-path"
|
||||||
|
t.Setenv("KUBECONFIG", envPath)
|
||||||
|
path, err = resolveKubeconfigPath(&cobra.Command{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, envPath, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteKubeconfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
existing string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing content",
|
||||||
|
existing: `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
insecure-skip-tls-verify: true
|
||||||
|
server: https://foobar.com
|
||||||
|
name: foo
|
||||||
|
current-context: test
|
||||||
|
kind: Config
|
||||||
|
users: []
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
kubeconfigPath := filepath.Join(t.TempDir(), "config")
|
||||||
|
err := os.WriteFile(kubeconfigPath, []byte(tt.existing), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
kc := kubernetesCluster{
|
||||||
|
name: "foo",
|
||||||
|
url: &url.URL{Scheme: "https", Host: "example.com"},
|
||||||
|
}
|
||||||
|
err = writeKubeconfig(kubeconfigPath, kc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
b, err := os.ReadFile(kubeconfigPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
expected := `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
insecure-skip-tls-verify: true
|
||||||
|
server: https://example.com
|
||||||
|
name: foo
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: foo
|
||||||
|
namespace: default
|
||||||
|
user: netbird
|
||||||
|
name: foo
|
||||||
|
current-context: foo
|
||||||
|
kind: Config
|
||||||
|
users:
|
||||||
|
- name: netbird
|
||||||
|
user:
|
||||||
|
token: none
|
||||||
|
`
|
||||||
|
require.Equal(t, expected, string(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -96,17 +96,19 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
|||||||
dnsLabelsReq = dnsLabelsValidated.ToSafeStringList()
|
dnsLabelsReq = dnsLabelsValidated.ToSafeStringList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle := activeProf.ID.String()
|
||||||
|
|
||||||
loginRequest := proto.LoginRequest{
|
loginRequest := proto.LoginRequest{
|
||||||
SetupKey: providedSetupKey,
|
SetupKey: providedSetupKey,
|
||||||
ManagementUrl: managementURL,
|
ManagementUrl: managementURL,
|
||||||
IsUnixDesktopClient: isUnixRunningDesktop(),
|
IsUnixDesktopClient: isUnixRunningDesktop(),
|
||||||
Hostname: hostName,
|
Hostname: hostName,
|
||||||
DnsLabels: dnsLabelsReq,
|
DnsLabels: dnsLabelsReq,
|
||||||
ProfileName: &activeProf.Name,
|
ProfileName: &handle,
|
||||||
Username: &username,
|
Username: &username,
|
||||||
}
|
}
|
||||||
|
|
||||||
profileState, err := pm.GetProfileState(activeProf.Name)
|
profileState, err := pm.GetProfileState(activeProf.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("failed to get profile state for login hint: %v", err)
|
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||||
} else if profileState.Email != "" {
|
} else if profileState.Email != "" {
|
||||||
@@ -170,14 +172,13 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
|
|||||||
return activeProf, nil
|
return activeProf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) error {
|
func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error {
|
||||||
err := switchProfile(context.Background(), profileName, username)
|
resolvedID, err := switchProfile(ctx, handle, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("switch profile on daemon: %v", err)
|
return fmt.Errorf("switch profile on daemon: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pm.SwitchProfile(profileName)
|
if err := pm.SwitchProfile(resolvedID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("switch profile: %v", err)
|
return fmt.Errorf("switch profile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,11 +206,15 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchProfile(ctx context.Context, profileName string, username string) error {
|
// switchProfile asks the daemon to switch to the profile identified by
|
||||||
|
// handle (a name, ID, or unique ID prefix). Returns the resolved profile
|
||||||
|
// ID so the caller can update the local active-profile state without
|
||||||
|
// re-resolving the handle.
|
||||||
|
func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//nolint
|
//nolint
|
||||||
return fmt.Errorf("failed to connect to daemon error: %v\n"+
|
return "", fmt.Errorf("failed to connect to daemon error: %v\n"+
|
||||||
"If the daemon is not running please run: "+
|
"If the daemon is not running please run: "+
|
||||||
"\nnetbird service install \nnetbird service start\n", err)
|
"\nnetbird service install \nnetbird service start\n", err)
|
||||||
}
|
}
|
||||||
@@ -217,15 +222,15 @@ func switchProfile(ctx context.Context, profileName string, username string) err
|
|||||||
|
|
||||||
client := proto.NewDaemonServiceClient(conn)
|
client := proto.NewDaemonServiceClient(conn)
|
||||||
|
|
||||||
_, err = client.SwitchProfile(ctx, &proto.SwitchProfileRequest{
|
resp, err := client.SwitchProfile(ctx, &proto.SwitchProfileRequest{
|
||||||
ProfileName: &profileName,
|
ProfileName: &handle,
|
||||||
Username: &username,
|
Username: &username,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("switch profile failed: %v", err)
|
return "", fmt.Errorf("switch profile failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return profilemanager.ID(resp.Id), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, activeProf *profilemanager.Profile) error {
|
func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, activeProf *profilemanager.Profile) error {
|
||||||
@@ -249,7 +254,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
|||||||
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
return fmt.Errorf("read config file %s: %v", configFilePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name)
|
err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("foreground login failed: %v", err)
|
return fmt.Errorf("foreground login failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -277,7 +282,7 @@ func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.Lo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey, profileName string) error {
|
func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey string, profileID profilemanager.ID) error {
|
||||||
authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
|
authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create auth client: %v", err)
|
return fmt.Errorf("failed to create auth client: %v", err)
|
||||||
@@ -291,7 +296,7 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman
|
|||||||
|
|
||||||
jwtToken := ""
|
jwtToken := ""
|
||||||
if setupKey == "" && needsLogin {
|
if setupKey == "" && needsLogin {
|
||||||
tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileName)
|
tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -306,10 +311,10 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileName string) (*auth.TokenInfo, error) {
|
func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileID profilemanager.ID) (*auth.TokenInfo, error) {
|
||||||
hint := ""
|
hint := ""
|
||||||
pm := profilemanager.NewProfileManager()
|
pm := profilemanager.NewProfileManager()
|
||||||
profileState, err := pm.GetProfileState(profileName)
|
profileState, err := pm.GetProfileState(profileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("failed to get profile state for login hint: %v", err)
|
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||||
} else if profileState.Email != "" {
|
} else if profileState.Email != "" {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestLogin(t *testing.T) {
|
|||||||
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
|
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
|
||||||
sm := profilemanager.ServiceManager{}
|
sm := profilemanager.ServiceManager{}
|
||||||
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||||
Name: "default",
|
ID: "default",
|
||||||
Username: currUser.Username,
|
Username: currUser.Username,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/user"
|
"os/user"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
@@ -14,6 +19,8 @@ import (
|
|||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var profileListShowID bool
|
||||||
|
|
||||||
var profileCmd = &cobra.Command{
|
var profileCmd = &cobra.Command{
|
||||||
Use: "profile",
|
Use: "profile",
|
||||||
Short: "Manage NetBird client profiles",
|
Short: "Manage NetBird client profiles",
|
||||||
@@ -31,27 +38,40 @@ var profileListCmd = &cobra.Command{
|
|||||||
var profileAddCmd = &cobra.Command{
|
var profileAddCmd = &cobra.Command{
|
||||||
Use: "add <profile_name>",
|
Use: "add <profile_name>",
|
||||||
Short: "Add a new profile",
|
Short: "Add a new profile",
|
||||||
Long: `Add a new profile to the NetBird client. The profile name must be unique.`,
|
Long: `Add a new profile. Profile name is free-form, a unique ID is generated for the on-disk config file.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: addProfileFunc,
|
RunE: addProfileFunc,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var profileRenameCmd = &cobra.Command{
|
||||||
|
Use: "rename <profile> <new_profile_name>",
|
||||||
|
Short: "Renames an existing profile",
|
||||||
|
Long: `Renames an existing profile (by a name, ID, or unique ID prefix). Profile name is free-form.`,
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: renameProfileFunc,
|
||||||
|
}
|
||||||
|
|
||||||
var profileRemoveCmd = &cobra.Command{
|
var profileRemoveCmd = &cobra.Command{
|
||||||
Use: "remove <profile_name>",
|
Use: "remove <profile>",
|
||||||
Short: "Remove a profile",
|
Short: "Remove a profile",
|
||||||
Long: `Remove a profile from the NetBird client. The profile must not be inactive.`,
|
Long: `Remove a profile by name, ID, or unique ID prefix.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Aliases: []string{"rm"},
|
||||||
RunE: removeProfileFunc,
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: removeProfileFunc,
|
||||||
}
|
}
|
||||||
|
|
||||||
var profileSelectCmd = &cobra.Command{
|
var profileSelectCmd = &cobra.Command{
|
||||||
Use: "select <profile_name>",
|
Use: "select <profile>",
|
||||||
Short: "Select a profile",
|
Short: "Select a profile",
|
||||||
Long: `Make the specified profile active. This will switch the client to use the selected profile's configuration.`,
|
Long: `Make the specified profile active. Accepts a name, ID, or unique ID prefix.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: selectProfileFunc,
|
RunE: selectProfileFunc,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column")
|
||||||
|
}
|
||||||
|
|
||||||
func setupCmd(cmd *cobra.Command) error {
|
func setupCmd(cmd *cobra.Command) error {
|
||||||
SetFlagsFromEnvVars(rootCmd)
|
SetFlagsFromEnvVars(rootCmd)
|
||||||
SetFlagsFromEnvVars(cmd)
|
SetFlagsFromEnvVars(cmd)
|
||||||
@@ -65,6 +85,7 @@ func setupCmd(cmd *cobra.Command) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
||||||
if err := setupCmd(cmd); err != nil {
|
if err := setupCmd(cmd); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -83,25 +104,33 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
|||||||
|
|
||||||
daemonClient := proto.NewDaemonServiceClient(conn)
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||||
|
|
||||||
profiles, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
|
resp, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
|
||||||
Username: currUser.Username,
|
Username: currUser.Username,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// list profiles, add a tick if the profile is active
|
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
||||||
cmd.Println("Found", len(profiles.Profiles), "profiles:")
|
if profileListShowID {
|
||||||
for _, profile := range profiles.Profiles {
|
fmt.Fprintln(tw, "ID\tNAME\tACTIVE")
|
||||||
// use a cross to indicate the passive profiles
|
} else {
|
||||||
activeMarker := "✗"
|
fmt.Fprintln(tw, "NAME\tACTIVE")
|
||||||
if profile.IsActive {
|
|
||||||
activeMarker = "✓"
|
|
||||||
}
|
|
||||||
cmd.Println(activeMarker, profile.Name)
|
|
||||||
}
|
}
|
||||||
|
for _, profile := range resp.Profiles {
|
||||||
return nil
|
marker := ""
|
||||||
|
if profile.IsActive {
|
||||||
|
marker = "✓"
|
||||||
|
}
|
||||||
|
name := profilemanager.StripCtrlChars(profile.Name)
|
||||||
|
id := profilemanager.ID(profile.Id)
|
||||||
|
if profileListShowID {
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\t%s\n", id.ShortID(), name, marker)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\n", name, marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tw.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
func addProfileFunc(cmd *cobra.Command, args []string) error {
|
func addProfileFunc(cmd *cobra.Command, args []string) error {
|
||||||
@@ -109,6 +138,41 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currUser, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get current user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to service CLI interface: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||||
|
profileName := args[0]
|
||||||
|
|
||||||
|
id, err := addProfileOnDaemon(cmd.Context(), daemonClient, profileName, currUser.Username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName)
|
||||||
|
if dupCount > 1 {
|
||||||
|
cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, profileName)
|
||||||
|
cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName))
|
||||||
|
return nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameProfileFunc(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := setupCmd(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to service CLI interface: %w", err)
|
return fmt.Errorf("connect to service CLI interface: %w", err)
|
||||||
@@ -121,21 +185,43 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
daemonClient := proto.NewDaemonServiceClient(conn)
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||||
|
handle := args[0]
|
||||||
|
newProfilename := args[1]
|
||||||
|
|
||||||
profileName := args[0]
|
resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{
|
||||||
|
Handle: handle,
|
||||||
_, err = daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{
|
Username: currUser.Username,
|
||||||
ProfileName: profileName,
|
NewProfileName: newProfilename,
|
||||||
Username: currUser.Username,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return wrapAmbiguityError(err, handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Println("Profile added successfully:", profileName)
|
dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename)
|
||||||
|
if dupCount > 1 {
|
||||||
|
cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, newProfilename)
|
||||||
|
cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Profile renamed from %s to %s\n", profilemanager.StripCtrlChars(resp.OldProfileName), profilemanager.StripCtrlChars(newProfilename))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func countProfilesWithName(ctx context.Context, c proto.DaemonServiceClient, username, name string) (int, error) {
|
||||||
|
resp, err := c.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, p := range resp.Profiles {
|
||||||
|
if p.Name == name {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
func removeProfileFunc(cmd *cobra.Command, args []string) error {
|
func removeProfileFunc(cmd *cobra.Command, args []string) error {
|
||||||
if err := setupCmd(cmd); err != nil {
|
if err := setupCmd(cmd); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -153,18 +239,17 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
daemonClient := proto.NewDaemonServiceClient(conn)
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||||
|
handle := args[0]
|
||||||
|
|
||||||
profileName := args[0]
|
resp, err := daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
|
||||||
|
ProfileName: handle,
|
||||||
_, err = daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
|
|
||||||
ProfileName: profileName,
|
|
||||||
Username: currUser.Username,
|
Username: currUser.Username,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return wrapAmbiguityError(err, handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Println("Profile removed successfully:", profileName)
|
cmd.Printf("Profile removed: %s\n", resp.Id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +259,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
profileManager := profilemanager.NewProfileManager()
|
profileManager := profilemanager.NewProfileManager()
|
||||||
profileName := args[0]
|
handle := args[0]
|
||||||
|
|
||||||
currUser, err := user.Current()
|
currUser, err := user.Current()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,32 +276,15 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
daemonClient := proto.NewDaemonServiceClient(conn)
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||||
|
|
||||||
profiles, err := daemonClient.ListProfiles(ctx, &proto.ListProfilesRequest{
|
switchResp, err := daemonClient.SwitchProfile(ctx, &proto.SwitchProfileRequest{
|
||||||
Username: currUser.Username,
|
ProfileName: &handle,
|
||||||
|
Username: &currUser.Username,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list profiles: %w", err)
|
return wrapAmbiguityError(err, handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
var profileExists bool
|
if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil {
|
||||||
|
|
||||||
for _, profile := range profiles.Profiles {
|
|
||||||
if profile.Name == profileName {
|
|
||||||
profileExists = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !profileExists {
|
|
||||||
return fmt.Errorf("profile %s does not exist", profileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := switchProfile(cmd.Context(), profileName, currUser.Username); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = profileManager.SwitchProfile(profileName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +299,46 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Println("Profile switched successfully to:", profileName)
|
id := profilemanager.ID(switchResp.Id)
|
||||||
|
cmd.Printf("Profile switched to: %s\n", id.ShortID())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors
|
||||||
|
// (which carry the resolver's message verbatim) into CLI-friendly text
|
||||||
|
// that points the user at --show-id.
|
||||||
|
func wrapAmbiguityError(err error, handle string) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st, ok := gstatus.FromError(err)
|
||||||
|
if !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch st.Code() {
|
||||||
|
case codes.InvalidArgument:
|
||||||
|
msg := st.Message()
|
||||||
|
if strings.Contains(msg, "ambiguous") {
|
||||||
|
return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n netbird profile select|remove <id-prefix>")
|
||||||
|
}
|
||||||
|
case codes.NotFound:
|
||||||
|
return fmt.Errorf("profile %q not found", handle)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// addProfileOnDaemon issues the AddProfile RPC on an existing daemon client
|
||||||
|
// and returns the new profile's ID. It is the single entry point for profile
|
||||||
|
// creation, shared by `netbird profile add` and the `netbird up --profile
|
||||||
|
// <name>` auto-create path.
|
||||||
|
func addProfileOnDaemon(ctx context.Context, client proto.DaemonServiceClient, profileName, username string) (profilemanager.ID, error) {
|
||||||
|
resp, err := client.AddProfile(ctx, &proto.AddProfileRequest{
|
||||||
|
ProfileName: profileName,
|
||||||
|
Username: username,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("add profile failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return profilemanager.ID(resp.Id), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Execute executes the root command.
|
// Execute runs the appropriate Cobra command for the CLI.
|
||||||
|
// If the process is the update binary it delegates to updateCmd; otherwise it runs the root command.
|
||||||
|
// It returns any error produced during command execution.
|
||||||
func Execute() error {
|
func Execute() error {
|
||||||
if isUpdateBinary() {
|
if isUpdateBinary() {
|
||||||
return updateCmd.Execute()
|
return updateCmd.Execute()
|
||||||
@@ -103,6 +105,16 @@ func Execute() error {
|
|||||||
return rootCmd.Execute()
|
return rootCmd.Execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// init initialises package-level defaults and configures the root
|
||||||
|
// Cobra command tree. Sets platform-specific config / log directory
|
||||||
|
// paths (including legacy Wiretrustee fallbacks) and a default daemon
|
||||||
|
// address; registers persistent CLI flags (daemon address,
|
||||||
|
// management / admin URLs, logging, setup key (file and inline,
|
||||||
|
// mutually exclusive), preshared key, hostname, anonymise, config
|
||||||
|
// path); attaches top-level and nested subcommands to the root
|
||||||
|
// command; and registers `up`-specific persistent flags (external IP
|
||||||
|
// maps, custom DNS resolver address, Rosenpass options, auto-connect
|
||||||
|
// disabling, lazy connection).
|
||||||
func init() {
|
func init() {
|
||||||
defaultConfigPathDir = "/etc/netbird/"
|
defaultConfigPathDir = "/etc/netbird/"
|
||||||
defaultLogFileDir = "/var/log/netbird/"
|
defaultLogFileDir = "/var/log/netbird/"
|
||||||
@@ -168,10 +180,17 @@ func init() {
|
|||||||
logCmd.AddCommand(logLevelCmd)
|
logCmd.AddCommand(logLevelCmd)
|
||||||
debugCmd.AddCommand(forCmd)
|
debugCmd.AddCommand(forCmd)
|
||||||
debugCmd.AddCommand(persistenceCmd)
|
debugCmd.AddCommand(persistenceCmd)
|
||||||
|
debugCmd.AddCommand(debugConfigCmd)
|
||||||
|
|
||||||
|
// kubernetes commands
|
||||||
|
rootCmd.AddCommand(kubernetesCmd)
|
||||||
|
kubernetesCmd.AddCommand(kubernetesListCmd)
|
||||||
|
kubernetesCmd.AddCommand(kubernetesWriteKubeconfigCmd)
|
||||||
|
|
||||||
// profile commands
|
// profile commands
|
||||||
profileCmd.AddCommand(profileListCmd)
|
profileCmd.AddCommand(profileListCmd)
|
||||||
profileCmd.AddCommand(profileAddCmd)
|
profileCmd.AddCommand(profileAddCmd)
|
||||||
|
profileCmd.AddCommand(profileRenameCmd)
|
||||||
profileCmd.AddCommand(profileRemoveCmd)
|
profileCmd.AddCommand(profileRemoveCmd)
|
||||||
profileCmd.AddCommand(profileSelectCmd)
|
profileCmd.AddCommand(profileSelectCmd)
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func (p *program) Stop(srv service.Service) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Common setup for service control commands
|
// Common setup for service control commands
|
||||||
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
|
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) {
|
||||||
// rootCmd env vars are already applied by PersistentPreRunE.
|
// rootCmd env vars are already applied by PersistentPreRunE.
|
||||||
SetFlagsFromEnvVars(serviceCmd)
|
SetFlagsFromEnvVars(serviceCmd)
|
||||||
|
|
||||||
@@ -112,8 +112,14 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := util.InitLog(logLevel, logFiles...); err != nil {
|
if consoleLog {
|
||||||
return nil, fmt.Errorf("init log: %w", err)
|
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||||
|
return nil, fmt.Errorf("init log: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := util.InitLog(logLevel, logFiles...); err != nil {
|
||||||
|
return nil, fmt.Errorf("init log: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := newSVCConfig()
|
cfg, err := newSVCConfig()
|
||||||
@@ -138,7 +144,7 @@ var runCmd = &cobra.Command{
|
|||||||
SetupCloseHandler(ctx, cancel)
|
SetupCloseHandler(ctx, cancel)
|
||||||
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
|
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
|
||||||
|
|
||||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -152,7 +158,7 @@ var startCmd = &cobra.Command{
|
|||||||
Short: "starts NetBird service",
|
Short: "starts NetBird service",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
ctx, cancel := context.WithCancel(cmd.Context())
|
ctx, cancel := context.WithCancel(cmd.Context())
|
||||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -170,7 +176,7 @@ var stopCmd = &cobra.Command{
|
|||||||
Short: "stops NetBird service",
|
Short: "stops NetBird service",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
ctx, cancel := context.WithCancel(cmd.Context())
|
ctx, cancel := context.WithCancel(cmd.Context())
|
||||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -188,7 +194,7 @@ var restartCmd = &cobra.Command{
|
|||||||
Short: "restarts NetBird service",
|
Short: "restarts NetBird service",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
ctx, cancel := context.WithCancel(cmd.Context())
|
ctx, cancel := context.WithCancel(cmd.Context())
|
||||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -206,7 +212,7 @@ var svcStatusCmd = &cobra.Command{
|
|||||||
Short: "shows NetBird service status",
|
Short: "shows NetBird service status",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
ctx, cancel := context.WithCancel(cmd.Context())
|
ctx, cancel := context.WithCancel(cmd.Context())
|
||||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
s, err := setupServiceControlCommand(cmd, ctx, cancel, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
nbstatus "github.com/netbirdio/netbird/client/status"
|
nbstatus "github.com/netbirdio/netbird/client/status"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
@@ -111,11 +110,10 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pm := profilemanager.NewProfileManager()
|
// Resolve the active profile's display name via the daemon, which runs
|
||||||
var profName string
|
// as root and can read the per-user profile files. The local profile
|
||||||
if activeProf, err := pm.GetActiveProfile(); err == nil {
|
// manager only knows the active profile ID, not its display name.
|
||||||
profName = activeProf.Name
|
profName := getActiveProfileName(ctx)
|
||||||
}
|
|
||||||
|
|
||||||
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||||
Anonymize: anonymizeFlag,
|
Anonymize: anonymizeFlag,
|
||||||
@@ -167,6 +165,25 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getActiveProfileName asks the daemon for the active profile's display
|
||||||
|
// name. The daemon runs as root and can read the per-user profile files to
|
||||||
|
// resolve the ID to its human-readable name. Returns an empty string on any
|
||||||
|
// error so status output degrades gracefully.
|
||||||
|
func getActiveProfileName(ctx context.Context) string {
|
||||||
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
resp, err := proto.NewDaemonServiceClient(conn).GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.GetProfileName()
|
||||||
|
}
|
||||||
|
|
||||||
func parseFilters() error {
|
func parseFilters() error {
|
||||||
switch strings.ToLower(statusFilter) {
|
switch strings.ToLower(statusFilter) {
|
||||||
case "", "idle", "connecting", "connected":
|
case "", "idle", "connecting", "connected":
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
"github.com/netbirdio/management-integrations/integrations"
|
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||||
|
|
||||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
iv, _ := integrations.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
|
iv, _ := validator.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
|
||||||
|
|
||||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -128,16 +128,9 @@ func upFunc(cmd *cobra.Command, args []string) error {
|
|||||||
var profileSwitched bool
|
var profileSwitched bool
|
||||||
// switch profile if provided
|
// switch profile if provided
|
||||||
if profileName != "" {
|
if profileName != "" {
|
||||||
err = switchProfile(cmd.Context(), profileName, username.Username)
|
if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("switch profile: %v", err)
|
return fmt.Errorf("switch profile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pm.SwitchProfile(profileName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("switch profile: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
profileSwitched = true
|
profileSwitched = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +145,52 @@ func upFunc(cmd *cobra.Command, args []string) error {
|
|||||||
return runInDaemonMode(ctx, cmd, pm, activeProf, profileSwitched)
|
return runInDaemonMode(ctx, cmd, pm, activeProf, profileSwitched)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// switchOrCreateProfile switches the active profile to the one identified by
|
||||||
|
// handle, creating it first when it does not exist yet. This restores the
|
||||||
|
// pre-0.73 behaviour where `netbird up --profile <name>` auto-creates a
|
||||||
|
// missing profile instead of failing.
|
||||||
|
func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error {
|
||||||
|
resolvedID, err := switchProfile(ctx, handle, username)
|
||||||
|
if err != nil {
|
||||||
|
st, ok := gstatus.FromError(err)
|
||||||
|
if !ok || st.Code() != codes.NotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Don't fail immediately on a create error: a concurrent run may
|
||||||
|
// have created the profile between the NotFound above and this
|
||||||
|
// call, in which case the retried switch still succeeds. Only
|
||||||
|
// surface the create error if the switch also fails.
|
||||||
|
_, createErr := createProfile(ctx, handle, username)
|
||||||
|
if resolvedID, err = switchProfile(ctx, handle, username); err != nil {
|
||||||
|
if createErr != nil {
|
||||||
|
return fmt.Errorf("create profile: %w", createErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pm.SwitchProfile(resolvedID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createProfile dials the daemon and creates a new profile with the given
|
||||||
|
// display name, returning its generated ID. Use addProfileOnDaemon directly
|
||||||
|
// when a daemon client is already available to reuse the connection.
|
||||||
|
func createProfile(ctx context.Context, profileName, username string) (profilemanager.ID, 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()
|
||||||
|
|
||||||
|
return addProfileOnDaemon(ctx, proto.NewDaemonServiceClient(conn), profileName, username)
|
||||||
|
}
|
||||||
|
|
||||||
func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error {
|
func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error {
|
||||||
// override the default profile filepath if provided
|
// override the default profile filepath if provided
|
||||||
if configPath != "" {
|
if configPath != "" {
|
||||||
@@ -190,7 +229,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
|||||||
|
|
||||||
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
|
||||||
|
|
||||||
err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.Name)
|
err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("foreground login failed: %v", err)
|
return fmt.Errorf("foreground login failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -261,10 +300,10 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
|||||||
}
|
}
|
||||||
|
|
||||||
// set the new config
|
// set the new config
|
||||||
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.Name, username.Username)
|
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
|
||||||
if _, err := client.SetConfig(ctx, req); err != nil {
|
if _, err := client.SetConfig(ctx, req); err != nil {
|
||||||
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
|
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
|
||||||
log.Warnf("setConfig method is not available in the daemon")
|
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("call service setConfig method: %v", err)
|
return fmt.Errorf("call service setConfig method: %v", err)
|
||||||
}
|
}
|
||||||
@@ -289,10 +328,11 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
|||||||
return fmt.Errorf("setup login request: %v", err)
|
return fmt.Errorf("setup login request: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
loginRequest.ProfileName = &activeProf.Name
|
profileID := activeProf.ID.String()
|
||||||
|
loginRequest.ProfileName = &profileID
|
||||||
loginRequest.Username = &username
|
loginRequest.Username = &username
|
||||||
|
|
||||||
profileState, err := pm.GetProfileState(activeProf.Name)
|
profileState, err := pm.GetProfileState(activeProf.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("failed to get profile state for login hint: %v", err)
|
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||||
} else if profileState.Email != "" {
|
} else if profileState.Email != "" {
|
||||||
@@ -329,7 +369,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := client.Up(ctx, &proto.UpRequest{
|
if _, err := client.Up(ctx, &proto.UpRequest{
|
||||||
ProfileName: &activeProf.Name,
|
ProfileName: &profileID,
|
||||||
Username: &username,
|
Username: &username,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("call service up method: %v", err)
|
return fmt.Errorf("call service up method: %v", err)
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ func TestUpDaemon(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sm := profilemanager.ServiceManager{}
|
sm := profilemanager.ServiceManager{}
|
||||||
err = sm.AddProfile("test1", currUser.Username)
|
created, err := sm.AddProfile("test1", currUser.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to add profile: %v", err)
|
t.Fatalf("failed to add profile: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||||
Name: "test1",
|
ID: created.ID,
|
||||||
Username: currUser.Username,
|
Username: currUser.Username,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ var (
|
|||||||
Short: "Print the NetBird's client application version",
|
Short: "Print the NetBird's client application version",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
cmd.SetOut(cmd.OutOrStdout())
|
cmd.SetOut(cmd.OutOrStdout())
|
||||||
cmd.Println(version.NetbirdVersion())
|
out := version.NetbirdVersion()
|
||||||
|
if version.IsDevelopmentVersion(out) {
|
||||||
|
if commit := version.NetbirdCommit(); commit != "" {
|
||||||
|
out += "-" + commit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.Println(out)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||||
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
@@ -84,6 +85,12 @@ type Options struct {
|
|||||||
DisableIPv6 bool
|
DisableIPv6 bool
|
||||||
// BlockInbound blocks all inbound connections from peers
|
// BlockInbound blocks all inbound connections from peers
|
||||||
BlockInbound bool
|
BlockInbound bool
|
||||||
|
// BlockLANAccess blocks the embedded peer from reaching the host's
|
||||||
|
// LAN (RFC 1918, link-local, loopback) when it's used as a routing
|
||||||
|
// peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
|
||||||
|
// when the embedded client must never act as a stepping stone into
|
||||||
|
// the host's local network (e.g. the proxy's overlay peer).
|
||||||
|
BlockLANAccess bool
|
||||||
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
||||||
WireguardPort *int
|
WireguardPort *int
|
||||||
// MTU is the MTU for the tunnel interface.
|
// MTU is the MTU for the tunnel interface.
|
||||||
@@ -94,6 +101,26 @@ type Options struct {
|
|||||||
MTU *uint16
|
MTU *uint16
|
||||||
// DNSLabels defines additional DNS labels configured in the peer.
|
// DNSLabels defines additional DNS labels configured in the peer.
|
||||||
DNSLabels []string
|
DNSLabels []string
|
||||||
|
// Performance configures the tunnel's buffer pool cap and batch size.
|
||||||
|
Performance Performance
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance configures the embedded client's tunnel memory/throughput knobs.
|
||||||
|
//
|
||||||
|
// These settings are process-global: any non-nil field also becomes the
|
||||||
|
// default for Clients constructed by later embed.New calls in the same
|
||||||
|
// process. Nil fields are ignored.
|
||||||
|
type Performance struct {
|
||||||
|
// PreallocatedBuffersPerPool caps the per-tunnel buffer pool. Zero
|
||||||
|
// leaves the pool unbounded. Lower values trade throughput for a
|
||||||
|
// tighter memory ceiling. May also be changed on a running Client via
|
||||||
|
// Client.SetPerformance, provided this field was nonzero at construction.
|
||||||
|
PreallocatedBuffersPerPool *uint32
|
||||||
|
// MaxBatchSize overrides the number of packets the tunnel reads or
|
||||||
|
// writes per syscall, which also bounds eager buffer allocation per
|
||||||
|
// worker. Zero uses the platform default. Applied at construction
|
||||||
|
// only; ignored by Client.SetPerformance.
|
||||||
|
MaxBatchSize *uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCredentials checks that exactly one credential type is provided
|
// validateCredentials checks that exactly one credential type is provided
|
||||||
@@ -175,6 +202,7 @@ func New(opts Options) (*Client, error) {
|
|||||||
DisableClientRoutes: &opts.DisableClientRoutes,
|
DisableClientRoutes: &opts.DisableClientRoutes,
|
||||||
DisableIPv6: &opts.DisableIPv6,
|
DisableIPv6: &opts.DisableIPv6,
|
||||||
BlockInbound: &opts.BlockInbound,
|
BlockInbound: &opts.BlockInbound,
|
||||||
|
BlockLANAccess: &opts.BlockLANAccess,
|
||||||
WireguardPort: opts.WireguardPort,
|
WireguardPort: opts.WireguardPort,
|
||||||
MTU: opts.MTU,
|
MTU: opts.MTU,
|
||||||
DNSLabels: parsedLabels,
|
DNSLabels: parsedLabels,
|
||||||
@@ -192,6 +220,13 @@ func New(opts Options) (*Client, error) {
|
|||||||
config.PrivateKey = opts.PrivateKey
|
config.PrivateKey = opts.PrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.Performance.PreallocatedBuffersPerPool != nil {
|
||||||
|
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
|
||||||
|
}
|
||||||
|
if opts.Performance.MaxBatchSize != nil {
|
||||||
|
wgdevice.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize)
|
||||||
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
deviceName: opts.DeviceName,
|
deviceName: opts.DeviceName,
|
||||||
setupKey: opts.SetupKey,
|
setupKey: opts.SetupKey,
|
||||||
@@ -244,6 +279,12 @@ func (c *Client) Start(startCtx context.Context) error {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case <-startCtx.Done():
|
case <-startCtx.Done():
|
||||||
|
// ConnectClient.Stop now cancels its own run context and waits for the
|
||||||
|
// run loop to tear the engine down, so this cancel() is no longer
|
||||||
|
// required to break the deadlock and could be removed. It is kept as a
|
||||||
|
// defensive belt-and-suspenders: cancelling the parent context first
|
||||||
|
// guarantees the run loop is unblocked even if Stop's contract regresses.
|
||||||
|
cancel()
|
||||||
if stopErr := client.Stop(); stopErr != nil {
|
if stopErr := client.Stop(); stopErr != nil {
|
||||||
return fmt.Errorf("stop error after context done. Stop error: %w. Context done: %w", stopErr, startCtx.Err())
|
return fmt.Errorf("stop error after context done. Stop error: %w. Context done: %w", stopErr, startCtx.Err())
|
||||||
}
|
}
|
||||||
@@ -405,6 +446,21 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IdentityForIP looks up a remote peer by its tunnel IP using the
|
||||||
|
// embedded client's status recorder. Returns the peer's WireGuard public
|
||||||
|
// key and FQDN. ok=false means the IP doesn't belong to an active peer
|
||||||
|
// — offline roster peers are treated as unknown, same as foreign IPs.
|
||||||
|
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
|
||||||
|
if !ip.IsValid() || c.recorder == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
state, found := c.recorder.PeerStateByIP(ip.String())
|
||||||
|
if !found {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return state.PubKey, state.FQDN, true
|
||||||
|
}
|
||||||
|
|
||||||
// Status returns the current status of the client.
|
// Status returns the current status of the client.
|
||||||
func (c *Client) Status() (peer.FullStatus, error) {
|
func (c *Client) Status() (peer.FullStatus, error) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -473,6 +529,25 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
|
|||||||
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
|
||||||
|
// takes effect, and only when it was nonzero at construction;
|
||||||
|
// MaxBatchSize is construction-only and returns an error if set here.
|
||||||
|
//
|
||||||
|
// Returns ErrClientNotStarted / ErrEngineNotStarted if the Client is not
|
||||||
|
// running yet.
|
||||||
|
func (c *Client) SetPerformance(t Performance) error {
|
||||||
|
if t.MaxBatchSize != nil {
|
||||||
|
return errors.New("MaxBatchSize is construction-only and cannot be changed at runtime")
|
||||||
|
}
|
||||||
|
engine, err := c.getEngine()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return engine.SetPerformance(internal.Performance{
|
||||||
|
PreallocatedBuffersPerPool: t.PreallocatedBuffersPerPool,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// StartCapture begins capturing packets on this client's tunnel device.
|
// StartCapture begins capturing packets on this client's tunnel device.
|
||||||
// Only one capture can be active at a time; starting a new one stops the previous.
|
// Only one capture can be active at a time; starting a new one stops the previous.
|
||||||
// Call StopCapture (or CaptureSession.Stop) to end it.
|
// Call StopCapture (or CaptureSession.Stop) to end it.
|
||||||
|
|||||||
168
client/embed/embed_test.go
Normal file
168
client/embed/embed_test.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package embed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
|
"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/modules/peers"
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
|
||||||
|
"github.com/netbirdio/netbird/management/internals/server/config"
|
||||||
|
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||||
|
mgmt "github.com/netbirdio/netbird/management/server"
|
||||||
|
"github.com/netbirdio/netbird/management/server/activity"
|
||||||
|
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||||
|
"github.com/netbirdio/netbird/management/server/groups"
|
||||||
|
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||||
|
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||||
|
"github.com/netbirdio/netbird/management/server/job"
|
||||||
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
|
"github.com/netbirdio/netbird/management/server/settings"
|
||||||
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
|
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||||
|
"github.com/netbirdio/netbird/management/server/types"
|
||||||
|
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
"github.com/netbirdio/netbird/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testSetupKey = "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
|
||||||
|
|
||||||
|
// TestClientStartTimeoutRollback reproduces a deadlock between Engine.Start and
|
||||||
|
// Engine.Stop. The signal endpoint accepts gRPC connections but never serves the
|
||||||
|
// SignalExchange service, so Engine.Start parks in WaitStreamConnected while
|
||||||
|
// holding the engine mutex. When the Start context expires, the rollback path
|
||||||
|
// calls ConnectClient.Stop, which must not block forever acquiring that mutex.
|
||||||
|
func TestClientStartTimeoutRollback(t *testing.T) {
|
||||||
|
signalAddr := startBlackholeSignal(t)
|
||||||
|
mgmAddr := startManagement(t, signalAddr)
|
||||||
|
|
||||||
|
wgPort := 0
|
||||||
|
client, err := New(Options{
|
||||||
|
DeviceName: "embed-rollback-test",
|
||||||
|
SetupKey: testSetupKey,
|
||||||
|
ManagementURL: "http://" + mgmAddr,
|
||||||
|
WireguardPort: &wgPort,
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "embed client creation must succeed")
|
||||||
|
|
||||||
|
startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
startErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
startErr <- client.Start(startCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-startErr:
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
case <-time.After(60 * time.Second):
|
||||||
|
t.Fatal("client.Start did not return after its context expired: Engine.Stop deadlocked against Engine.Start waiting for the signal stream")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startBlackholeSignal starts a gRPC server without the SignalExchange service
|
||||||
|
// registered. Connections succeed, but the signal stream can never be
|
||||||
|
// established, which keeps Engine.Start parked in WaitStreamConnected.
|
||||||
|
func startBlackholeSignal(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
lis, err := net.Listen("tcp", "localhost:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s := grpc.NewServer()
|
||||||
|
go func() {
|
||||||
|
if err := s.Serve(lis); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
t.Cleanup(s.Stop)
|
||||||
|
|
||||||
|
return lis.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startManagement(t *testing.T, signalAddr string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Stuns: []*config.Host{},
|
||||||
|
TURNConfig: &config.TURNConfig{},
|
||||||
|
Relay: &config.Relay{
|
||||||
|
Addresses: []string{"127.0.0.1:1234"},
|
||||||
|
CredentialsTTL: util.Duration{Duration: time.Hour},
|
||||||
|
Secret: "222222222222222222",
|
||||||
|
},
|
||||||
|
Signal: &config.Host{
|
||||||
|
Proto: "http",
|
||||||
|
URI: signalAddr,
|
||||||
|
},
|
||||||
|
Datadir: t.TempDir(),
|
||||||
|
HttpConfig: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
lis, err := net.Listen("tcp", "localhost:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s := grpc.NewServer()
|
||||||
|
|
||||||
|
testStore, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", cfg.Datadir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(cleanUp)
|
||||||
|
|
||||||
|
eventStore := &activity.InMemoryEventStore{}
|
||||||
|
|
||||||
|
permissionsManager := permissions.NewManager(testStore)
|
||||||
|
peersManager := peers.NewManager(testStore, permissionsManager)
|
||||||
|
jobManager := job.NewJobManager(nil, testStore, peersManager)
|
||||||
|
|
||||||
|
cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
iv, err := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
||||||
|
require.NoError(t, err)
|
||||||
|
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
t.Cleanup(ctrl.Finish)
|
||||||
|
settingsMockManager := settings.NewMockManager(ctrl)
|
||||||
|
settingsMockManager.EXPECT().
|
||||||
|
GetSettings(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
Return(&types.Settings{}, nil).
|
||||||
|
AnyTimes()
|
||||||
|
settingsMockManager.EXPECT().
|
||||||
|
GetExtraSettings(gomock.Any(), gomock.Any()).
|
||||||
|
Return(&types.ExtraSettings{}, nil).
|
||||||
|
AnyTimes()
|
||||||
|
|
||||||
|
groupsManager := groups.NewManagerMock()
|
||||||
|
|
||||||
|
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||||
|
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
|
||||||
|
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg)
|
||||||
|
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mgmtServer, err := nbgrpc.NewServer(cfg, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := s.Serve(lis); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
t.Cleanup(s.Stop)
|
||||||
|
|
||||||
|
return lis.Addr().String()
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package iptables
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
@@ -421,12 +422,17 @@ func (m *aclManager) updateState() {
|
|||||||
currentState.Lock()
|
currentState.Lock()
|
||||||
defer currentState.Unlock()
|
defer currentState.Unlock()
|
||||||
|
|
||||||
|
// Clone the maps so the persisted state holds a private snapshot. The
|
||||||
|
// live maps keep being mutated by subsequent rule operations while the
|
||||||
|
// state manager marshals the state from its periodic-save goroutine.
|
||||||
|
// Sharing them by reference races the two and aborts the process with a
|
||||||
|
// concurrent map iteration and write.
|
||||||
if m.v6 {
|
if m.v6 {
|
||||||
currentState.ACLEntries6 = m.entries
|
currentState.ACLEntries6 = maps.Clone(m.entries)
|
||||||
currentState.ACLIPsetStore6 = m.ipsetStore
|
currentState.ACLIPsetStore6 = m.ipsetStore.clone()
|
||||||
} else {
|
} else {
|
||||||
currentState.ACLEntries = m.entries
|
currentState.ACLEntries = maps.Clone(m.entries)
|
||||||
currentState.ACLIPsetStore = m.ipsetStore
|
currentState.ACLIPsetStore = m.ipsetStore.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.stateManager.UpdateState(currentState); err != nil {
|
if err := m.stateManager.UpdateState(currentState); err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package iptables
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -749,11 +750,17 @@ func (r *router) updateState() {
|
|||||||
currentState.Lock()
|
currentState.Lock()
|
||||||
defer currentState.Unlock()
|
defer currentState.Unlock()
|
||||||
|
|
||||||
|
// Clone the rule map so the persisted state holds a private snapshot. The
|
||||||
|
// live map keeps being mutated by subsequent rule operations while the
|
||||||
|
// state manager marshals the state from its periodic-save goroutine.
|
||||||
|
// Sharing it by reference races the two and aborts the process with a
|
||||||
|
// concurrent map iteration and write. The ipset counter guards itself
|
||||||
|
// during marshaling, so it can be shared directly.
|
||||||
if r.v6 {
|
if r.v6 {
|
||||||
currentState.RouteRules6 = r.rules
|
currentState.RouteRules6 = maps.Clone(r.rules)
|
||||||
currentState.RouteIPsetCounter6 = r.ipsetCounter
|
currentState.RouteIPsetCounter6 = r.ipsetCounter
|
||||||
} else {
|
} else {
|
||||||
currentState.RouteRules = r.rules
|
currentState.RouteRules = maps.Clone(r.rules)
|
||||||
currentState.RouteIPsetCounter = r.ipsetCounter
|
currentState.RouteIPsetCounter = r.ipsetCounter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package iptables
|
package iptables
|
||||||
|
|
||||||
import "encoding/json"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"maps"
|
||||||
|
)
|
||||||
|
|
||||||
type ipList struct {
|
type ipList struct {
|
||||||
ips map[string]struct{}
|
ips map[string]struct{}
|
||||||
@@ -19,6 +22,14 @@ func (s *ipList) addIP(ip string) {
|
|||||||
s.ips[ip] = struct{}{}
|
s.ips[ip] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clone returns a deep copy of the ipList with its own ips map.
|
||||||
|
func (s *ipList) clone() *ipList {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ipList{ips: maps.Clone(s.ips)}
|
||||||
|
}
|
||||||
|
|
||||||
// MarshalJSON implements json.Marshaler
|
// MarshalJSON implements json.Marshaler
|
||||||
func (s *ipList) MarshalJSON() ([]byte, error) {
|
func (s *ipList) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
@@ -55,6 +66,19 @@ func newIpsetStore() *ipsetStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clone returns a deep copy of the ipsetStore with its own ipsets map and
|
||||||
|
// independent ipList entries.
|
||||||
|
func (s *ipsetStore) clone() *ipsetStore {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))}
|
||||||
|
for name, list := range s.ipsets {
|
||||||
|
cloned.ipsets[name] = list.clone()
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) {
|
func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) {
|
||||||
r, ok := s.ipsets[ipsetName]
|
r, ok := s.ipsets[ipsetName]
|
||||||
return r, ok
|
return r, ok
|
||||||
|
|||||||
@@ -362,6 +362,10 @@ func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pc := f.endpoint.capture.Load(); pc != nil {
|
||||||
|
(*pc).Offer(fullPacket, true)
|
||||||
|
}
|
||||||
|
|
||||||
return len(fullPacket)
|
return len(fullPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ type ICEBind struct {
|
|||||||
*wgConn.StdNetBind
|
*wgConn.StdNetBind
|
||||||
|
|
||||||
transportNet transport.Net
|
transportNet transport.Net
|
||||||
filterFn udpmux.FilterFn
|
|
||||||
address wgaddr.Address
|
address wgaddr.Address
|
||||||
mtu uint16
|
mtu uint16
|
||||||
|
|
||||||
@@ -61,12 +60,11 @@ type ICEBind struct {
|
|||||||
ipv6Conn *net.UDPConn
|
ipv6Conn *net.UDPConn
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewICEBind(transportNet transport.Net, filterFn udpmux.FilterFn, address wgaddr.Address, mtu uint16) *ICEBind {
|
func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) *ICEBind {
|
||||||
b, _ := wgConn.NewStdNetBind().(*wgConn.StdNetBind)
|
b, _ := wgConn.NewStdNetBind().(*wgConn.StdNetBind)
|
||||||
ib := &ICEBind{
|
ib := &ICEBind{
|
||||||
StdNetBind: b,
|
StdNetBind: b,
|
||||||
transportNet: transportNet,
|
transportNet: transportNet,
|
||||||
filterFn: filterFn,
|
|
||||||
address: address,
|
address: address,
|
||||||
mtu: mtu,
|
mtu: mtu,
|
||||||
endpoints: make(map[netip.Addr]net.Conn),
|
endpoints: make(map[netip.Addr]net.Conn),
|
||||||
@@ -265,7 +263,6 @@ func (s *ICEBind) createOrUpdateMux() {
|
|||||||
udpmux.UniversalUDPMuxParams{
|
udpmux.UniversalUDPMuxParams{
|
||||||
UDPConn: muxConn,
|
UDPConn: muxConn,
|
||||||
Net: s.transportNet,
|
Net: s.transportNet,
|
||||||
FilterFn: s.filterFn,
|
|
||||||
WGAddress: s.address,
|
WGAddress: s.address,
|
||||||
MTU: s.mtu,
|
MTU: s.mtu,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ func setupICEBind(t *testing.T) *ICEBind {
|
|||||||
IP: netip.MustParseAddr("100.64.0.1"),
|
IP: netip.MustParseAddr("100.64.0.1"),
|
||||||
Network: netip.MustParsePrefix("100.64.0.0/10"),
|
Network: netip.MustParsePrefix("100.64.0.0/10"),
|
||||||
}
|
}
|
||||||
return NewICEBind(transportNet, nil, address, 1280)
|
return NewICEBind(transportNet, address, 1280)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createDualStackConns(t *testing.T) (*net.UDPConn, *net.UDPConn) {
|
func createDualStackConns(t *testing.T) (*net.UDPConn, *net.UDPConn) {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package device
|
package device
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.zx2c4.com/wireguard/tun"
|
"golang.zx2c4.com/wireguard/tun"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,10 +44,13 @@ type PacketCapture interface {
|
|||||||
type FilteredDevice struct {
|
type FilteredDevice struct {
|
||||||
tun.Device
|
tun.Device
|
||||||
|
|
||||||
filter PacketFilter
|
filter PacketFilter
|
||||||
capture atomic.Pointer[PacketCapture]
|
capture atomic.Pointer[PacketCapture]
|
||||||
mutex sync.RWMutex
|
// panicHandler is invoked after a panic in the underlying device is
|
||||||
closeOnce sync.Once
|
// recovered in Read or Write.
|
||||||
|
panicHandler atomic.Pointer[func()]
|
||||||
|
mutex sync.RWMutex
|
||||||
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// newDeviceFilter constructor function
|
// newDeviceFilter constructor function
|
||||||
@@ -70,7 +76,7 @@ func (d *FilteredDevice) Close() error {
|
|||||||
|
|
||||||
// Read wraps read method with filtering feature
|
// Read wraps read method with filtering feature
|
||||||
func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) {
|
func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) {
|
||||||
if n, err = d.Device.Read(bufs, sizes, offset); err != nil {
|
if n, err = d.deviceRead(bufs, sizes, offset); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +118,7 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
|
|||||||
d.mutex.RUnlock()
|
d.mutex.RUnlock()
|
||||||
|
|
||||||
if filter == nil {
|
if filter == nil {
|
||||||
return d.Device.Write(bufs, offset)
|
return d.deviceWrite(bufs, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredBufs := make([][]byte, 0, len(bufs))
|
filteredBufs := make([][]byte, 0, len(bufs))
|
||||||
@@ -125,9 +131,44 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
n, err := d.Device.Write(filteredBufs, offset)
|
n, err := d.deviceWrite(filteredBufs, offset)
|
||||||
n += dropped
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
|
}
|
||||||
|
return n + dropped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceRead calls the underlying device Read, recovering from panics in the
|
||||||
|
// wintun read path and converting them into errors.
|
||||||
|
func (d *FilteredDevice) deviceRead(bufs [][]byte, sizes []int, offset int) (n int, err error) {
|
||||||
|
defer d.recoverFromPanic("read", &n, &err)
|
||||||
|
return d.Device.Read(bufs, sizes, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceWrite calls the underlying device Write, recovering from panics in the
|
||||||
|
// wintun write path and converting them into errors.
|
||||||
|
func (d *FilteredDevice) deviceWrite(bufs [][]byte, offset int) (n int, err error) {
|
||||||
|
defer d.recoverFromPanic("write", &n, &err)
|
||||||
|
return d.Device.Write(bufs, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverFromPanic converts a panic in the underlying device into a regular
|
||||||
|
// error and invokes the registered panic handler. The wintun read path is
|
||||||
|
// known to panic on zero-length packets that third-party filter drivers can
|
||||||
|
// place in the ring.
|
||||||
|
func (d *FilteredDevice) recoverFromPanic(op string, n *int, err *error) {
|
||||||
|
r := recover()
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("recovered panic in tun device %s: %v\n%s", op, r, debug.Stack())
|
||||||
|
*n = 0
|
||||||
|
*err = fmt.Errorf("tun device %s panic: %v", op, r)
|
||||||
|
|
||||||
|
if handler := d.panicHandler.Load(); handler != nil {
|
||||||
|
(*handler)()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFilter sets packet filter to device
|
// SetFilter sets packet filter to device
|
||||||
@@ -137,6 +178,17 @@ func (d *FilteredDevice) SetFilter(filter PacketFilter) {
|
|||||||
d.mutex.Unlock()
|
d.mutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPanicHandler registers a handler invoked after a recovered panic in Read
|
||||||
|
// or Write. The device is unusable after such a panic; the handler should
|
||||||
|
// trigger recreation of the interface. Pass nil to remove.
|
||||||
|
func (d *FilteredDevice) SetPanicHandler(handler func()) {
|
||||||
|
if handler == nil {
|
||||||
|
d.panicHandler.Store(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.panicHandler.Store(&handler)
|
||||||
|
}
|
||||||
|
|
||||||
// SetCapture sets or clears the packet capture sink. Pass nil to disable.
|
// SetCapture sets or clears the packet capture sink. Pass nil to disable.
|
||||||
// Uses atomic store so the hot path (Read/Write) is a single pointer load
|
// Uses atomic store so the hot path (Read/Write) is a single pointer load
|
||||||
// with no locking overhead when capture is off.
|
// with no locking overhead when capture is off.
|
||||||
|
|||||||
@@ -221,3 +221,60 @@ func TestDeviceWrapperRead(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeviceWrapperReadPanic(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
|
tun := mocks.NewMockDevice(ctrl)
|
||||||
|
tun.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||||
|
DoAndReturn(func(bufs [][]byte, sizes []int, offset int) (int, error) {
|
||||||
|
// Reproduce the wintun zero-length packet panic (index out of range).
|
||||||
|
packet := make([]byte, 0)
|
||||||
|
return int(packet[0]), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := newDeviceFilter(tun)
|
||||||
|
|
||||||
|
handlerCalled := false
|
||||||
|
wrapped.SetPanicHandler(func() { handlerCalled = true })
|
||||||
|
|
||||||
|
n, err := wrapped.Read([][]byte{{}}, []int{0}, 0)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error from recovered panic, got nil")
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("expected n=0, got %d", n)
|
||||||
|
}
|
||||||
|
if !handlerCalled {
|
||||||
|
t.Errorf("expected panic handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceWrapperWritePanic(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
defer ctrl.Finish()
|
||||||
|
|
||||||
|
tun := mocks.NewMockDevice(ctrl)
|
||||||
|
tun.EXPECT().Write(gomock.Any(), gomock.Any()).
|
||||||
|
DoAndReturn(func(bufs [][]byte, offset int) (int, error) {
|
||||||
|
packet := make([]byte, 0)
|
||||||
|
return int(packet[0]), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapped := newDeviceFilter(tun)
|
||||||
|
|
||||||
|
handlerCalled := false
|
||||||
|
wrapped.SetPanicHandler(func() { handlerCalled = true })
|
||||||
|
|
||||||
|
n, err := wrapped.Write([][]byte{{0x45, 0x00}}, 0)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error from recovered panic, got nil")
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("expected n=0, got %d", n)
|
||||||
|
}
|
||||||
|
if !handlerCalled {
|
||||||
|
t.Errorf("expected panic handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ type TunKernelDevice struct {
|
|||||||
link *wgLink
|
link *wgLink
|
||||||
udpMuxConn net.PacketConn
|
udpMuxConn net.PacketConn
|
||||||
udpMux *udpmux.UniversalUDPMuxDefault
|
udpMux *udpmux.UniversalUDPMuxDefault
|
||||||
|
|
||||||
filterFn udpmux.FilterFn
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKernelDevice(name string, address wgaddr.Address, wgPort int, key string, mtu uint16, transportNet transport.Net) *TunKernelDevice {
|
func NewKernelDevice(name string, address wgaddr.Address, wgPort int, key string, mtu uint16, transportNet transport.Net) *TunKernelDevice {
|
||||||
@@ -104,7 +102,6 @@ func (t *TunKernelDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
|||||||
bindParams := udpmux.UniversalUDPMuxParams{
|
bindParams := udpmux.UniversalUDPMuxParams{
|
||||||
UDPConn: nbnet.WrapPacketConn(rawSock),
|
UDPConn: nbnet.WrapPacketConn(rawSock),
|
||||||
Net: t.transportNet,
|
Net: t.transportNet,
|
||||||
FilterFn: t.filterFn,
|
|
||||||
WGAddress: t.address,
|
WGAddress: t.address,
|
||||||
MTU: t.mtu,
|
MTU: t.mtu,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ type WGIFaceOpts struct {
|
|||||||
MTU uint16
|
MTU uint16
|
||||||
MobileArgs *device.MobileIFaceArguments
|
MobileArgs *device.MobileIFaceArguments
|
||||||
TransportNet transport.Net
|
TransportNet transport.Net
|
||||||
FilterFn udpmux.FilterFn
|
|
||||||
DisableDNS bool
|
DisableDNS bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
// NewWGIFace Creates a new WireGuard interface instance
|
// NewWGIFace Creates a new WireGuard interface instance
|
||||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
|
||||||
|
|
||||||
var tun WGTunDevice
|
var tun WGTunDevice
|
||||||
if netstack.IsEnabled() {
|
if netstack.IsEnabled() {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
// NewWGIFace Creates a new WireGuard interface instance
|
// NewWGIFace Creates a new WireGuard interface instance
|
||||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
|
||||||
|
|
||||||
if netstack.IsEnabled() {
|
if netstack.IsEnabled() {
|
||||||
wgIFace := &WGIface{
|
wgIFace := &WGIface{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
// NewWGIFace Creates a new WireGuard interface instance
|
// NewWGIFace Creates a new WireGuard interface instance
|
||||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
|
||||||
|
|
||||||
wgIFace := &WGIface{
|
wgIFace := &WGIface{
|
||||||
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunFd),
|
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunFd),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// NewWGIFace Creates a new WireGuard interface instance
|
// NewWGIFace Creates a new WireGuard interface instance
|
||||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
||||||
if netstack.IsEnabled() {
|
if netstack.IsEnabled() {
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
|
||||||
return &WGIface{
|
return &WGIface{
|
||||||
tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()),
|
tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()),
|
||||||
userspaceBind: true,
|
userspaceBind: true,
|
||||||
@@ -30,7 +30,7 @@ func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if device.ModuleTunIsLoaded() {
|
if device.ModuleTunIsLoaded() {
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
|
||||||
return &WGIface{
|
return &WGIface{
|
||||||
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind),
|
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind),
|
||||||
userspaceBind: true,
|
userspaceBind: true,
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -22,10 +20,6 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FilterFn is a function that filters out candidates based on the address.
|
|
||||||
// If it returns true, the address is to be filtered. It also returns the prefix of matching route.
|
|
||||||
type FilterFn func(address netip.Addr) (bool, netip.Prefix, error)
|
|
||||||
|
|
||||||
// UniversalUDPMuxDefault handles STUN and TURN servers packets by wrapping the original UDPConn
|
// UniversalUDPMuxDefault handles STUN and TURN servers packets by wrapping the original UDPConn
|
||||||
// It then passes packets to the UDPMux that does the actual connection muxing.
|
// It then passes packets to the UDPMux that does the actual connection muxing.
|
||||||
type UniversalUDPMuxDefault struct {
|
type UniversalUDPMuxDefault struct {
|
||||||
@@ -43,7 +37,6 @@ type UniversalUDPMuxParams struct {
|
|||||||
UDPConn net.PacketConn
|
UDPConn net.PacketConn
|
||||||
XORMappedAddrCacheTTL time.Duration
|
XORMappedAddrCacheTTL time.Duration
|
||||||
Net transport.Net
|
Net transport.Net
|
||||||
FilterFn FilterFn
|
|
||||||
WGAddress wgaddr.Address
|
WGAddress wgaddr.Address
|
||||||
MTU uint16
|
MTU uint16
|
||||||
}
|
}
|
||||||
@@ -68,7 +61,6 @@ func NewUniversalUDPMuxDefault(params UniversalUDPMuxParams) *UniversalUDPMuxDef
|
|||||||
PacketConn: params.UDPConn,
|
PacketConn: params.UDPConn,
|
||||||
mux: m,
|
mux: m,
|
||||||
logger: params.Logger,
|
logger: params.Logger,
|
||||||
filterFn: params.FilterFn,
|
|
||||||
address: params.WGAddress,
|
address: params.WGAddress,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,15 +107,12 @@ func (m *UniversalUDPMuxDefault) ReadFromConn(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDPConn is a wrapper around UDPMux conn that overrides ReadFrom and handles STUN/TURN packets
|
// UDPConn is a wrapper around UDPMux conn that overrides WriteTo to drop packets destined for the overlay subnet.
|
||||||
type UDPConn struct {
|
type UDPConn struct {
|
||||||
net.PacketConn
|
net.PacketConn
|
||||||
mux *UniversalUDPMuxDefault
|
mux *UniversalUDPMuxDefault
|
||||||
logger logging.LeveledLogger
|
logger logging.LeveledLogger
|
||||||
filterFn FilterFn
|
address wgaddr.Address
|
||||||
// TODO: reset cache on route changes
|
|
||||||
addrCache sync.Map
|
|
||||||
address wgaddr.Address
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPacketConn returns the underlying PacketConn
|
// GetPacketConn returns the underlying PacketConn
|
||||||
@@ -132,65 +121,16 @@ func (u *UDPConn) GetPacketConn() net.PacketConn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *UDPConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
func (u *UDPConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||||
if u.filterFn == nil {
|
udpAddr, ok := addr.(*net.UDPAddr)
|
||||||
|
if !ok {
|
||||||
return u.PacketConn.WriteTo(b, addr)
|
return u.PacketConn.WriteTo(b, addr)
|
||||||
}
|
}
|
||||||
|
dst := udpAddr.AddrPort().Addr().Unmap()
|
||||||
if isRouted, found := u.addrCache.Load(addr.String()); found {
|
if (u.address.Network.IsValid() && u.address.Network.Contains(dst)) || (u.address.IPv6Net.IsValid() && u.address.IPv6Net.Contains(dst)) {
|
||||||
return u.handleCachedAddress(isRouted.(bool), b, addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return u.handleUncachedAddress(b, addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UDPConn) handleCachedAddress(isRouted bool, b []byte, addr net.Addr) (int, error) {
|
|
||||||
if isRouted {
|
|
||||||
return 0, fmt.Errorf("address %s is part of a routed network, refusing to write", addr)
|
|
||||||
}
|
|
||||||
return u.PacketConn.WriteTo(b, addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UDPConn) handleUncachedAddress(b []byte, addr net.Addr) (int, error) {
|
|
||||||
if err := u.performFilterCheck(addr); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return u.PacketConn.WriteTo(b, addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UDPConn) performFilterCheck(addr net.Addr) error {
|
|
||||||
host, err := getHostFromAddr(addr)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to get host from address %s: %v", addr, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
a, err := netip.ParseAddr(host)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to parse address %s: %v", addr, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.address.Network.Contains(a) {
|
|
||||||
log.Warnf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
|
log.Warnf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
|
||||||
return fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
|
return 0, fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
|
||||||
}
|
}
|
||||||
|
return u.PacketConn.WriteTo(b, addr)
|
||||||
if isRouted, prefix, err := u.filterFn(a); err != nil {
|
|
||||||
log.Errorf("Failed to check if address %s is routed: %v", addr, err)
|
|
||||||
} else {
|
|
||||||
u.addrCache.Store(addr.String(), isRouted)
|
|
||||||
if isRouted {
|
|
||||||
// Extra log, as the error only shows up with ICE logging enabled
|
|
||||||
log.Infof("address %s is part of routed network %s, refusing to write", addr, prefix)
|
|
||||||
return fmt.Errorf("address %s is part of routed network %s, refusing to write", addr, prefix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getHostFromAddr(addr net.Addr) (string, error) {
|
|
||||||
host, _, err := net.SplitHostPort(addr.String())
|
|
||||||
return host, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSharedConn returns the shared udp conn
|
// GetSharedConn returns the shared udp conn
|
||||||
@@ -225,6 +165,13 @@ func (m *UniversalUDPMuxDefault) HandleSTUNMessage(msg *stun.Message, addr net.A
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
src := udpAddr.AddrPort().Addr().Unmap()
|
||||||
|
wg := m.params.WGAddress
|
||||||
|
if (wg.Network.IsValid() && wg.Network.Contains(src)) || (wg.IPv6Net.IsValid() && wg.IPv6Net.Contains(src)) {
|
||||||
|
log.Debugf("dropping STUN message from overlay source %s", udpAddr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if m.isXORMappedResponse(msg, udpAddr.String()) {
|
if m.isXORMappedResponse(msg, udpAddr.String()) {
|
||||||
err := m.handleXORMappedResponse(udpAddr, msg)
|
err := m.handleXORMappedResponse(udpAddr, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280)
|
iceBind := bind.NewICEBind(nil, wgAddress, 1280)
|
||||||
endpointAddress := &net.UDPAddr{
|
endpointAddress := &net.UDPAddr{
|
||||||
IP: net.IPv4(10, 0, 0, 1),
|
IP: net.IPv4(10, 0, 0, 1),
|
||||||
Port: 1234,
|
Port: 1234,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280)
|
iceBind := bind.NewICEBind(nil, wgAddress, 1280)
|
||||||
endpointAddress := &net.UDPAddr{
|
endpointAddress := &net.UDPAddr{
|
||||||
IP: net.IPv4(10, 0, 0, 1),
|
IP: net.IPv4(10, 0, 0, 1),
|
||||||
Port: 1234,
|
Port: 1234,
|
||||||
|
|||||||
@@ -260,23 +260,15 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
|
|||||||
|
|
||||||
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
||||||
|
|
||||||
; Drop Run, App Paths and Uninstall entries left in the 32-bit registry view
|
; Create autostart registry entry based on checkbox
|
||||||
; or HKCU by legacy installers.
|
|
||||||
DetailPrint "Cleaning legacy 32-bit / HKCU entries..."
|
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
|
||||||
SetRegView 32
|
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
|
||||||
DeleteRegKey HKLM "${REG_APP_PATH}"
|
|
||||||
DeleteRegKey HKLM "${UI_REG_APP_PATH}"
|
|
||||||
DeleteRegKey HKLM "${UNINSTALL_PATH}"
|
|
||||||
SetRegView 64
|
|
||||||
|
|
||||||
DetailPrint "Autostart enabled: $AutostartEnabled"
|
DetailPrint "Autostart enabled: $AutostartEnabled"
|
||||||
${If} $AutostartEnabled == "1"
|
${If} $AutostartEnabled == "1"
|
||||||
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
||||||
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
||||||
${Else}
|
${Else}
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||||
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
DetailPrint "Autostart not enabled by user"
|
DetailPrint "Autostart not enabled by user"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
@@ -307,16 +299,11 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
|
|||||||
DetailPrint "Terminating Netbird UI process..."
|
DetailPrint "Terminating Netbird UI process..."
|
||||||
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
||||||
|
|
||||||
; Remove autostart entries from every view a previous installer may have used.
|
; Remove autostart registry entry
|
||||||
DetailPrint "Removing autostart registry entry if exists..."
|
DetailPrint "Removing autostart registry entry if exists..."
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
|
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||||
SetRegView 32
|
|
||||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
|
||||||
DeleteRegKey HKLM "${REG_APP_PATH}"
|
|
||||||
DeleteRegKey HKLM "${UI_REG_APP_PATH}"
|
|
||||||
DeleteRegKey HKLM "${UNINSTALL_PATH}"
|
|
||||||
SetRegView 64
|
|
||||||
|
|
||||||
; Handle data deletion based on checkbox
|
; Handle data deletion based on checkbox
|
||||||
DetailPrint "Checking if user requested data deletion..."
|
DetailPrint "Checking if user requested data deletion..."
|
||||||
|
|||||||
@@ -360,7 +360,13 @@ func isRedirectURLPortUsed(redirectURL string, excludedRanges []excludedPortRang
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%s", port)
|
// FreeBSD 15 disables connecting to INADDR_ANY (0.0.0.0) as a localhost
|
||||||
|
// alias by default, ensure explicit ip for localhost.
|
||||||
|
host := parsedURL.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(host, port)
|
||||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
@@ -53,6 +55,10 @@ var androidRunOverride func(c *ConnectClient, runningChan chan struct{}, logPath
|
|||||||
|
|
||||||
type ConnectClient struct {
|
type ConnectClient struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
runCancel context.CancelFunc
|
||||||
|
runExited chan struct{}
|
||||||
|
runOnce sync.Once
|
||||||
|
runStarted atomic.Bool
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
|
|
||||||
@@ -69,8 +75,14 @@ func NewConnectClient(
|
|||||||
config *profilemanager.Config,
|
config *profilemanager.Config,
|
||||||
statusRecorder *peer.Status,
|
statusRecorder *peer.Status,
|
||||||
) *ConnectClient {
|
) *ConnectClient {
|
||||||
|
// Derive the run context here so Stop owns the cancel that unblocks the run
|
||||||
|
// loop. runCancel is set once at construction, so Stop can call it without
|
||||||
|
// racing the run loop's startup. Callers therefore need not cancel before Stop.
|
||||||
|
runCtx, runCancel := context.WithCancel(ctx)
|
||||||
return &ConnectClient{
|
return &ConnectClient{
|
||||||
ctx: ctx,
|
ctx: runCtx,
|
||||||
|
runCancel: runCancel,
|
||||||
|
runExited: make(chan struct{}),
|
||||||
config: config,
|
config: config,
|
||||||
statusRecorder: statusRecorder,
|
statusRecorder: statusRecorder,
|
||||||
engineMutex: sync.Mutex{},
|
engineMutex: sync.Mutex{},
|
||||||
@@ -117,6 +129,8 @@ func (c *ConnectClient) RunOniOS(
|
|||||||
networkChangeListener listener.NetworkChangeListener,
|
networkChangeListener listener.NetworkChangeListener,
|
||||||
dnsManager dns.IosDnsManager,
|
dnsManager dns.IosDnsManager,
|
||||||
stateFilePath string,
|
stateFilePath string,
|
||||||
|
cacheDir string,
|
||||||
|
logFilePath string,
|
||||||
) error {
|
) error {
|
||||||
// Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension.
|
// Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension.
|
||||||
debug.SetGCPercent(5)
|
debug.SetGCPercent(5)
|
||||||
@@ -126,11 +140,17 @@ func (c *ConnectClient) RunOniOS(
|
|||||||
NetworkChangeListener: networkChangeListener,
|
NetworkChangeListener: networkChangeListener,
|
||||||
DnsManager: dnsManager,
|
DnsManager: dnsManager,
|
||||||
StateFilePath: stateFilePath,
|
StateFilePath: stateFilePath,
|
||||||
|
TempDir: cacheDir,
|
||||||
}
|
}
|
||||||
return c.run(mobileDependency, nil, "")
|
return c.run(mobileDependency, nil, logFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error {
|
func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error {
|
||||||
|
// Mark the loop as started and signal exit on return so Stop can wait for
|
||||||
|
// the loop to finish (and skip the wait if the loop never ran).
|
||||||
|
c.runStarted.Store(true)
|
||||||
|
defer c.runOnce.Do(func() { close(c.runExited) })
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
rec := c.statusRecorder
|
rec := c.statusRecorder
|
||||||
@@ -286,7 +306,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
log.Debug(err)
|
log.Debug(err)
|
||||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
||||||
state.Set(StatusNeedsLogin)
|
state.Set(StatusNeedsLogin)
|
||||||
_ = c.Stop()
|
c.runCancel()
|
||||||
return backoff.Permanent(wrapErr(err)) // unrecoverable error
|
return backoff.Permanent(wrapErr(err)) // unrecoverable error
|
||||||
}
|
}
|
||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
@@ -346,6 +366,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
engineConfig.TempDir = mobileDependency.TempDir
|
engineConfig.TempDir = mobileDependency.TempDir
|
||||||
|
// Leave StateDir empty when there is no state path so a disk-backed
|
||||||
|
// syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
|
||||||
|
if path != "" {
|
||||||
|
engineConfig.StateDir = filepath.Dir(path)
|
||||||
|
}
|
||||||
|
|
||||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
|
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
|
||||||
c.statusRecorder.SetRelayMgr(relayManager)
|
c.statusRecorder.SetRelayMgr(relayManager)
|
||||||
@@ -401,14 +426,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
c.engine = nil
|
c.engine = nil
|
||||||
c.engineMutex.Unlock()
|
c.engineMutex.Unlock()
|
||||||
|
|
||||||
// todo: consider to remove this condition. Is not thread safe.
|
log.Infof("ensuring wg interface is removed, Netbird engine context cancelled")
|
||||||
// We should always call Stop(), but we need to verify that it is idempotent
|
|
||||||
if engine.wgInterface != nil {
|
|
||||||
log.Infof("ensuring %s is removed, Netbird engine context cancelled", engine.wgInterface.Name())
|
|
||||||
|
|
||||||
if err := engine.Stop(); err != nil {
|
if err := engine.Stop(); err != nil {
|
||||||
log.Errorf("Failed to stop engine: %v", err)
|
log.Errorf("Failed to stop engine: %v", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
c.statusRecorder.ClientTeardown()
|
c.statusRecorder.ClientTeardown()
|
||||||
|
|
||||||
@@ -424,12 +445,12 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.statusRecorder.ClientStart()
|
c.statusRecorder.ClientStart()
|
||||||
err = backoff.Retry(operation, backOff)
|
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) {
|
||||||
state.Set(StatusNeedsLogin)
|
state.Set(StatusNeedsLogin)
|
||||||
_ = c.Stop()
|
c.runCancel()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -507,11 +528,9 @@ func (c *ConnectClient) Status() StatusType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ConnectClient) Stop() error {
|
func (c *ConnectClient) Stop() error {
|
||||||
engine := c.Engine()
|
c.runCancel()
|
||||||
if engine != nil {
|
if c.runStarted.Load() {
|
||||||
if err := engine.Stop(); err != nil {
|
<-c.runExited
|
||||||
return fmt.Errorf("stop engine: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,10 +250,13 @@ type BundleGenerator struct {
|
|||||||
syncResponse *mgmProto.SyncResponse
|
syncResponse *mgmProto.SyncResponse
|
||||||
logPath string
|
logPath string
|
||||||
tempDir string
|
tempDir string
|
||||||
|
statePath string
|
||||||
cpuProfile []byte
|
cpuProfile []byte
|
||||||
capturePath string
|
capturePath string
|
||||||
refreshStatus func() // Optional callback to refresh status before bundle generation
|
refreshStatus func() // Optional callback to refresh status before bundle generation
|
||||||
clientMetrics MetricsExporter
|
clientMetrics MetricsExporter
|
||||||
|
daemonVersion string
|
||||||
|
cliVersion string
|
||||||
|
|
||||||
anonymize bool
|
anonymize bool
|
||||||
includeSystemInfo bool
|
includeSystemInfo bool
|
||||||
@@ -274,10 +277,13 @@ type GeneratorDependencies struct {
|
|||||||
SyncResponse *mgmProto.SyncResponse
|
SyncResponse *mgmProto.SyncResponse
|
||||||
LogPath string
|
LogPath string
|
||||||
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
|
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
|
||||||
|
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
|
||||||
CPUProfile []byte
|
CPUProfile []byte
|
||||||
CapturePath string
|
CapturePath string
|
||||||
RefreshStatus func()
|
RefreshStatus func()
|
||||||
ClientMetrics MetricsExporter
|
ClientMetrics MetricsExporter
|
||||||
|
DaemonVersion string
|
||||||
|
CliVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
|
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
|
||||||
@@ -295,10 +301,13 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
|||||||
syncResponse: deps.SyncResponse,
|
syncResponse: deps.SyncResponse,
|
||||||
logPath: deps.LogPath,
|
logPath: deps.LogPath,
|
||||||
tempDir: deps.TempDir,
|
tempDir: deps.TempDir,
|
||||||
|
statePath: deps.StatePath,
|
||||||
cpuProfile: deps.CPUProfile,
|
cpuProfile: deps.CPUProfile,
|
||||||
capturePath: deps.CapturePath,
|
capturePath: deps.CapturePath,
|
||||||
refreshStatus: deps.RefreshStatus,
|
refreshStatus: deps.RefreshStatus,
|
||||||
clientMetrics: deps.ClientMetrics,
|
clientMetrics: deps.ClientMetrics,
|
||||||
|
daemonVersion: deps.DaemonVersion,
|
||||||
|
cliVersion: deps.CliVersion,
|
||||||
|
|
||||||
anonymize: cfg.Anonymize,
|
anonymize: cfg.Anonymize,
|
||||||
includeSystemInfo: cfg.IncludeSystemInfo,
|
includeSystemInfo: cfg.IncludeSystemInfo,
|
||||||
@@ -459,9 +468,11 @@ func (g *BundleGenerator) addStatus() error {
|
|||||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||||
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
|
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
|
||||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||||
Anonymize: g.anonymize,
|
Anonymize: g.anonymize,
|
||||||
ProfileName: profName,
|
ProfileName: profName,
|
||||||
|
DaemonVersion: g.daemonVersion,
|
||||||
})
|
})
|
||||||
|
overview.CliVersion = g.cliVersion
|
||||||
statusOutput := overview.FullDetailSummary()
|
statusOutput := overview.FullDetailSummary()
|
||||||
|
|
||||||
statusReader := strings.NewReader(statusOutput)
|
statusReader := strings.NewReader(statusOutput)
|
||||||
@@ -508,6 +519,14 @@ func (g *BundleGenerator) addConfig() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surface the set of MDM-enforced keys so a support engineer reading
|
||||||
|
// the bundle can tell which field values are user-set vs MDM-overridden.
|
||||||
|
// Same semantics as the mDMManagedFields list returned by the
|
||||||
|
// GetConfig RPC consumed by `netbird debug config`.
|
||||||
|
if managed := g.internalConfig.Policy().ManagedKeys(); len(managed) > 0 {
|
||||||
|
configContent.WriteString(fmt.Sprintf("MDMManagedFields: %v\n", managed))
|
||||||
|
}
|
||||||
|
|
||||||
configReader := strings.NewReader(configContent.String())
|
configReader := strings.NewReader(configContent.String())
|
||||||
if err := g.addFileToZip(configReader, "config.txt"); err != nil {
|
if err := g.addFileToZip(configReader, "config.txt"); err != nil {
|
||||||
return fmt.Errorf("add config file to zip: %w", err)
|
return fmt.Errorf("add config file to zip: %w", err)
|
||||||
@@ -798,6 +817,8 @@ func (g *BundleGenerator) addSyncResponse() error {
|
|||||||
AllowPartial: true,
|
AllowPartial: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.maskSecrets()
|
||||||
|
|
||||||
jsonBytes, err := options.Marshal(g.syncResponse)
|
jsonBytes, err := options.Marshal(g.syncResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("generate json: %w", err)
|
return fmt.Errorf("generate json: %w", err)
|
||||||
@@ -810,9 +831,33 @@ func (g *BundleGenerator) addSyncResponse() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *BundleGenerator) maskSecrets() {
|
||||||
|
if g.syncResponse == nil || g.syncResponse.NetbirdConfig == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.syncResponse.NetbirdConfig.Flow != nil {
|
||||||
|
g.syncResponse.NetbirdConfig.Flow.TokenPayload = maskedValue
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.syncResponse.NetbirdConfig.Relay != nil {
|
||||||
|
g.syncResponse.NetbirdConfig.Relay.TokenPayload = maskedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range g.syncResponse.NetbirdConfig.Turns {
|
||||||
|
if g.syncResponse.NetbirdConfig.Turns[i] != nil {
|
||||||
|
g.syncResponse.NetbirdConfig.Turns[i].Password = maskedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (g *BundleGenerator) addStateFile() error {
|
func (g *BundleGenerator) addStateFile() error {
|
||||||
sm := profilemanager.NewServiceManager("")
|
path := g.statePath
|
||||||
path := sm.GetStatePath()
|
if path == "" {
|
||||||
|
sm := profilemanager.NewServiceManager("")
|
||||||
|
path = sm.GetStatePath()
|
||||||
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1039,7 +1084,8 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pattern := filepath.Join(logDir, "client-*.log.gz")
|
// This regex will match both logs rotated by us and logrotate on linux
|
||||||
|
pattern := filepath.Join(logDir, "client*.log.*")
|
||||||
files, err := filepath.Glob(pattern)
|
files, err := filepath.Glob(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("failed to glob rotated logs: %v", err)
|
log.Warnf("failed to glob rotated logs: %v", err)
|
||||||
@@ -1072,7 +1118,12 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
|||||||
|
|
||||||
for i := 0; i < maxFiles; i++ {
|
for i := 0; i < maxFiles; i++ {
|
||||||
name := filepath.Base(files[i])
|
name := filepath.Base(files[i])
|
||||||
if err := g.addSingleLogFileGz(files[i], name); err != nil {
|
if strings.HasSuffix(name, ".gz") {
|
||||||
|
err = g.addSingleLogFileGz(files[i], name)
|
||||||
|
} else {
|
||||||
|
err = g.addSingleLogfile(files[i], name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
log.Warnf("failed to add rotated log %s: %v", name, err)
|
log.Warnf("failed to add rotated log %s: %v", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
client/internal/debug/debug_ios.go
Normal file
36
client/internal/debug/debug_ios.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build ios
|
||||||
|
|
||||||
|
package debug
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swiftLogFile is the Swift app log written by the iOS app into the same log
|
||||||
|
// directory as the Go client log, so it can be collected into the bundle.
|
||||||
|
const swiftLogFile = "swift-log.log"
|
||||||
|
|
||||||
|
// addPlatformLog collects logs for the iOS debug bundle. iOS has no logcat or
|
||||||
|
// systemd journal, so we rely on file-based logs. addLogfile handles the Go
|
||||||
|
// client log (logPath) with rotation, the stderr/stdout companions and
|
||||||
|
// anonymization. The iOS app writes its own Swift log into the same directory,
|
||||||
|
// so we add it alongside the Go log.
|
||||||
|
func (g *BundleGenerator) addPlatformLog() error {
|
||||||
|
if err := g.addLogfile(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.logPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile)
|
||||||
|
if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil {
|
||||||
|
// The Swift log is best-effort: the app may not have written it yet.
|
||||||
|
log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
103
client/internal/debug/debug_logfiles_test.go
Normal file
103
client/internal/debug/debug_logfiles_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package debug
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log
|
||||||
|
// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain
|
||||||
|
// and gzipped), and skips unrelated files.
|
||||||
|
func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(dir, "client.log"), "active log\n")
|
||||||
|
writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n")
|
||||||
|
|
||||||
|
timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz"
|
||||||
|
writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n")
|
||||||
|
|
||||||
|
logrotatePlain := "client.log.1"
|
||||||
|
writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n")
|
||||||
|
|
||||||
|
logrotateGz := "client.log.2.gz"
|
||||||
|
writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n")
|
||||||
|
|
||||||
|
names := runAddRotatedLogFiles(t, dir, 10)
|
||||||
|
|
||||||
|
require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle")
|
||||||
|
require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle")
|
||||||
|
require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle")
|
||||||
|
require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles")
|
||||||
|
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
|
||||||
|
// logFileCount rotated files are bundled, ordered by mtime.
|
||||||
|
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
oldest := filepath.Join(dir, "client.log.3")
|
||||||
|
middle := filepath.Join(dir, "client.log.2")
|
||||||
|
newest := filepath.Join(dir, "client.log.1")
|
||||||
|
writeFile(t, oldest, "old\n")
|
||||||
|
writeFile(t, middle, "mid\n")
|
||||||
|
writeFile(t, newest, "new\n")
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour)))
|
||||||
|
require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour)))
|
||||||
|
require.NoError(t, os.Chtimes(newest, now, now))
|
||||||
|
|
||||||
|
names := runAddRotatedLogFiles(t, dir, 2)
|
||||||
|
|
||||||
|
require.Contains(t, names, "client.log.1")
|
||||||
|
require.Contains(t, names, "client.log.2")
|
||||||
|
require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
|
||||||
|
// zip writer and returns the set of entry names that ended up in the archive.
|
||||||
|
func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
g := &BundleGenerator{
|
||||||
|
archive: zip.NewWriter(&buf),
|
||||||
|
logFileCount: logFileCount,
|
||||||
|
}
|
||||||
|
g.addRotatedLogFiles(dir)
|
||||||
|
require.NoError(t, g.archive.Close())
|
||||||
|
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
names := make(map[string]struct{}, len(zr.File))
|
||||||
|
for _, f := range zr.File {
|
||||||
|
names[f.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGzFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gw := gzip.NewWriter(&buf)
|
||||||
|
_, err := io.WriteString(gw, content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, gw.Close())
|
||||||
|
require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !android
|
//go:build !android && !ios
|
||||||
|
|
||||||
package debug
|
package debug
|
||||||
|
|
||||||
|
|||||||
@@ -843,6 +843,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
|||||||
"PreSharedKey": "sensitive: WireGuard pre-shared key",
|
"PreSharedKey": "sensitive: WireGuard pre-shared key",
|
||||||
"SSHKey": "sensitive: SSH private key",
|
"SSHKey": "sensitive: SSH private key",
|
||||||
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
|
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
|
||||||
|
"Name": "non-config: profile name is not needed for debug purposes",
|
||||||
|
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
|
||||||
}
|
}
|
||||||
|
|
||||||
mURL, _ := url.Parse("https://api.example.com:443")
|
mURL, _ := url.Parse("https://api.example.com:443")
|
||||||
|
|||||||
@@ -339,8 +339,7 @@ func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool {
|
|||||||
case entry.Pattern == ".":
|
case entry.Pattern == ".":
|
||||||
return true
|
return true
|
||||||
case entry.IsWildcard:
|
case entry.IsWildcard:
|
||||||
parts := strings.Split(strings.TrimSuffix(qname, entry.Pattern), ".")
|
return strings.HasSuffix(qname, "."+entry.Pattern)
|
||||||
return len(parts) >= 2 && strings.HasSuffix(qname, entry.Pattern)
|
|
||||||
default:
|
default:
|
||||||
// For non-wildcard patterns:
|
// For non-wildcard patterns:
|
||||||
// If handler wants subdomain matching, allow suffix match
|
// If handler wants subdomain matching, allow suffix match
|
||||||
|
|||||||
@@ -164,6 +164,54 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) {
|
|||||||
matchSubdomains: true,
|
matchSubdomains: true,
|
||||||
shouldMatch: true,
|
shouldMatch: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard label-boundary mismatch (suffix overlap)",
|
||||||
|
handlerDomain: "*.b.test.",
|
||||||
|
queryDomain: "x.ab.test.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard label-boundary match",
|
||||||
|
handlerDomain: "*.b.test.",
|
||||||
|
queryDomain: "x.b.test.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard multi-label match",
|
||||||
|
handlerDomain: "*.b.test.",
|
||||||
|
queryDomain: "x.y.b.test.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard no match on multi-label apex",
|
||||||
|
handlerDomain: "*.b.test.",
|
||||||
|
queryDomain: "b.test.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard no match on unrelated suffix containment",
|
||||||
|
handlerDomain: "*.example.com.",
|
||||||
|
queryDomain: "notexample.com.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard accepts pattern registered without trailing dot",
|
||||||
|
handlerDomain: "*.b.test",
|
||||||
|
queryDomain: "x.b.test.",
|
||||||
|
isWildcard: true,
|
||||||
|
matchSubdomains: false,
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -273,6 +321,19 @@ func TestHandlerChain_ServeDNS_OverlappingDomains(t *testing.T) {
|
|||||||
expectedCalls: 1,
|
expectedCalls: 1,
|
||||||
expectedHandler: 2, // highest priority matching handler should be called
|
expectedHandler: 2, // highest priority matching handler should be called
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "overlapping wildcard suffixes route to correct handler",
|
||||||
|
handlers: []struct {
|
||||||
|
pattern string
|
||||||
|
priority int
|
||||||
|
}{
|
||||||
|
{pattern: "*.b.test.", priority: nbdns.PriorityDNSRoute},
|
||||||
|
{pattern: "*.ab.test.", priority: nbdns.PriorityDNSRoute},
|
||||||
|
},
|
||||||
|
queryDomain: "app.ab.test.",
|
||||||
|
expectedCalls: 1,
|
||||||
|
expectedHandler: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "root zone with specific domain",
|
name: "root zone with specific domain",
|
||||||
handlers: []struct {
|
handlers: []struct {
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ type resolver interface {
|
|||||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PeerConnectivity reports whether a tunnel IP belongs to a peer the
|
||||||
|
// client knows about and whether that peer is currently connected. The
|
||||||
|
// local resolver uses this to suppress A/AAAA answers whose RDATA points
|
||||||
|
// at a disconnected peer (typical case: a synthesized private-service
|
||||||
|
// record pointing at an embedded proxy peer that just went offline).
|
||||||
|
//
|
||||||
|
// known=false means the IP isn't in the local peerstore at all — the
|
||||||
|
// record is left alone (it points at something outside our mesh, e.g.
|
||||||
|
// a non-peer upstream).
|
||||||
|
type PeerConnectivity interface {
|
||||||
|
IsConnectedByIP(ip string) (known, connected bool)
|
||||||
|
}
|
||||||
|
|
||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
records map[dns.Question][]dns.RR
|
records map[dns.Question][]dns.RR
|
||||||
@@ -33,6 +46,11 @@ type Resolver struct {
|
|||||||
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
|
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
|
||||||
zones map[domain.Domain]bool
|
zones map[domain.Domain]bool
|
||||||
resolver resolver
|
resolver resolver
|
||||||
|
// peerConn, when non-nil, is consulted on every A/AAAA answer to
|
||||||
|
// drop records pointing at disconnected peers. nil disables the
|
||||||
|
// filter and preserves the legacy "return whatever is registered"
|
||||||
|
// behaviour for callers that never wire a status source.
|
||||||
|
peerConn PeerConnectivity
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -49,6 +67,15 @@ func NewResolver() *Resolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPeerConnectivity wires the per-IP connectivity check used to filter
|
||||||
|
// out A/AAAA answers pointing at disconnected peers. Pass nil to disable.
|
||||||
|
// Safe to call multiple times; the latest value wins.
|
||||||
|
func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.peerConn = p
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Resolver) MatchSubdomains() bool {
|
func (d *Resolver) MatchSubdomains() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -95,6 +122,7 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
|||||||
replyMessage.RecursionAvailable = true
|
replyMessage.RecursionAvailable = true
|
||||||
|
|
||||||
result := d.lookupRecords(logger, question)
|
result := d.lookupRecords(logger, question)
|
||||||
|
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
|
||||||
replyMessage.Authoritative = !result.hasExternalData
|
replyMessage.Authoritative = !result.hasExternalData
|
||||||
replyMessage.Answer = result.records
|
replyMessage.Answer = result.records
|
||||||
replyMessage.Rcode = d.determineRcode(question, result)
|
replyMessage.Rcode = d.determineRcode(question, result)
|
||||||
@@ -436,6 +464,78 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterDisconnectedPeerAnswers drops A/AAAA records whose RDATA matches
|
||||||
|
// a known but disconnected peer. The synthesized private-service zones
|
||||||
|
// emit one A record per connected proxy peer in a cluster; when a peer
|
||||||
|
// goes offline, the server-side refresh removes the record from the
|
||||||
|
// next netmap, but the client may still hold the previous netmap for a
|
||||||
|
// short window. This filter is the local belt to that braces — even on
|
||||||
|
// the stale netmap, the resolver hides the offline target.
|
||||||
|
//
|
||||||
|
// Records pointing at unknown IPs (outside the local peerstore, e.g.
|
||||||
|
// non-mesh upstreams) are never dropped. Non-A/AAAA records pass
|
||||||
|
// through untouched.
|
||||||
|
//
|
||||||
|
// Escape hatch: if filtering would leave the answer empty AND at least
|
||||||
|
// one record was filtered, the original list is returned. Better to
|
||||||
|
// hand the client a record that may not respond than NXDOMAIN it
|
||||||
|
// completely when every proxy peer is offline (the upstream may still
|
||||||
|
// be reachable some other way, or the peerstore may be stale).
|
||||||
|
func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
|
||||||
|
if len(records) < 2 {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
d.mu.RLock()
|
||||||
|
checker := d.peerConn
|
||||||
|
d.mu.RUnlock()
|
||||||
|
if checker == nil {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
kept := make([]dns.RR, 0, len(records))
|
||||||
|
var dropped int
|
||||||
|
for _, rr := range records {
|
||||||
|
ip := extractRecordIP(rr)
|
||||||
|
if ip == "" {
|
||||||
|
kept = append(kept, rr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
known, connected := checker.IsConnectedByIP(ip)
|
||||||
|
if known && !connected {
|
||||||
|
dropped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, rr)
|
||||||
|
}
|
||||||
|
if dropped == 0 {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
logger.Debugf("all %d answers for %s point at disconnected peers; returning the original list", dropped, question.Name)
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
logger.Tracef("dropped %d disconnected-peer answer(s) for %s, returning %d", dropped, question.Name, len(kept))
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
|
||||||
|
// an A or AAAA record, or "" for any other record type.
|
||||||
|
func extractRecordIP(rr dns.RR) string {
|
||||||
|
switch r := rr.(type) {
|
||||||
|
case *dns.A:
|
||||||
|
if r.A == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.A.String()
|
||||||
|
case *dns.AAAA:
|
||||||
|
if r.AAAA == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.AAAA.String()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Update replaces all zones and their records
|
// Update replaces all zones and their records
|
||||||
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
|
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockPeerConnectivity returns canned (known, connected) results per IP.
|
||||||
|
// Used by the disconnected-peer filter tests below. IPs not in the map
|
||||||
|
// are reported as unknown so the filter leaves them alone.
|
||||||
|
type mockPeerConnectivity struct {
|
||||||
|
byIP map[string]struct{ known, connected bool }
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||||
|
v, ok := m.byIP[ip]
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return v.known, v.connected
|
||||||
|
}
|
||||||
|
|
||||||
func TestLocalResolver_ServeDNS(t *testing.T) {
|
func TestLocalResolver_ServeDNS(t *testing.T) {
|
||||||
recordA := nbdns.SimpleRecord{
|
recordA := nbdns.SimpleRecord{
|
||||||
Name: "peera.netbird.cloud.",
|
Name: "peera.netbird.cloud.",
|
||||||
@@ -2652,3 +2667,125 @@ 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"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A single answer is never filtered: dropping it would only
|
||||||
|
// trigger the empty-answer escape hatch, so the fast path
|
||||||
|
// returns it untouched.
|
||||||
|
name: "single disconnected answer passes through",
|
||||||
|
records: []nbdns.SimpleRecord{disconnectedRec},
|
||||||
|
connByIP: map[string]ipState{
|
||||||
|
"100.64.0.11": {known: true, connected: false},
|
||||||
|
},
|
||||||
|
wantInOrder: []string{"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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errNoSuitableAddress mirrors the unexported error string the net package
|
||||||
|
// uses when a resolved host has no addresses of the requested family.
|
||||||
|
const errNoSuitableAddress = "no suitable address found"
|
||||||
|
|
||||||
// GenerateRequestID creates a random 8-character hex string for request tracing.
|
// GenerateRequestID creates a random 8-character hex string for request tracing.
|
||||||
func GenerateRequestID() string {
|
func GenerateRequestID() string {
|
||||||
bytes := make([]byte, 4)
|
bytes := make([]byte, 4)
|
||||||
@@ -126,6 +130,14 @@ func LookupIP(ctx context.Context, r resolver, network, host string, qtype uint1
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getRcodeForError(ctx context.Context, r resolver, host string, qtype uint16, err error) int {
|
func getRcodeForError(ctx context.Context, r resolver, host string, qtype uint16, err error) int {
|
||||||
|
// The net package returns this AddrError when the host resolves but has
|
||||||
|
// no addresses of the requested family. The domain exists, so answer
|
||||||
|
// NODATA instead of SERVFAIL.
|
||||||
|
var addrErr *net.AddrError
|
||||||
|
if errors.As(err, &addrErr) && addrErr.Err == errNoSuitableAddress {
|
||||||
|
return dns.RcodeSuccess
|
||||||
|
}
|
||||||
|
|
||||||
var dnsErr *net.DNSError
|
var dnsErr *net.DNSError
|
||||||
if !errors.As(err, &dnsErr) {
|
if !errors.As(err, &dnsErr) {
|
||||||
return dns.RcodeServerFailure
|
return dns.RcodeServerFailure
|
||||||
@@ -195,3 +207,35 @@ func FormatAnswers(answers []dns.RR) string {
|
|||||||
}
|
}
|
||||||
return "[" + strings.Join(parts, ", ") + "]"
|
return "[" + strings.Join(parts, ", ") + "]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StripOPT removes any OPT pseudo-RRs from the message's Extra section. Per
|
||||||
|
// RFC 6891 a responder must not include an OPT RR toward a client that did not
|
||||||
|
// advertise EDNS0.
|
||||||
|
func StripOPT(msg *dns.Msg) {
|
||||||
|
if len(msg.Extra) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := msg.Extra[:0]
|
||||||
|
for _, rr := range msg.Extra {
|
||||||
|
if _, ok := rr.(*dns.OPT); ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, rr)
|
||||||
|
}
|
||||||
|
msg.Extra = out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractEDE returns the first Extended DNS Error (RFC 8914) option carried in
|
||||||
|
// the message, if present.
|
||||||
|
func ExtractEDE(msg *dns.Msg) (*dns.EDNS0_EDE, bool) {
|
||||||
|
opt := msg.IsEdns0()
|
||||||
|
if opt == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, o := range opt.Option {
|
||||||
|
if ede, ok := o.(*dns.EDNS0_EDE); ok {
|
||||||
|
return ede, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|||||||
161
client/internal/dns/resutil/resolve_test.go
Normal file
161
client/internal/dns/resutil/resolve_test.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package resutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockResolver struct {
|
||||||
|
// results maps network ("ip4"/"ip6") to the lookup outcome.
|
||||||
|
results map[string]mockLookup
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockLookup struct {
|
||||||
|
ips []netip.Addr
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockResolver) LookupNetIP(_ context.Context, network, _ string) ([]netip.Addr, error) {
|
||||||
|
res, ok := m.results[network]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unexpected network: " + network)
|
||||||
|
}
|
||||||
|
return res.ips, res.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupIP_Success(t *testing.T) {
|
||||||
|
r := &mockResolver{results: map[string]mockLookup{
|
||||||
|
"ip4": {ips: []netip.Addr{netip.MustParseAddr("::ffff:192.0.2.1")}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
|
||||||
|
|
||||||
|
assert.Equal(t, dns.RcodeSuccess, result.Rcode, "successful lookup should return NOERROR")
|
||||||
|
require.Len(t, result.IPs, 1, "should return the resolved address")
|
||||||
|
assert.Equal(t, netip.MustParseAddr("192.0.2.1"), result.IPs[0], "v4-mapped address should be unmapped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupIP_NoSuitableAddress(t *testing.T) {
|
||||||
|
// The net package returns this AddrError when the host resolves but has
|
||||||
|
// no addresses of the requested family (e.g. AAAA query for a v4-only
|
||||||
|
// hosts file entry). The domain exists, so this is NODATA, not SERVFAIL.
|
||||||
|
r := &mockResolver{results: map[string]mockLookup{
|
||||||
|
"ip6": {err: &net.AddrError{Err: "no suitable address found", Addr: "example.com."}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA)
|
||||||
|
|
||||||
|
assert.Equal(t, dns.RcodeSuccess, result.Rcode, "no suitable address should map to NODATA")
|
||||||
|
assert.Empty(t, result.IPs, "NODATA response should carry no addresses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrNoSuitableAddressMatchesNetPackage pins our copy of the error string
|
||||||
|
// to what the net package actually emits. A literal IP of the wrong family
|
||||||
|
// takes the same filterAddrList path as a resolved hostname, without network
|
||||||
|
// access.
|
||||||
|
func TestErrNoSuitableAddressMatchesNetPackage(t *testing.T) {
|
||||||
|
_, err := (&net.Resolver{}).LookupNetIP(context.Background(), "ip6", "192.0.2.1")
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
var addrErr *net.AddrError
|
||||||
|
require.ErrorAs(t, err, &addrErr, "wrong-family lookup should return AddrError")
|
||||||
|
assert.Equal(t, errNoSuitableAddress, addrErr.Err, "net package error string should match our constant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupIP_OtherAddrError(t *testing.T) {
|
||||||
|
r := &mockResolver{results: map[string]mockLookup{
|
||||||
|
"ip4": {err: &net.AddrError{Err: "some other address problem", Addr: "example.com."}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
|
||||||
|
|
||||||
|
assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "unrecognized AddrError should map to SERVFAIL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupIP_NotFoundNXDomain(t *testing.T) {
|
||||||
|
r := &mockResolver{results: map[string]mockLookup{
|
||||||
|
"ip4": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
|
||||||
|
"ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
|
||||||
|
|
||||||
|
assert.Equal(t, dns.RcodeNameError, result.Rcode, "not found for both families should map to NXDOMAIN")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupIP_NotFoundNoData(t *testing.T) {
|
||||||
|
r := &mockResolver{results: map[string]mockLookup{
|
||||||
|
"ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
|
||||||
|
"ip4": {ips: []netip.Addr{netip.MustParseAddr("192.0.2.1")}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA)
|
||||||
|
|
||||||
|
assert.Equal(t, dns.RcodeSuccess, result.Rcode, "not found with the other family present should map to NODATA")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupIP_GenericError(t *testing.T) {
|
||||||
|
r := &mockResolver{results: map[string]mockLookup{
|
||||||
|
"ip4": {err: errors.New("connection refused")},
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
|
||||||
|
|
||||||
|
assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "generic error should map to SERVFAIL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) {
|
||||||
|
r := &mockResolver{results: map[string]mockLookup{
|
||||||
|
"ip4": {err: &net.DNSError{Err: "server misbehaving", Name: "example.com.", IsTemporary: true}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
|
||||||
|
|
||||||
|
assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripOPT(t *testing.T) {
|
||||||
|
rm := &dns.Msg{
|
||||||
|
Extra: []dns.RR{
|
||||||
|
&dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}},
|
||||||
|
&dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
StripOPT(rm)
|
||||||
|
assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept")
|
||||||
|
_, isOPT := rm.Extra[0].(*dns.OPT)
|
||||||
|
assert.False(t, isOPT, "remaining record must not be OPT")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEDE(t *testing.T) {
|
||||||
|
t.Run("no edns", func(t *testing.T) {
|
||||||
|
_, ok := ExtractEDE(&dns.Msg{})
|
||||||
|
assert.False(t, ok, "message without OPT has no EDE")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("edns without ede", func(t *testing.T) {
|
||||||
|
rm := &dns.Msg{}
|
||||||
|
rm.SetEdns0(4096, false)
|
||||||
|
_, ok := ExtractEDE(rm)
|
||||||
|
assert.False(t, ok, "OPT without EDE option returns false")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with ede", func(t *testing.T) {
|
||||||
|
rm := &dns.Msg{}
|
||||||
|
opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
|
||||||
|
opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: 49152, ExtraText: "upstream timeout"})
|
||||||
|
rm.Extra = append(rm.Extra, opt)
|
||||||
|
|
||||||
|
ede, ok := ExtractEDE(rm)
|
||||||
|
assert.True(t, ok, "EDE option should be found")
|
||||||
|
assert.Equal(t, uint16(49152), ede.InfoCode)
|
||||||
|
assert.Equal(t, "upstream timeout", ede.ExtraText)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -38,11 +39,15 @@ const (
|
|||||||
// defaultWarningDelayBase is the starting grace window before a
|
// defaultWarningDelayBase is the starting grace window before a
|
||||||
// "Nameserver group unreachable" event fires for a group that's
|
// "Nameserver group unreachable" event fires for a group that's
|
||||||
// never been healthy and only has overlay upstreams with no
|
// never been healthy and only has overlay upstreams with no
|
||||||
// Connected peer. Per-server and overridable; see warningDelayFor.
|
// Connected peer. Per-server and overridable via envWarningDelay;
|
||||||
defaultWarningDelayBase = 30 * time.Second
|
// see warningDelay.
|
||||||
|
defaultWarningDelayBase = 60 * time.Second
|
||||||
// warningDelayBonusCap caps the route-count bonus added to the
|
// warningDelayBonusCap caps the route-count bonus added to the
|
||||||
// base grace window. See warningDelayFor.
|
// base grace window. See warningDelay.
|
||||||
warningDelayBonusCap = 30 * time.Second
|
warningDelayBonusCap = 30 * time.Second
|
||||||
|
// envWarningDelay overrides defaultWarningDelayBase with a Go duration
|
||||||
|
// string (e.g. "90s", "2m"). Invalid or non-positive values are ignored.
|
||||||
|
envWarningDelay = "NB_DNS_HEALTH_WARNING_DELAY"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errNoUsableNameservers signals that a merged-domain group has no usable
|
// errNoUsableNameservers signals that a merged-domain group has no usable
|
||||||
@@ -135,7 +140,7 @@ type DefaultServer struct {
|
|||||||
disableSys bool
|
disableSys bool
|
||||||
mux sync.Mutex
|
mux sync.Mutex
|
||||||
service service
|
service service
|
||||||
dnsMuxMap registeredHandlerMap
|
dnsMuxHandlers []handlerWrapper
|
||||||
localResolver *local.Resolver
|
localResolver *local.Resolver
|
||||||
wgInterface WGIface
|
wgInterface WGIface
|
||||||
hostManager hostManager
|
hostManager hostManager
|
||||||
@@ -199,8 +204,6 @@ type handlerWrapper struct {
|
|||||||
priority int
|
priority int
|
||||||
}
|
}
|
||||||
|
|
||||||
type registeredHandlerMap map[types.HandlerID]handlerWrapper
|
|
||||||
|
|
||||||
// DefaultServerConfig holds configuration parameters for NewDefaultServer
|
// DefaultServerConfig holds configuration parameters for NewDefaultServer
|
||||||
type DefaultServerConfig struct {
|
type DefaultServerConfig struct {
|
||||||
WgInterface WGIface
|
WgInterface WGIface
|
||||||
@@ -289,7 +292,6 @@ func newDefaultServer(
|
|||||||
service: dnsService,
|
service: dnsService,
|
||||||
handlerChain: handlerChain,
|
handlerChain: handlerChain,
|
||||||
extraDomains: make(map[domain.Domain]int),
|
extraDomains: make(map[domain.Domain]int),
|
||||||
dnsMuxMap: make(registeredHandlerMap),
|
|
||||||
localResolver: local.NewResolver(),
|
localResolver: local.NewResolver(),
|
||||||
wgInterface: wgInterface,
|
wgInterface: wgInterface,
|
||||||
statusRecorder: statusRecorder,
|
statusRecorder: statusRecorder,
|
||||||
@@ -298,9 +300,14 @@ func newDefaultServer(
|
|||||||
hostManager: &noopHostConfigurator{},
|
hostManager: &noopHostConfigurator{},
|
||||||
mgmtCacheResolver: mgmtCacheResolver,
|
mgmtCacheResolver: mgmtCacheResolver,
|
||||||
currentConfigHash: ^uint64(0), // Initialize to max uint64 to ensure first config is always applied
|
currentConfigHash: ^uint64(0), // Initialize to max uint64 to ensure first config is always applied
|
||||||
warningDelayBase: defaultWarningDelayBase,
|
warningDelayBase: warningDelayBaseFromEnv(),
|
||||||
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)
|
||||||
@@ -323,7 +330,7 @@ func (s *DefaultServer) SetRouteSources(selected, active func() route.HAMap) {
|
|||||||
type routeSettable interface {
|
type routeSettable interface {
|
||||||
setSelectedRoutes(func() route.HAMap)
|
setSelectedRoutes(func() route.HAMap)
|
||||||
}
|
}
|
||||||
for _, entry := range s.dnsMuxMap {
|
for _, entry := range s.dnsMuxHandlers {
|
||||||
if h, ok := entry.handler.(routeSettable); ok {
|
if h, ok := entry.handler.(routeSettable); ok {
|
||||||
h.setSelectedRoutes(selected)
|
h.setSelectedRoutes(selected)
|
||||||
}
|
}
|
||||||
@@ -772,13 +779,24 @@ func (s *DefaultServer) applyHostConfig() {
|
|||||||
// context is released rather than leaked until GC.
|
// context is released rather than leaked until GC.
|
||||||
func (s *DefaultServer) registerFallback() {
|
func (s *DefaultServer) registerFallback() {
|
||||||
originalNameservers := s.hostManager.getOriginalNameservers()
|
originalNameservers := s.hostManager.getOriginalNameservers()
|
||||||
if len(originalNameservers) == 0 {
|
|
||||||
|
serverIP := s.service.RuntimeIP()
|
||||||
|
var servers []netip.AddrPort
|
||||||
|
for _, ns := range originalNameservers {
|
||||||
|
if ns == serverIP {
|
||||||
|
log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(servers) == 0 {
|
||||||
log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler")
|
log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler")
|
||||||
s.clearFallback()
|
s.clearFallback()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback)
|
log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback)
|
||||||
|
|
||||||
handler, err := newUpstreamResolver(
|
handler, err := newUpstreamResolver(
|
||||||
s.ctx,
|
s.ctx,
|
||||||
@@ -792,11 +810,6 @@ func (s *DefaultServer) registerFallback() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.selectedRoutes = s.selectedRoutes
|
handler.selectedRoutes = s.selectedRoutes
|
||||||
|
|
||||||
var servers []netip.AddrPort
|
|
||||||
for _, ns := range originalNameservers {
|
|
||||||
servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
|
|
||||||
}
|
|
||||||
handler.addRace(servers)
|
handler.addRace(servers)
|
||||||
|
|
||||||
prev := s.fallbackHandler
|
prev := s.fallbackHandler
|
||||||
@@ -967,19 +980,23 @@ func (s *DefaultServer) usableNameServers(nameServers []nbdns.NameServer) []neti
|
|||||||
|
|
||||||
func (s *DefaultServer) updateMux(muxUpdates []handlerWrapper) {
|
func (s *DefaultServer) updateMux(muxUpdates []handlerWrapper) {
|
||||||
// this will introduce a short period of time when the server is not able to handle DNS requests
|
// this will introduce a short period of time when the server is not able to handle DNS requests
|
||||||
for _, existing := range s.dnsMuxMap {
|
for _, existing := range s.dnsMuxHandlers {
|
||||||
s.deregisterHandler([]string{existing.domain}, existing.priority)
|
s.deregisterHandler([]string{existing.domain}, existing.priority)
|
||||||
existing.handler.Stop()
|
// The local resolver is a persistent singleton shared by every custom
|
||||||
|
// zone and reused across config updates. Its chain registrations are
|
||||||
|
// per-config and must be deregistered, but Stop() cancels its lookup
|
||||||
|
// context (breaking external CNAME-target resolution) and clears its
|
||||||
|
// records, so it must not be torn down here.
|
||||||
|
if existing.handler != s.localResolver {
|
||||||
|
existing.handler.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
muxUpdateMap := make(registeredHandlerMap)
|
|
||||||
|
|
||||||
for _, update := range muxUpdates {
|
for _, update := range muxUpdates {
|
||||||
s.registerHandler([]string{update.domain}, update.handler, update.priority)
|
s.registerHandler([]string{update.domain}, update.handler, update.priority)
|
||||||
muxUpdateMap[update.handler.ID()] = update
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.dnsMuxMap = muxUpdateMap
|
s.dnsMuxHandlers = muxUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateNSGroupStates records the new group set and pokes the refresher.
|
// updateNSGroupStates records the new group set and pokes the refresher.
|
||||||
@@ -1143,6 +1160,26 @@ func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPor
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warningDelayBaseFromEnv returns the base grace window, honoring
|
||||||
|
// envWarningDelay when it holds a valid positive Go duration. Invalid or
|
||||||
|
// non-positive values fall back to defaultWarningDelayBase.
|
||||||
|
func warningDelayBaseFromEnv() time.Duration {
|
||||||
|
val := os.Getenv(envWarningDelay)
|
||||||
|
if val == "" {
|
||||||
|
return defaultWarningDelayBase
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(val)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("invalid %s value %q, using default %v: %v", envWarningDelay, val, defaultWarningDelayBase, err)
|
||||||
|
return defaultWarningDelayBase
|
||||||
|
}
|
||||||
|
if d <= 0 {
|
||||||
|
log.Warnf("%s must be positive, got %v, using default %v", envWarningDelay, d, defaultWarningDelayBase)
|
||||||
|
return defaultWarningDelayBase
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
// warningDelay returns the grace window for the given selected-route
|
// warningDelay returns the grace window for the given selected-route
|
||||||
// count. Scales gently: +1s per 100 routes, capped by
|
// count. Scales gently: +1s per 100 routes, capped by
|
||||||
// warningDelayBonusCap. Parallel handshakes mean handshake time grows
|
// warningDelayBonusCap. Parallel handshakes mean handshake time grows
|
||||||
@@ -1193,7 +1230,7 @@ func (s *DefaultServer) groupHasImmediateUpstream(servers []netip.AddrPort, snap
|
|||||||
// in more than one handler.
|
// in more than one handler.
|
||||||
func (s *DefaultServer) collectUpstreamHealth() map[netip.AddrPort]UpstreamHealth {
|
func (s *DefaultServer) collectUpstreamHealth() map[netip.AddrPort]UpstreamHealth {
|
||||||
merged := make(map[netip.AddrPort]UpstreamHealth)
|
merged := make(map[netip.AddrPort]UpstreamHealth)
|
||||||
for _, entry := range s.dnsMuxMap {
|
for _, entry := range s.dnsMuxHandlers {
|
||||||
reporter, ok := entry.handler.(upstreamHealthReporter)
|
reporter, ok := entry.handler.(upstreamHealthReporter)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -1386,3 +1423,25 @@ func (s *DefaultServer) PopulateManagementDomain(mgmtURL *url.URL) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localPeerConnectivity adapts *peer.Status to local.PeerConnectivity so
|
||||||
|
// the local resolver can ask "is this IP a known peer and is it
|
||||||
|
// connected?" without taking on the peer package as a dependency.
|
||||||
|
// A nil status recorder always reports known=false so the resolver
|
||||||
|
// short-circuits to the legacy "return everything" path.
|
||||||
|
type localPeerConnectivity struct {
|
||||||
|
status *peer.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
|
||||||
|
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
|
||||||
|
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||||
|
if l.status == nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
state, ok := l.status.PeerStateByIP(ip)
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return true, state.ConnStatus == peer.StatusConnected
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,19 +104,6 @@ func init() {
|
|||||||
formatter.SetTextFormatter(log.StandardLogger())
|
formatter.SetTextFormatter(log.StandardLogger())
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateDummyHandler(d string, servers []nbdns.NameServer) *upstreamResolverBase {
|
|
||||||
var srvs []netip.AddrPort
|
|
||||||
for _, srv := range servers {
|
|
||||||
srvs = append(srvs, srv.AddrPort())
|
|
||||||
}
|
|
||||||
u := &upstreamResolverBase{
|
|
||||||
domain: domain.Domain(d),
|
|
||||||
cancel: func() {},
|
|
||||||
}
|
|
||||||
u.addRace(srvs)
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateDNSServer(t *testing.T) {
|
func TestUpdateDNSServer(t *testing.T) {
|
||||||
|
|
||||||
nameServers := []nbdns.NameServer{
|
nameServers := []nbdns.NameServer{
|
||||||
@@ -132,22 +119,20 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
dummyHandler := local.NewResolver()
|
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
initUpstreamMap registeredHandlerMap
|
initUpstreamMap []handlerWrapper
|
||||||
initLocalZones []nbdns.CustomZone
|
initLocalZones []nbdns.CustomZone
|
||||||
initSerial uint64
|
initSerial uint64
|
||||||
inputSerial uint64
|
inputSerial uint64
|
||||||
inputUpdate nbdns.Config
|
inputUpdate nbdns.Config
|
||||||
shouldFail bool
|
shouldFail bool
|
||||||
expectedUpstreamMap registeredHandlerMap
|
expectedUpstreamMap []handlerWrapper
|
||||||
expectedLocalQs []dns.Question
|
expectedLocalQs []dns.Question
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Initial Config Should Succeed",
|
name: "Initial Config Should Succeed",
|
||||||
initUpstreamMap: make(registeredHandlerMap),
|
initUpstreamMap: nil,
|
||||||
initSerial: 0,
|
initSerial: 0,
|
||||||
inputSerial: 1,
|
inputSerial: 1,
|
||||||
inputUpdate: nbdns.Config{
|
inputUpdate: nbdns.Config{
|
||||||
@@ -169,20 +154,17 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedUpstreamMap: registeredHandlerMap{
|
expectedUpstreamMap: []handlerWrapper{
|
||||||
generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{
|
{
|
||||||
domain: "netbird.io",
|
domain: "netbird.io",
|
||||||
handler: dummyHandler,
|
|
||||||
priority: PriorityUpstream,
|
priority: PriorityUpstream,
|
||||||
},
|
},
|
||||||
dummyHandler.ID(): handlerWrapper{
|
{
|
||||||
domain: "netbird.cloud",
|
domain: "netbird.cloud",
|
||||||
handler: dummyHandler,
|
|
||||||
priority: PriorityLocal,
|
priority: PriorityLocal,
|
||||||
},
|
},
|
||||||
generateDummyHandler(".", nameServers).ID(): handlerWrapper{
|
{
|
||||||
domain: nbdns.RootZone,
|
domain: nbdns.RootZone,
|
||||||
handler: dummyHandler,
|
|
||||||
priority: PriorityDefault,
|
priority: PriorityDefault,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -191,10 +173,10 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "New Config Should Succeed",
|
name: "New Config Should Succeed",
|
||||||
initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
|
initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
|
||||||
initUpstreamMap: registeredHandlerMap{
|
initUpstreamMap: []handlerWrapper{
|
||||||
generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
|
{
|
||||||
domain: "netbird.cloud",
|
domain: "netbird.cloud",
|
||||||
handler: dummyHandler,
|
handler: &mockHandler{},
|
||||||
priority: PriorityUpstream,
|
priority: PriorityUpstream,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -215,15 +197,13 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedUpstreamMap: registeredHandlerMap{
|
expectedUpstreamMap: []handlerWrapper{
|
||||||
generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{
|
{
|
||||||
domain: "netbird.io",
|
domain: "netbird.io",
|
||||||
handler: dummyHandler,
|
|
||||||
priority: PriorityUpstream,
|
priority: PriorityUpstream,
|
||||||
},
|
},
|
||||||
"local-resolver": handlerWrapper{
|
{
|
||||||
domain: "netbird.cloud",
|
domain: "netbird.cloud",
|
||||||
handler: dummyHandler,
|
|
||||||
priority: PriorityLocal,
|
priority: PriorityLocal,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -232,7 +212,7 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "Smaller Config Serial Should Be Skipped",
|
name: "Smaller Config Serial Should Be Skipped",
|
||||||
initLocalZones: []nbdns.CustomZone{},
|
initLocalZones: []nbdns.CustomZone{},
|
||||||
initUpstreamMap: make(registeredHandlerMap),
|
initUpstreamMap: nil,
|
||||||
initSerial: 2,
|
initSerial: 2,
|
||||||
inputSerial: 1,
|
inputSerial: 1,
|
||||||
shouldFail: true,
|
shouldFail: true,
|
||||||
@@ -240,7 +220,7 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "Empty NS Group Domain Or Not Primary Element Should Fail",
|
name: "Empty NS Group Domain Or Not Primary Element Should Fail",
|
||||||
initLocalZones: []nbdns.CustomZone{},
|
initLocalZones: []nbdns.CustomZone{},
|
||||||
initUpstreamMap: make(registeredHandlerMap),
|
initUpstreamMap: nil,
|
||||||
initSerial: 0,
|
initSerial: 0,
|
||||||
inputSerial: 1,
|
inputSerial: 1,
|
||||||
inputUpdate: nbdns.Config{
|
inputUpdate: nbdns.Config{
|
||||||
@@ -262,7 +242,7 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "Invalid NS Group Nameservers list Should Fail",
|
name: "Invalid NS Group Nameservers list Should Fail",
|
||||||
initLocalZones: []nbdns.CustomZone{},
|
initLocalZones: []nbdns.CustomZone{},
|
||||||
initUpstreamMap: make(registeredHandlerMap),
|
initUpstreamMap: nil,
|
||||||
initSerial: 0,
|
initSerial: 0,
|
||||||
inputSerial: 1,
|
inputSerial: 1,
|
||||||
inputUpdate: nbdns.Config{
|
inputUpdate: nbdns.Config{
|
||||||
@@ -284,7 +264,7 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "Invalid Custom Zone Records list Should Skip",
|
name: "Invalid Custom Zone Records list Should Skip",
|
||||||
initLocalZones: []nbdns.CustomZone{},
|
initLocalZones: []nbdns.CustomZone{},
|
||||||
initUpstreamMap: make(registeredHandlerMap),
|
initUpstreamMap: nil,
|
||||||
initSerial: 0,
|
initSerial: 0,
|
||||||
inputSerial: 1,
|
inputSerial: 1,
|
||||||
inputUpdate: nbdns.Config{
|
inputUpdate: nbdns.Config{
|
||||||
@@ -301,42 +281,41 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedUpstreamMap: registeredHandlerMap{generateDummyHandler(".", nameServers).ID(): handlerWrapper{
|
expectedUpstreamMap: []handlerWrapper{{
|
||||||
domain: ".",
|
domain: ".",
|
||||||
handler: dummyHandler,
|
|
||||||
priority: PriorityDefault,
|
priority: PriorityDefault,
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Empty Config Should Succeed and Clean Maps",
|
name: "Empty Config Should Succeed and Clean Maps",
|
||||||
initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
|
initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
|
||||||
initUpstreamMap: registeredHandlerMap{
|
initUpstreamMap: []handlerWrapper{
|
||||||
generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
|
{
|
||||||
domain: zoneRecords[0].Name,
|
domain: zoneRecords[0].Name,
|
||||||
handler: dummyHandler,
|
handler: &mockHandler{},
|
||||||
priority: PriorityUpstream,
|
priority: PriorityUpstream,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
initSerial: 0,
|
initSerial: 0,
|
||||||
inputSerial: 1,
|
inputSerial: 1,
|
||||||
inputUpdate: nbdns.Config{ServiceEnable: true},
|
inputUpdate: nbdns.Config{ServiceEnable: true},
|
||||||
expectedUpstreamMap: make(registeredHandlerMap),
|
expectedUpstreamMap: nil,
|
||||||
expectedLocalQs: []dns.Question{},
|
expectedLocalQs: []dns.Question{},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Disabled Service Should clean map",
|
name: "Disabled Service Should clean map",
|
||||||
initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
|
initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
|
||||||
initUpstreamMap: registeredHandlerMap{
|
initUpstreamMap: []handlerWrapper{
|
||||||
generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
|
{
|
||||||
domain: zoneRecords[0].Name,
|
domain: zoneRecords[0].Name,
|
||||||
handler: dummyHandler,
|
handler: &mockHandler{},
|
||||||
priority: PriorityUpstream,
|
priority: PriorityUpstream,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
initSerial: 0,
|
initSerial: 0,
|
||||||
inputSerial: 1,
|
inputSerial: 1,
|
||||||
inputUpdate: nbdns.Config{ServiceEnable: false},
|
inputUpdate: nbdns.Config{ServiceEnable: false},
|
||||||
expectedUpstreamMap: make(registeredHandlerMap),
|
expectedUpstreamMap: nil,
|
||||||
expectedLocalQs: []dns.Question{},
|
expectedLocalQs: []dns.Question{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -393,7 +372,7 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
dnsServer.dnsMuxMap = testCase.initUpstreamMap
|
dnsServer.dnsMuxHandlers = testCase.initUpstreamMap
|
||||||
dnsServer.localResolver.Update(testCase.initLocalZones)
|
dnsServer.localResolver.Update(testCase.initLocalZones)
|
||||||
dnsServer.updateSerial = testCase.initSerial
|
dnsServer.updateSerial = testCase.initSerial
|
||||||
|
|
||||||
@@ -405,14 +384,20 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
t.Fatalf("update dns server should not fail, got error: %v", err)
|
t.Fatalf("update dns server should not fail, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(dnsServer.dnsMuxMap) != len(testCase.expectedUpstreamMap) {
|
if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) {
|
||||||
t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxMap))
|
t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers))
|
||||||
}
|
}
|
||||||
|
|
||||||
for key := range testCase.expectedUpstreamMap {
|
for _, expected := range testCase.expectedUpstreamMap {
|
||||||
_, found := dnsServer.dnsMuxMap[key]
|
found := false
|
||||||
|
for _, got := range dnsServer.dnsMuxHandlers {
|
||||||
|
if got.domain == expected.domain && got.priority == expected.priority {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatalf("update upstream failed, key %s was not found in the dnsMuxMap: %#v", key, dnsServer.dnsMuxMap)
|
t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,8 +497,8 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
dnsServer.dnsMuxMap = registeredHandlerMap{
|
dnsServer.dnsMuxHandlers = []handlerWrapper{
|
||||||
"id1": handlerWrapper{
|
{
|
||||||
domain: zoneRecords[0].Name,
|
domain: zoneRecords[0].Name,
|
||||||
handler: &local.Resolver{},
|
handler: &local.Resolver{},
|
||||||
priority: PriorityUpstream,
|
priority: PriorityUpstream,
|
||||||
@@ -1029,15 +1014,15 @@ func (m *mockService) RegisterMux(string, dns.Handler) {}
|
|||||||
func (m *mockService) DeregisterMux(string) {}
|
func (m *mockService) DeregisterMux(string) {}
|
||||||
|
|
||||||
func TestDefaultServer_UpdateMux(t *testing.T) {
|
func TestDefaultServer_UpdateMux(t *testing.T) {
|
||||||
baseMatchHandlers := registeredHandlerMap{
|
baseMatchHandlers := []handlerWrapper{
|
||||||
"upstream-group1": {
|
{
|
||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
handler: &mockHandler{
|
handler: &mockHandler{
|
||||||
Id: "upstream-group1",
|
Id: "upstream-group1",
|
||||||
},
|
},
|
||||||
priority: PriorityUpstream,
|
priority: PriorityUpstream,
|
||||||
},
|
},
|
||||||
"upstream-group2": {
|
{
|
||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
handler: &mockHandler{
|
handler: &mockHandler{
|
||||||
Id: "upstream-group2",
|
Id: "upstream-group2",
|
||||||
@@ -1046,15 +1031,15 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
baseRootHandlers := registeredHandlerMap{
|
baseRootHandlers := []handlerWrapper{
|
||||||
"upstream-root1": {
|
{
|
||||||
domain: ".",
|
domain: ".",
|
||||||
handler: &mockHandler{
|
handler: &mockHandler{
|
||||||
Id: "upstream-root1",
|
Id: "upstream-root1",
|
||||||
},
|
},
|
||||||
priority: PriorityDefault,
|
priority: PriorityDefault,
|
||||||
},
|
},
|
||||||
"upstream-root2": {
|
{
|
||||||
domain: ".",
|
domain: ".",
|
||||||
handler: &mockHandler{
|
handler: &mockHandler{
|
||||||
Id: "upstream-root2",
|
Id: "upstream-root2",
|
||||||
@@ -1063,22 +1048,22 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
baseMixedHandlers := registeredHandlerMap{
|
baseMixedHandlers := []handlerWrapper{
|
||||||
"upstream-group1": {
|
{
|
||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
handler: &mockHandler{
|
handler: &mockHandler{
|
||||||
Id: "upstream-group1",
|
Id: "upstream-group1",
|
||||||
},
|
},
|
||||||
priority: PriorityUpstream,
|
priority: PriorityUpstream,
|
||||||
},
|
},
|
||||||
"upstream-group2": {
|
{
|
||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
handler: &mockHandler{
|
handler: &mockHandler{
|
||||||
Id: "upstream-group2",
|
Id: "upstream-group2",
|
||||||
},
|
},
|
||||||
priority: PriorityUpstream - 1,
|
priority: PriorityUpstream - 1,
|
||||||
},
|
},
|
||||||
"upstream-other": {
|
{
|
||||||
domain: "other.com",
|
domain: "other.com",
|
||||||
handler: &mockHandler{
|
handler: &mockHandler{
|
||||||
Id: "upstream-other",
|
Id: "upstream-other",
|
||||||
@@ -1089,7 +1074,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
initialHandlers registeredHandlerMap
|
initialHandlers []handlerWrapper
|
||||||
updates []handlerWrapper
|
updates []handlerWrapper
|
||||||
expectedHandlers map[string]string // map[HandlerID]domain
|
expectedHandlers map[string]string // map[HandlerID]domain
|
||||||
description string
|
description string
|
||||||
@@ -1373,32 +1358,38 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
server := &DefaultServer{
|
server := &DefaultServer{
|
||||||
dnsMuxMap: tt.initialHandlers,
|
dnsMuxHandlers: tt.initialHandlers,
|
||||||
handlerChain: NewHandlerChain(),
|
handlerChain: NewHandlerChain(),
|
||||||
service: &mockService{},
|
service: &mockService{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform the update
|
// Perform the update
|
||||||
server.updateMux(tt.updates)
|
server.updateMux(tt.updates)
|
||||||
|
|
||||||
// Verify the results
|
// Verify the results
|
||||||
assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxMap),
|
assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxHandlers),
|
||||||
"Number of handlers after update doesn't match expected")
|
"Number of handlers after update doesn't match expected")
|
||||||
|
|
||||||
// Check each expected handler
|
// Check each expected handler
|
||||||
for id, expectedDomain := range tt.expectedHandlers {
|
for id, expectedDomain := range tt.expectedHandlers {
|
||||||
handler, exists := server.dnsMuxMap[types.HandlerID(id)]
|
var found *handlerWrapper
|
||||||
assert.True(t, exists, "Expected handler %s not found", id)
|
for i := range server.dnsMuxHandlers {
|
||||||
if exists {
|
if server.dnsMuxHandlers[i].handler.ID() == types.HandlerID(id) {
|
||||||
assert.Equal(t, expectedDomain, handler.domain,
|
found = &server.dnsMuxHandlers[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.NotNil(t, found, "Expected handler %s not found", id)
|
||||||
|
if found != nil {
|
||||||
|
assert.Equal(t, expectedDomain, found.domain,
|
||||||
"Domain mismatch for handler %s", id)
|
"Domain mismatch for handler %s", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify no unexpected handlers exist
|
// Verify no unexpected handlers exist
|
||||||
for HandlerID := range server.dnsMuxMap {
|
for _, entry := range server.dnsMuxHandlers {
|
||||||
_, expected := tt.expectedHandlers[string(HandlerID)]
|
_, expected := tt.expectedHandlers[string(entry.handler.ID())]
|
||||||
assert.True(t, expected, "Unexpected handler found: %s", HandlerID)
|
assert.True(t, expected, "Unexpected handler found: %s", entry.handler.ID())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the handlerChain state and order
|
// Verify the handlerChain state and order
|
||||||
@@ -1413,7 +1404,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
|
|||||||
|
|
||||||
// Verify handler exists in mux
|
// Verify handler exists in mux
|
||||||
foundInMux := false
|
foundInMux := false
|
||||||
for _, muxEntry := range server.dnsMuxMap {
|
for _, muxEntry := range server.dnsMuxHandlers {
|
||||||
if chainEntry.Handler == muxEntry.handler &&
|
if chainEntry.Handler == muxEntry.handler &&
|
||||||
chainEntry.Priority == muxEntry.priority &&
|
chainEntry.Priority == muxEntry.priority &&
|
||||||
chainEntry.Pattern == dns.Fqdn(muxEntry.domain) {
|
chainEntry.Pattern == dns.Fqdn(muxEntry.domain) {
|
||||||
@@ -1422,12 +1413,108 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.True(t, foundInMux,
|
assert.True(t, foundInMux,
|
||||||
"Handler in chain not found in dnsMuxMap")
|
"Handler in chain not found in dnsMuxHandlers")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chainHasPattern reports whether the handler chain holds an entry registered
|
||||||
|
// for the given fqdn pattern at the given priority.
|
||||||
|
func chainHasPattern(s *DefaultServer, pattern string, priority int) bool {
|
||||||
|
for _, h := range s.handlerChain.handlers {
|
||||||
|
if h.OrigPattern == pattern && h.Priority == priority {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval verifies that updateMux
|
||||||
|
// tracks each (handler, domain) registration independently when one handler
|
||||||
|
// serves multiple zones. Every custom zone is served by the same handler
|
||||||
|
// instance (the local resolver, whose ID is the constant "local-resolver"), so
|
||||||
|
// removing one zone must deregister exactly that zone's chain entry and leave
|
||||||
|
// the others in place. Tracking registrations by handler ID alone collapses all
|
||||||
|
// zones onto one entry, leaving removed zones in the chain to answer
|
||||||
|
// authoritatively with no records.
|
||||||
|
func TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval(t *testing.T) {
|
||||||
|
// One handler serves every custom zone, mirroring s.localResolver.
|
||||||
|
shared := &mockHandler{Id: "local-resolver"}
|
||||||
|
|
||||||
|
server := &DefaultServer{
|
||||||
|
handlerChain: NewHandlerChain(),
|
||||||
|
service: &mockService{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two custom zones under the same handler. The surviving zone is registered
|
||||||
|
// last, mirroring the management emission order.
|
||||||
|
server.updateMux([]handlerWrapper{
|
||||||
|
{domain: "userzone.test", handler: shared, priority: PriorityLocal},
|
||||||
|
{domain: "peerzone.test", handler: shared, priority: PriorityLocal},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.True(t, chainHasPattern(server, "userzone.test.", PriorityLocal),
|
||||||
|
"userzone.test should be registered after the first update")
|
||||||
|
require.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal),
|
||||||
|
"peerzone.test should be registered after the first update")
|
||||||
|
|
||||||
|
// Remove one zone, keep the other.
|
||||||
|
server.updateMux([]handlerWrapper{
|
||||||
|
{domain: "peerzone.test", handler: shared, priority: PriorityLocal},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal),
|
||||||
|
"peerzone.test should remain after removing userzone.test")
|
||||||
|
assert.False(t, chainHasPattern(server, "userzone.test.", PriorityLocal),
|
||||||
|
"userzone.test handler must be deregistered, not leaked in the chain")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultServer_UpdateMux_PreservesLocalResolver verifies that updateMux
|
||||||
|
// does not tear down the shared local resolver during reconfiguration. The
|
||||||
|
// resolver is a process-lifetime singleton reused across config updates;
|
||||||
|
// Stop() cancels its lookup context (breaking external CNAME-target
|
||||||
|
// resolution) and clears its records. updateMux must deregister its chain
|
||||||
|
// entries without stopping it. Records surviving a teardown update is the
|
||||||
|
// observable proxy: Stop() would have cleared them.
|
||||||
|
func TestDefaultServer_UpdateMux_PreservesLocalResolver(t *testing.T) {
|
||||||
|
resolver := local.NewResolver()
|
||||||
|
require.NoError(t, resolver.RegisterRecord(nbdns.SimpleRecord{
|
||||||
|
Name: "peer.netbird.cloud.",
|
||||||
|
Type: int(dns.TypeA),
|
||||||
|
Class: nbdns.DefaultClass,
|
||||||
|
TTL: 300,
|
||||||
|
RData: "10.0.0.1",
|
||||||
|
}))
|
||||||
|
|
||||||
|
server := &DefaultServer{
|
||||||
|
handlerChain: NewHandlerChain(),
|
||||||
|
service: &mockService{},
|
||||||
|
localResolver: resolver,
|
||||||
|
}
|
||||||
|
|
||||||
|
server.updateMux([]handlerWrapper{
|
||||||
|
{domain: "netbird.cloud", handler: resolver, priority: PriorityLocal},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove the zone. The resolver must survive so its records and lookup
|
||||||
|
// context stay intact for the next registration.
|
||||||
|
server.updateMux(nil)
|
||||||
|
|
||||||
|
var response *dns.Msg
|
||||||
|
resolver.ServeDNS(&test.MockResponseWriter{
|
||||||
|
WriteMsgFunc: func(m *dns.Msg) error {
|
||||||
|
response = m
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, &dns.Msg{Question: []dns.Question{{Name: "peer.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}})
|
||||||
|
|
||||||
|
require.NotNil(t, response, "local resolver should answer after teardown")
|
||||||
|
assert.Equal(t, dns.RcodeSuccess, response.Rcode,
|
||||||
|
"local resolver records must survive teardown; updateMux must not Stop() the shared resolver")
|
||||||
|
assert.NotEmpty(t, response.Answer, "answer should contain the surviving record")
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtraDomains(t *testing.T) {
|
func TestExtraDomains(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -2049,7 +2136,6 @@ func TestBuildUpstreamHandler_MergesGroupsPerDomain(t *testing.T) {
|
|||||||
localResolver: local.NewResolver(),
|
localResolver: local.NewResolver(),
|
||||||
handlerChain: NewHandlerChain(),
|
handlerChain: NewHandlerChain(),
|
||||||
hostManager: &noopHostConfigurator{},
|
hostManager: &noopHostConfigurator{},
|
||||||
dnsMuxMap: make(registeredHandlerMap),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := []*nbdns.NameServerGroup{
|
groups := []*nbdns.NameServerGroup{
|
||||||
@@ -2207,7 +2293,7 @@ func TestEvaluateNSGroupHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// healthStubHandler is a minimal dnsMuxMap entry that exposes a fixed
|
// healthStubHandler is a minimal dnsMuxHandlers entry that exposes a fixed
|
||||||
// UpstreamHealth snapshot, letting tests drive recomputeNSGroupStates
|
// UpstreamHealth snapshot, letting tests drive recomputeNSGroupStates
|
||||||
// without spinning up real handlers.
|
// without spinning up real handlers.
|
||||||
type healthStubHandler struct {
|
type healthStubHandler struct {
|
||||||
@@ -2283,12 +2369,11 @@ func newProjTestFixture(t *testing.T) *projTestFixture {
|
|||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
wgInterface: &mocWGIface{},
|
wgInterface: &mocWGIface{},
|
||||||
statusRecorder: recorder,
|
statusRecorder: recorder,
|
||||||
dnsMuxMap: make(registeredHandlerMap),
|
|
||||||
selectedRoutes: func() route.HAMap { return fx.selected },
|
selectedRoutes: func() route.HAMap { return fx.selected },
|
||||||
activeRoutes: func() route.HAMap { return fx.active },
|
activeRoutes: func() route.HAMap { return fx.active },
|
||||||
warningDelayBase: defaultWarningDelayBase,
|
warningDelayBase: defaultWarningDelayBase,
|
||||||
}
|
}
|
||||||
fx.server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}
|
fx.server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}}
|
||||||
|
|
||||||
fx.server.mux.Lock()
|
fx.server.mux.Lock()
|
||||||
fx.server.updateNSGroupStates([]*nbdns.NameServerGroup{fx.group})
|
fx.server.updateNSGroupStates([]*nbdns.NameServerGroup{fx.group})
|
||||||
@@ -2395,7 +2480,6 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) {
|
|||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
wgInterface: &mocWGIface{},
|
wgInterface: &mocWGIface{},
|
||||||
statusRecorder: recorder,
|
statusRecorder: recorder,
|
||||||
dnsMuxMap: make(registeredHandlerMap),
|
|
||||||
selectedRoutes: func() route.HAMap { return nil },
|
selectedRoutes: func() route.HAMap { return nil },
|
||||||
activeRoutes: func() route.HAMap { return nil },
|
activeRoutes: func() route.HAMap { return nil },
|
||||||
warningDelayBase: 50 * time.Millisecond,
|
warningDelayBase: 50 * time.Millisecond,
|
||||||
@@ -2407,7 +2491,7 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) {
|
|||||||
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{
|
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{
|
||||||
overlayPeer: {LastFail: time.Now(), LastErr: "timeout"},
|
overlayPeer: {LastFail: time.Now(), LastErr: "timeout"},
|
||||||
}}
|
}}
|
||||||
server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
|
server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
|
||||||
|
|
||||||
server.mux.Lock()
|
server.mux.Lock()
|
||||||
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
|
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
|
||||||
@@ -2444,7 +2528,6 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
|
|||||||
service: NewServiceViaMemory(wgIface),
|
service: NewServiceViaMemory(wgIface),
|
||||||
hostManager: &noopHostConfigurator{},
|
hostManager: &noopHostConfigurator{},
|
||||||
extraDomains: map[domain.Domain]int{},
|
extraDomains: map[domain.Domain]int{},
|
||||||
dnsMuxMap: make(registeredHandlerMap),
|
|
||||||
statusRecorder: peer.NewRecorder("mgm"),
|
statusRecorder: peer.NewRecorder("mgm"),
|
||||||
selectedRoutes: func() route.HAMap { return nil },
|
selectedRoutes: func() route.HAMap { return nil },
|
||||||
activeRoutes: func() route.HAMap { return nil },
|
activeRoutes: func() route.HAMap { return nil },
|
||||||
@@ -2459,7 +2542,7 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
|
|||||||
NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}},
|
NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}},
|
||||||
}
|
}
|
||||||
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{srv: {LastOk: time.Now()}}}
|
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{srv: {LastOk: time.Now()}}}
|
||||||
server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
|
server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
|
||||||
|
|
||||||
server.mux.Lock()
|
server.mux.Lock()
|
||||||
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
|
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
|
||||||
@@ -2484,6 +2567,32 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
|
|||||||
// rule 3: startup failures while the peer is handshaking, then the peer
|
// rule 3: startup failures while the peer is handshaking, then the peer
|
||||||
// comes up and a query succeeds before the grace window elapses. No
|
// comes up and a query succeeds before the grace window elapses. No
|
||||||
// warning should ever have fired, and no recovery either.
|
// warning should ever have fired, and no recovery either.
|
||||||
|
func TestWarningDelayBaseFromEnv(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
val string
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{name: "unset uses default", set: false, want: defaultWarningDelayBase},
|
||||||
|
{name: "valid override", set: true, val: "90s", want: 90 * time.Second},
|
||||||
|
{name: "valid minutes", set: true, val: "2m", want: 2 * time.Minute},
|
||||||
|
{name: "invalid falls back", set: true, val: "notaduration", want: defaultWarningDelayBase},
|
||||||
|
{name: "zero falls back", set: true, val: "0s", want: defaultWarningDelayBase},
|
||||||
|
{name: "negative falls back", set: true, val: "-30s", want: defaultWarningDelayBase},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv(envWarningDelay, tc.val)
|
||||||
|
if !tc.set {
|
||||||
|
os.Unsetenv(envWarningDelay)
|
||||||
|
}
|
||||||
|
assert.Equal(t, tc.want, warningDelayBaseFromEnv(), "grace window base")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProjection_OverlayRecoversDuringGrace(t *testing.T) {
|
func TestProjection_OverlayRecoversDuringGrace(t *testing.T) {
|
||||||
fx := newProjTestFixture(t)
|
fx := newProjTestFixture(t)
|
||||||
fx.server.warningDelayBase = 200 * time.Millisecond
|
fx.server.warningDelayBase = 200 * time.Millisecond
|
||||||
@@ -2595,7 +2704,6 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) {
|
|||||||
server := &DefaultServer{
|
server := &DefaultServer{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
statusRecorder: recorder,
|
statusRecorder: recorder,
|
||||||
dnsMuxMap: make(registeredHandlerMap),
|
|
||||||
selectedRoutes: func() route.HAMap { return overlayMap },
|
selectedRoutes: func() route.HAMap { return overlayMap },
|
||||||
activeRoutes: func() route.HAMap { return nil },
|
activeRoutes: func() route.HAMap { return nil },
|
||||||
warningDelayBase: time.Hour,
|
warningDelayBase: time.Hour,
|
||||||
@@ -2613,7 +2721,7 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) {
|
|||||||
overlay: {LastFail: time.Now(), LastErr: "timeout"},
|
overlay: {LastFail: time.Now(), LastErr: "timeout"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
|
server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
|
||||||
|
|
||||||
server.mux.Lock()
|
server.mux.Lock()
|
||||||
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
|
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
|
||||||
@@ -2640,7 +2748,6 @@ func TestDNSLoopPrevention(t *testing.T) {
|
|||||||
localResolver: local.NewResolver(),
|
localResolver: local.NewResolver(),
|
||||||
handlerChain: NewHandlerChain(),
|
handlerChain: NewHandlerChain(),
|
||||||
hostManager: &noopHostConfigurator{},
|
hostManager: &noopHostConfigurator{},
|
||||||
dnsMuxMap: make(registeredHandlerMap),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -443,29 +443,32 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M
|
|||||||
return raceResult{}, &upstreamFailure{upstream: upstream, reason: "no response"}
|
return raceResult{}, &upstreamFailure{upstream: upstream, reason: "no response"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A valid response means the upstream is reachable, whatever the Rcode.
|
||||||
|
u.markUpstreamOk(upstream)
|
||||||
|
|
||||||
proto := ""
|
proto := ""
|
||||||
if upstreamProto != nil {
|
if upstreamProto != nil {
|
||||||
proto = upstreamProto.protocol
|
proto = upstreamProto.protocol
|
||||||
}
|
}
|
||||||
|
|
||||||
if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused {
|
if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused {
|
||||||
|
// SERVFAIL and REFUSED are per-question outcomes (DNSSEC-bogus names,
|
||||||
|
// refused zones, transient recursion errors), not reachability
|
||||||
|
// problems: fail over for a better answer but keep the upstream healthy.
|
||||||
if code, ok := nonRetryableEDE(rm); ok {
|
if code, ok := nonRetryableEDE(rm); ok {
|
||||||
if !hadEdns {
|
if !hadEdns {
|
||||||
stripOPT(rm)
|
resutil.StripOPT(rm)
|
||||||
}
|
}
|
||||||
u.markUpstreamOk(upstream)
|
|
||||||
return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil
|
return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil
|
||||||
}
|
}
|
||||||
reason := dns.RcodeToString[rm.Rcode]
|
reason := dns.RcodeToString[rm.Rcode]
|
||||||
u.markUpstreamFail(upstream, reason)
|
|
||||||
return raceResult{}, &upstreamFailure{upstream: upstream, reason: reason}
|
return raceResult{}, &upstreamFailure{upstream: upstream, reason: reason}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hadEdns {
|
if !hadEdns {
|
||||||
stripOPT(rm)
|
resutil.StripOPT(rm)
|
||||||
}
|
}
|
||||||
|
|
||||||
u.markUpstreamOk(upstream)
|
|
||||||
return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil
|
return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,22 +523,6 @@ func upstreamUDPSize() uint16 {
|
|||||||
return dns.MinMsgSize
|
return dns.MinMsgSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripOPT removes any OPT pseudo-RRs from the response's Extra section so
|
|
||||||
// the response complies with RFC 6891 when the client did not advertise EDNS0.
|
|
||||||
func stripOPT(rm *dns.Msg) {
|
|
||||||
if len(rm.Extra) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out := rm.Extra[:0]
|
|
||||||
for _, rr := range rm.Extra {
|
|
||||||
if _, ok := rr.(*dns.OPT); ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, rr)
|
|
||||||
}
|
|
||||||
rm.Extra = out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure {
|
func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure {
|
||||||
if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) {
|
if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) {
|
||||||
return &upstreamFailure{upstream: upstream, reason: err.Error()}
|
return &upstreamFailure{upstream: upstream, reason: err.Error()}
|
||||||
|
|||||||
@@ -517,6 +517,78 @@ func TestUpstreamResolver_HealthTracking(t *testing.T) {
|
|||||||
assert.NotContains(t, health, bad, "sibling upstream should not be queried when primary answers")
|
assert.NotContains(t, health, bad, "sibling upstream should not be queried when primary answers")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUpstreamResolver_HealthTracking_ResponseMeansReachable verifies that an
|
||||||
|
// upstream which answers with SERVFAIL or REFUSED is recorded as healthy:
|
||||||
|
// those are per-question outcomes from a reachable server and must not mark
|
||||||
|
// the upstream unhealthy. Only transport failures (timeouts) do.
|
||||||
|
func TestUpstreamResolver_HealthTracking_ResponseMeansReachable(t *testing.T) {
|
||||||
|
a := netip.MustParseAddrPort("192.0.2.10:53")
|
||||||
|
b := netip.MustParseAddrPort("192.0.2.11:53")
|
||||||
|
timeoutErr := &net.OpError{Op: "read", Err: fmt.Errorf("i/o timeout")}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
respA mockUpstreamResponse
|
||||||
|
respB mockUpstreamResponse
|
||||||
|
wantHealthy bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "both SERVFAIL are reachable",
|
||||||
|
respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")},
|
||||||
|
respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")},
|
||||||
|
wantHealthy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both REFUSED are reachable",
|
||||||
|
respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")},
|
||||||
|
respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")},
|
||||||
|
wantHealthy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timeout marks unhealthy",
|
||||||
|
respA: mockUpstreamResponse{err: timeoutErr},
|
||||||
|
respB: mockUpstreamResponse{err: timeoutErr},
|
||||||
|
wantHealthy: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
mockClient := &mockUpstreamResolverPerServer{
|
||||||
|
responses: map[string]mockUpstreamResponse{
|
||||||
|
a.String(): tc.respA,
|
||||||
|
b.String(): tc.respB,
|
||||||
|
},
|
||||||
|
rtt: time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resolver := &upstreamResolverBase{
|
||||||
|
ctx: ctx,
|
||||||
|
upstreamClient: mockClient,
|
||||||
|
upstreamTimeout: UpstreamTimeout,
|
||||||
|
}
|
||||||
|
resolver.addRace([]netip.AddrPort{a, b})
|
||||||
|
|
||||||
|
responseWriter := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }}
|
||||||
|
resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA))
|
||||||
|
|
||||||
|
health := resolver.UpstreamHealth()
|
||||||
|
require.Contains(t, health, a, "primary upstream should have a health record")
|
||||||
|
if tc.wantHealthy {
|
||||||
|
assert.False(t, health[a].LastOk.IsZero(), "responding upstream should have LastOk set")
|
||||||
|
assert.True(t, health[a].LastFail.IsZero(), "responding upstream should not be marked failed")
|
||||||
|
assert.Empty(t, health[a].LastErr, "responding upstream should have no error")
|
||||||
|
} else {
|
||||||
|
assert.False(t, health[a].LastFail.IsZero(), "timed-out upstream should be marked failed")
|
||||||
|
assert.NotEmpty(t, health[a].LastErr, "timed-out upstream should record an error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatFailures(t *testing.T) {
|
func TestFormatFailures(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -913,19 +985,6 @@ func TestEDEName(t *testing.T) {
|
|||||||
assert.Equal(t, "EDE 9999", edeName(9999), "unknown code falls back to numeric")
|
assert.Equal(t, "EDE 9999", edeName(9999), "unknown code falls back to numeric")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripOPT(t *testing.T) {
|
|
||||||
rm := &dns.Msg{
|
|
||||||
Extra: []dns.RR{
|
|
||||||
&dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}},
|
|
||||||
&dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
stripOPT(rm)
|
|
||||||
assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept")
|
|
||||||
_, isOPT := rm.Extra[0].(*dns.OPT)
|
|
||||||
assert.False(t, isOPT, "remaining record must not be OPT")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) {
|
func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) {
|
||||||
upstream1 := netip.MustParseAddrPort("192.0.2.1:53")
|
upstream1 := netip.MustParseAddrPort("192.0.2.1:53")
|
||||||
upstream2 := netip.MustParseAddrPort("192.0.2.2:53")
|
upstream2 := netip.MustParseAddrPort("192.0.2.2:53")
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ import (
|
|||||||
const errResolveFailed = "failed to resolve query for domain=%s: %v"
|
const errResolveFailed = "failed to resolve query for domain=%s: %v"
|
||||||
const upstreamTimeout = 15 * time.Second
|
const upstreamTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
// EDE info codes the forwarder emits on upstream failures so the querying
|
||||||
|
// client can see the reason without inspecting this peer's logs. They live in
|
||||||
|
// the RFC 8914 Private Use range (49152-65535); the Go resolver never exposes a
|
||||||
|
// real upstream EDE here, so these cannot collide with a genuine code.
|
||||||
|
const (
|
||||||
|
edeNetbirdUpstreamTimeout uint16 = 49152
|
||||||
|
edeNetbirdUpstreamFailure uint16 = 49153
|
||||||
|
)
|
||||||
|
|
||||||
type resolver interface {
|
type resolver interface {
|
||||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||||
}
|
}
|
||||||
@@ -220,7 +229,7 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
|
|||||||
|
|
||||||
result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype)
|
result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype)
|
||||||
if result.Err != nil {
|
if result.Err != nil {
|
||||||
f.handleDNSError(ctx, logger, w, question, resp, qname, result, startTime)
|
f.handleDNSError(ctx, logger, w, question, resp, qname, result, query.IsEdns0() != nil, startTime)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +342,7 @@ func (f *DNSForwarder) handleDNSError(
|
|||||||
resp *dns.Msg,
|
resp *dns.Msg,
|
||||||
domain string,
|
domain string,
|
||||||
result resutil.LookupResult,
|
result resutil.LookupResult,
|
||||||
|
reqHasEdns bool,
|
||||||
startTime time.Time,
|
startTime time.Time,
|
||||||
) {
|
) {
|
||||||
qType := question.Qtype
|
qType := question.Qtype
|
||||||
@@ -374,6 +384,10 @@ func (f *DNSForwarder) handleDNSError(
|
|||||||
logger.Warnf(errResolveFailed, domain, result.Err)
|
logger.Warnf(errResolveFailed, domain, result.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if reqHasEdns {
|
||||||
|
attachEDE(resp, edeCodeFor(dnsErr), edeText(dnsErr))
|
||||||
|
}
|
||||||
|
|
||||||
f.writeResponse(logger, w, resp, domain, startTime)
|
f.writeResponse(logger, w, resp, domain, startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,3 +428,33 @@ func (f *DNSForwarder) getMatchingEntries(domain string) (route.ResID, []*Forwar
|
|||||||
|
|
||||||
return selectedResId, matches
|
return selectedResId, matches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// edeCodeFor maps an upstream lookup error to the NetBird EDE info code.
|
||||||
|
func edeCodeFor(dnsErr *net.DNSError) uint16 {
|
||||||
|
if dnsErr != nil && dnsErr.IsTimeout {
|
||||||
|
return edeNetbirdUpstreamTimeout
|
||||||
|
}
|
||||||
|
return edeNetbirdUpstreamFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
// edeText builds the EDE extra-text describing the class of upstream failure.
|
||||||
|
// It deliberately omits the upstream server address, which may be an internal
|
||||||
|
// resolver and is exposed to any client permitted to use the route; the full
|
||||||
|
// detail stays in the forwarder's local log.
|
||||||
|
func edeText(dnsErr *net.DNSError) string {
|
||||||
|
if dnsErr != nil && dnsErr.IsTimeout {
|
||||||
|
return "netbird forwarder: upstream timeout"
|
||||||
|
}
|
||||||
|
return "netbird forwarder: upstream failure"
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachEDE adds an Extended DNS Error (RFC 8914) option to the response,
|
||||||
|
// creating the OPT pseudo-record if the response does not already carry one.
|
||||||
|
func attachEDE(resp *dns.Msg, code uint16, text string) {
|
||||||
|
opt := resp.IsEdns0()
|
||||||
|
if opt == nil {
|
||||||
|
resp.SetEdns0(dns.DefaultMsgSize, false)
|
||||||
|
opt = resp.IsEdns0()
|
||||||
|
}
|
||||||
|
opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/dns/resutil"
|
||||||
"github.com/netbirdio/netbird/client/internal/dns/test"
|
"github.com/netbirdio/netbird/client/internal/dns/test"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
@@ -617,6 +618,85 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDNSForwarder_UpstreamFailureEDE(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
lookupErr error
|
||||||
|
reqEdns bool
|
||||||
|
wantEDE bool
|
||||||
|
wantCode uint16
|
||||||
|
wantTextHas string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "timeout with edns0",
|
||||||
|
lookupErr: &net.DNSError{Err: "i/o timeout", Server: "10.0.0.53:53", IsTimeout: true},
|
||||||
|
reqEdns: true,
|
||||||
|
wantEDE: true,
|
||||||
|
wantCode: edeNetbirdUpstreamTimeout,
|
||||||
|
wantTextHas: "netbird forwarder: upstream timeout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server failure with edns0",
|
||||||
|
lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"},
|
||||||
|
reqEdns: true,
|
||||||
|
wantEDE: true,
|
||||||
|
wantCode: edeNetbirdUpstreamFailure,
|
||||||
|
wantTextHas: "netbird forwarder: upstream failure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no edns0 in request omits ede",
|
||||||
|
lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"},
|
||||||
|
reqEdns: false,
|
||||||
|
wantEDE: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
mockResolver := &MockResolver{}
|
||||||
|
forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil)
|
||||||
|
forwarder.resolver = mockResolver
|
||||||
|
|
||||||
|
d, err := domain.FromString("example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}})
|
||||||
|
|
||||||
|
mockResolver.On("LookupNetIP", mock.Anything, "ip4", "example.com.").
|
||||||
|
Return([]netip.Addr(nil), tt.lookupErr).Once()
|
||||||
|
|
||||||
|
query := &dns.Msg{}
|
||||||
|
query.SetQuestion("example.com.", dns.TypeA)
|
||||||
|
if tt.reqEdns {
|
||||||
|
query.SetEdns0(dns.DefaultMsgSize, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
var writtenResp *dns.Msg
|
||||||
|
mockWriter := &test.MockResponseWriter{
|
||||||
|
WriteMsgFunc: func(m *dns.Msg) error {
|
||||||
|
writtenResp = m
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
|
||||||
|
mockResolver.AssertExpectations(t)
|
||||||
|
|
||||||
|
require.NotNil(t, writtenResp, "expected a response")
|
||||||
|
assert.Equal(t, dns.RcodeServerFailure, writtenResp.Rcode, "upstream failure must be SERVFAIL")
|
||||||
|
|
||||||
|
ede, ok := resutil.ExtractEDE(writtenResp)
|
||||||
|
if !tt.wantEDE {
|
||||||
|
assert.False(t, ok, "response must not carry EDE")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.True(t, ok, "response must carry EDE")
|
||||||
|
assert.Equal(t, tt.wantCode, ede.InfoCode, "EDE info code")
|
||||||
|
assert.Contains(t, ede.ExtraText, tt.wantTextHas, "EDE extra-text")
|
||||||
|
assert.NotContains(t, ede.ExtraText, "10.0.0.53", "must not leak upstream server address")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDNSForwarder_TCPTruncation(t *testing.T) {
|
func TestDNSForwarder_TCPTruncation(t *testing.T) {
|
||||||
// Test that large UDP responses are truncated with TC bit set
|
// Test that large UDP responses are truncated with TC bit set
|
||||||
mockResolver := &MockResolver{}
|
mockResolver := &MockResolver{}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
|
|
||||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||||
"github.com/netbirdio/netbird/client/firewall"
|
"github.com/netbirdio/netbird/client/firewall"
|
||||||
@@ -54,8 +53,8 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/relay"
|
"github.com/netbirdio/netbird/client/internal/relay"
|
||||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
|
|
||||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/syncstore"
|
||||||
"github.com/netbirdio/netbird/client/internal/updater"
|
"github.com/netbirdio/netbird/client/internal/updater"
|
||||||
"github.com/netbirdio/netbird/client/jobexec"
|
"github.com/netbirdio/netbird/client/jobexec"
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
@@ -72,6 +71,7 @@ import (
|
|||||||
sProto "github.com/netbirdio/netbird/shared/signal/proto"
|
sProto "github.com/netbirdio/netbird/shared/signal/proto"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
"github.com/netbirdio/netbird/util/capture"
|
"github.com/netbirdio/netbird/util/capture"
|
||||||
|
"github.com/netbirdio/netbird/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
|
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
|
||||||
@@ -86,6 +86,8 @@ const (
|
|||||||
|
|
||||||
var ErrResetConnection = fmt.Errorf("reset connection")
|
var ErrResetConnection = fmt.Errorf("reset connection")
|
||||||
|
|
||||||
|
var ErrEngineAlreadyStarted = errors.New("engine already started")
|
||||||
|
|
||||||
type EngineConfig struct {
|
type EngineConfig struct {
|
||||||
WgPort int
|
WgPort int
|
||||||
WgIfaceName string
|
WgIfaceName string
|
||||||
@@ -148,6 +150,10 @@ type EngineConfig struct {
|
|||||||
|
|
||||||
LogPath string
|
LogPath string
|
||||||
TempDir string
|
TempDir string
|
||||||
|
|
||||||
|
// StateDir is the directory holding the state file. The sync response
|
||||||
|
// (network map) is serialized here on platforms that persist it to disk.
|
||||||
|
StateDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// EngineServices holds the external service dependencies required by the Engine.
|
// EngineServices holds the external service dependencies required by the Engine.
|
||||||
@@ -195,6 +201,8 @@ type Engine struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
started bool
|
||||||
|
|
||||||
wgInterface WGIface
|
wgInterface WGIface
|
||||||
|
|
||||||
udpMux *udpmux.UniversalUDPMuxDefault
|
udpMux *udpmux.UniversalUDPMuxDefault
|
||||||
@@ -226,11 +234,16 @@ type Engine struct {
|
|||||||
|
|
||||||
afpacketCapture *capture.AFPacketCapture
|
afpacketCapture *capture.AFPacketCapture
|
||||||
|
|
||||||
// Sync response persistence (protected by syncRespMux)
|
// Sync response persistence (protected by syncRespMux).
|
||||||
syncRespMux sync.RWMutex
|
// syncStore is nil unless persistence has been enabled; its presence is
|
||||||
persistSyncResponse bool
|
// what marks persistence as active. The backend (disk or memory) is
|
||||||
latestSyncResponse *mgmProto.SyncResponse
|
// selected per-platform; see the syncstore package. syncStoreDir is where
|
||||||
flowManager nftypes.FlowManager
|
// a disk-backed store serializes to.
|
||||||
|
syncRespMux sync.RWMutex
|
||||||
|
syncStore syncstore.Store
|
||||||
|
syncStoreDir string
|
||||||
|
|
||||||
|
flowManager nftypes.FlowManager
|
||||||
|
|
||||||
// auto-update
|
// auto-update
|
||||||
updateManager *updater.Manager
|
updateManager *updater.Manager
|
||||||
@@ -270,9 +283,15 @@ func NewEngine(
|
|||||||
services EngineServices,
|
services EngineServices,
|
||||||
mobileDep MobileDependency,
|
mobileDep MobileDependency,
|
||||||
) *Engine {
|
) *Engine {
|
||||||
|
// The engine is single-use: a fresh instance is built per connection
|
||||||
|
// cycle (see Client.run), so the run context is created once here rather
|
||||||
|
// than in Start.
|
||||||
|
ctx, cancel := context.WithCancel(clientCtx)
|
||||||
engine := &Engine{
|
engine := &Engine{
|
||||||
clientCtx: clientCtx,
|
clientCtx: clientCtx,
|
||||||
clientCancel: clientCancel,
|
clientCancel: clientCancel,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
signal: services.SignalClient,
|
signal: services.SignalClient,
|
||||||
signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey),
|
signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey),
|
||||||
mgmClient: services.MgmClient,
|
mgmClient: services.MgmClient,
|
||||||
@@ -292,6 +311,7 @@ func NewEngine(
|
|||||||
jobExecutor: jobexec.NewExecutor(),
|
jobExecutor: jobexec.NewExecutor(),
|
||||||
clientMetrics: services.ClientMetrics,
|
clientMetrics: services.ClientMetrics,
|
||||||
updateManager: services.UpdateManager,
|
updateManager: services.UpdateManager,
|
||||||
|
syncStoreDir: config.StateDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
||||||
@@ -304,8 +324,34 @@ func (e *Engine) Stop() error {
|
|||||||
log.Debugf("tried stopping engine that is nil")
|
log.Debugf("tried stopping engine that is nil")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
e.cancel()
|
||||||
e.syncMsgMux.Lock()
|
e.syncMsgMux.Lock()
|
||||||
|
|
||||||
|
e.stopLocked()
|
||||||
|
|
||||||
|
e.syncMsgMux.Unlock()
|
||||||
|
|
||||||
|
timeout := e.calculateShutdownTimeout()
|
||||||
|
log.Debugf("waiting for goroutines to finish with timeout: %v", timeout)
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil {
|
||||||
|
log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("stopped Netbird Engine")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopLocked tears down everything Start may have brought up, in the order
|
||||||
|
// teardown requires (DNS before the interface goes down, flow manager after).
|
||||||
|
// The caller must hold syncMsgMux. It is shared by Stop and by Start's failure
|
||||||
|
// path, so a partially-initialized engine is cleaned up the same way; every
|
||||||
|
// step is nil-guarded. It does not wait on shutdownWg — the caller does that
|
||||||
|
// after releasing the lock, since the goroutines also take syncMsgMux.
|
||||||
|
func (e *Engine) stopLocked() {
|
||||||
if e.connMgr != nil {
|
if e.connMgr != nil {
|
||||||
e.connMgr.Close()
|
e.connMgr.Close()
|
||||||
}
|
}
|
||||||
@@ -356,10 +402,6 @@ func (e *Engine) Stop() error {
|
|||||||
// so dbus and friends don't complain because of a missing interface
|
// so dbus and friends don't complain because of a missing interface
|
||||||
e.stopDNSServer()
|
e.stopDNSServer()
|
||||||
|
|
||||||
if e.cancel != nil {
|
|
||||||
e.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
e.jobExecutorWG.Wait() // block until job goroutines finish
|
e.jobExecutorWG.Wait() // block until job goroutines finish
|
||||||
|
|
||||||
e.close()
|
e.close()
|
||||||
@@ -378,21 +420,6 @@ func (e *Engine) Stop() error {
|
|||||||
if err := e.stateManager.PersistState(context.Background()); err != nil {
|
if err := e.stateManager.PersistState(context.Background()); err != nil {
|
||||||
log.Errorf("failed to persist state: %v", err)
|
log.Errorf("failed to persist state: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
e.syncMsgMux.Unlock()
|
|
||||||
|
|
||||||
timeout := e.calculateShutdownTimeout()
|
|
||||||
log.Debugf("waiting for goroutines to finish with timeout: %v", timeout)
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil {
|
|
||||||
log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("stopped Netbird Engine")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateShutdownTimeout returns shutdown timeout: 10s base + 100ms per peer, capped at 30s.
|
// calculateShutdownTimeout returns shutdown timeout: 10s base + 100ms per peer, capped at 30s.
|
||||||
@@ -430,18 +457,38 @@ func waitWithContext(ctx context.Context, wg *sync.WaitGroup) error {
|
|||||||
// Start creates a new WireGuard tunnel interface and listens to events from Signal and Management services
|
// Start creates a new WireGuard tunnel interface and listens to events from Signal and Management services
|
||||||
// Connections to remote peers are not established here.
|
// Connections to remote peers are not established here.
|
||||||
// However, they will be established once an event with a list of peers to connect to will be received from Management Service
|
// However, they will be established once an event with a list of peers to connect to will be received from Management Service
|
||||||
func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) error {
|
func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) (err error) {
|
||||||
e.syncMsgMux.Lock()
|
e.syncMsgMux.Lock()
|
||||||
defer e.syncMsgMux.Unlock()
|
defer e.syncMsgMux.Unlock()
|
||||||
|
|
||||||
if err := iface.ValidateMTU(e.config.MTU); err != nil {
|
// The engine is single-use. Reject a duplicate start and a start on an
|
||||||
|
// already-stopped engine (run context cancelled).
|
||||||
|
if e.started {
|
||||||
|
return ErrEngineAlreadyStarted
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctxErr := e.ctx.Err(); ctxErr != nil {
|
||||||
|
return fmt.Errorf("engine already stopped: %w", ctxErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.started = true
|
||||||
|
|
||||||
|
// Tear down any partially-initialized state on a failed start. Cancel the
|
||||||
|
// run context first so goroutines started before the failure (connMgr,
|
||||||
|
// srWatcher, monitors) unwind, then stopLocked mirrors Stop's teardown (we
|
||||||
|
// already hold syncMsgMux), cleaning up route/DNS/flow/state managers too,
|
||||||
|
// not just what close() covers.
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
e.cancel()
|
||||||
|
e.stopLocked()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = iface.ValidateMTU(e.config.MTU); err != nil {
|
||||||
return fmt.Errorf("invalid MTU configuration: %w", err)
|
return fmt.Errorf("invalid MTU configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if e.cancel != nil {
|
|
||||||
e.cancel()
|
|
||||||
}
|
|
||||||
e.ctx, e.cancel = context.WithCancel(e.clientCtx)
|
|
||||||
e.exposeManager = expose.NewManager(e.ctx, e.mgmClient)
|
e.exposeManager = expose.NewManager(e.ctx, e.mgmClient)
|
||||||
|
|
||||||
wgIface, err := e.newWgIface()
|
wgIface, err := e.newWgIface()
|
||||||
@@ -475,13 +522,11 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
|
|
||||||
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
|
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.close()
|
|
||||||
return fmt.Errorf("read initial settings: %w", err)
|
return fmt.Errorf("read initial settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsServer, err := e.newDnsServer(dnsConfig)
|
dnsServer, err := e.newDnsServer(dnsConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.close()
|
|
||||||
return fmt.Errorf("create dns server: %w", err)
|
return fmt.Errorf("create dns server: %w", err)
|
||||||
}
|
}
|
||||||
e.dnsServer = dnsServer
|
e.dnsServer = dnsServer
|
||||||
@@ -516,12 +561,14 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
|
|
||||||
if err = e.wgInterfaceCreate(); err != nil {
|
if err = e.wgInterfaceCreate(); err != nil {
|
||||||
log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error())
|
log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error())
|
||||||
e.close()
|
|
||||||
return fmt.Errorf("create wg interface: %w", err)
|
return fmt.Errorf("create wg interface: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if filteredDevice := e.wgInterface.GetDevice(); filteredDevice != nil {
|
||||||
|
filteredDevice.SetPanicHandler(e.triggerClientRestart)
|
||||||
|
}
|
||||||
|
|
||||||
if err := e.createFirewall(); err != nil {
|
if err := e.createFirewall(); err != nil {
|
||||||
e.close()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +580,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
e.udpMux, err = e.wgInterface.Up()
|
e.udpMux, err = e.wgInterface.Up()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
|
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
|
||||||
e.close()
|
|
||||||
return fmt.Errorf("up wg interface: %w", err)
|
return fmt.Errorf("up wg interface: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,9 +604,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
e.acl = acl.NewDefaultManager(e.firewall)
|
e.acl = acl.NewDefaultManager(e.firewall)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = e.dnsServer.Initialize()
|
if err := e.dnsServer.Initialize(); err != nil {
|
||||||
if err != nil {
|
|
||||||
e.close()
|
|
||||||
return fmt.Errorf("initialize dns server: %w", err)
|
return fmt.Errorf("initialize dns server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +616,9 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
|
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
|
||||||
e.srWatcher.Start(peer.IsForceRelayed())
|
e.srWatcher.Start(peer.IsForceRelayed())
|
||||||
|
|
||||||
e.receiveSignalEvents()
|
if err = e.receiveSignalEvents(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
e.receiveManagementEvents()
|
e.receiveManagementEvents()
|
||||||
e.receiveJobEvents()
|
e.receiveJobEvents()
|
||||||
|
|
||||||
@@ -624,7 +670,6 @@ func (e *Engine) createFirewall() error {
|
|||||||
|
|
||||||
func (e *Engine) initFirewall() error {
|
func (e *Engine) initFirewall() error {
|
||||||
if err := e.routeManager.SetFirewall(e.firewall); err != nil {
|
if err := e.routeManager.SetFirewall(e.firewall); err != nil {
|
||||||
e.close()
|
|
||||||
return fmt.Errorf("set firewall: %w", err)
|
return fmt.Errorf("set firewall: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,63 +914,25 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
if update.GetNetbirdConfig() != nil {
|
if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
|
||||||
wCfg := update.GetNetbirdConfig()
|
return err
|
||||||
err := e.updateTURNs(wCfg.GetTurns())
|
}
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update TURNs: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = e.updateSTUNs(wCfg.GetStuns())
|
// Posture checks are bound to the network map presence:
|
||||||
if err != nil {
|
// NetworkMap != nil, checks present -> apply the received checks
|
||||||
return fmt.Errorf("update STUNs: %w", err)
|
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
|
||||||
}
|
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
|
||||||
|
// leave the previously applied checks untouched
|
||||||
var stunTurn []*stun.URI
|
nm := update.GetNetworkMap()
|
||||||
stunTurn = append(stunTurn, e.STUNs...)
|
if nm == nil {
|
||||||
stunTurn = append(stunTurn, e.TURNs...)
|
return nil
|
||||||
e.stunTurn.Store(stunTurn)
|
|
||||||
|
|
||||||
err = e.handleRelayUpdate(wCfg.GetRelay())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = e.handleFlowUpdate(wCfg.GetFlow())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("handle the flow configuration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
|
|
||||||
log.Warnf("Failed to update DNS server config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo update signal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := e.updateChecksIfNew(update.Checks); err != nil {
|
if err := e.updateChecksIfNew(update.Checks); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
nm := update.GetNetworkMap()
|
e.persistSyncResponse(update)
|
||||||
if nm == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
|
|
||||||
// Read the storage-enabled flag under the syncRespMux too.
|
|
||||||
e.syncRespMux.RLock()
|
|
||||||
enabled := e.persistSyncResponse
|
|
||||||
e.syncRespMux.RUnlock()
|
|
||||||
|
|
||||||
// Store sync response if persistence is enabled
|
|
||||||
if enabled {
|
|
||||||
e.syncRespMux.Lock()
|
|
||||||
e.latestSyncResponse = update
|
|
||||||
e.syncRespMux.Unlock()
|
|
||||||
|
|
||||||
log.Debugf("sync response persisted with serial %d", nm.GetSerial())
|
|
||||||
}
|
|
||||||
|
|
||||||
// only apply new changes and ignore old ones
|
// only apply new changes and ignore old ones
|
||||||
if err := e.updateNetworkMap(nm); err != nil {
|
if err := e.updateNetworkMap(nm); err != nil {
|
||||||
@@ -937,6 +944,64 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateNetbirdConfig applies the management-provided NetBird configuration:
|
||||||
|
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
|
||||||
|
// which is the case for sync updates carrying only a network map.
|
||||||
|
func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error {
|
||||||
|
if wCfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.updateTURNs(wCfg.GetTurns()); err != nil {
|
||||||
|
return fmt.Errorf("update TURNs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.updateSTUNs(wCfg.GetStuns()); err != nil {
|
||||||
|
return fmt.Errorf("update STUNs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stunTurn []*stun.URI
|
||||||
|
stunTurn = append(stunTurn, e.STUNs...)
|
||||||
|
stunTurn = append(stunTurn, e.TURNs...)
|
||||||
|
e.stunTurn.Store(stunTurn)
|
||||||
|
|
||||||
|
if err := e.handleRelayUpdate(wCfg.GetRelay()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.handleFlowUpdate(wCfg.GetFlow()); err != nil {
|
||||||
|
return fmt.Errorf("handle the flow configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
|
||||||
|
log.Warnf("Failed to update DNS server config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo update signal
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistSyncResponse stores the full sync response so it can be restored on the next
|
||||||
|
// startup. Persistence is enabled only when syncStore is set. The dedicated syncRespMux
|
||||||
|
// (not syncMsgMux) is held for the whole Set so the store cannot be cleared (disabled /
|
||||||
|
// engine close) mid-call and have this write resurrect a file that was just removed.
|
||||||
|
func (e *Engine) persistSyncResponse(update *mgmProto.SyncResponse) {
|
||||||
|
e.syncRespMux.RLock()
|
||||||
|
defer e.syncRespMux.RUnlock()
|
||||||
|
|
||||||
|
if e.syncStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.syncStore.Set(update); err != nil {
|
||||||
|
log.Errorf("failed to persist sync response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("sync response persisted with serial %d", update.GetNetworkMap().GetSerial())
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error {
|
func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error {
|
||||||
if update != nil {
|
if update != nil {
|
||||||
// when we receive token we expect valid address list too
|
// when we receive token we expect valid address list too
|
||||||
@@ -1063,6 +1128,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
|||||||
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
|
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
|
||||||
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
|
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
|
||||||
state.FQDN = conf.GetFqdn()
|
state.FQDN = conf.GetFqdn()
|
||||||
|
state.WgPort = e.config.WgPort
|
||||||
|
|
||||||
e.statusRecorder.UpdateLocalPeerState(state)
|
e.statusRecorder.UpdateLocalPeerState(state)
|
||||||
|
|
||||||
@@ -1141,6 +1207,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
|||||||
LogPath: e.config.LogPath,
|
LogPath: e.config.LogPath,
|
||||||
TempDir: e.config.TempDir,
|
TempDir: e.config.TempDir,
|
||||||
ClientMetrics: e.clientMetrics,
|
ClientMetrics: e.clientMetrics,
|
||||||
|
DaemonVersion: version.NetbirdVersion(),
|
||||||
RefreshStatus: func() {
|
RefreshStatus: func() {
|
||||||
e.RunHealthProbes(true)
|
e.RunHealthProbes(true)
|
||||||
},
|
},
|
||||||
@@ -1662,7 +1729,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
|||||||
}
|
}
|
||||||
|
|
||||||
// receiveSignalEvents connects to the Signal Service event stream to negotiate connection with remote peers
|
// receiveSignalEvents connects to the Signal Service event stream to negotiate connection with remote peers
|
||||||
func (e *Engine) receiveSignalEvents() {
|
func (e *Engine) receiveSignalEvents() error {
|
||||||
e.shutdownWg.Add(1)
|
e.shutdownWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer e.shutdownWg.Done()
|
defer e.shutdownWg.Done()
|
||||||
@@ -1678,6 +1745,13 @@ func (e *Engine) receiveSignalEvents() {
|
|||||||
return e.ctx.Err()
|
return e.ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-addressed heartbeat: the signal client's receive watchdog
|
||||||
|
// round-trips this through the server to confirm the receive stream
|
||||||
|
// is delivering. Liveness is already recorded before this handler.
|
||||||
|
if msg.GetBody().GetType() == sProto.Body_HEARTBEAT {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
conn, ok := e.peerStore.PeerConn(msg.Key)
|
conn, ok := e.peerStore.PeerConn(msg.Key)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("wrongly addressed message %s", msg.Key)
|
return fmt.Errorf("wrongly addressed message %s", msg.Key)
|
||||||
@@ -1726,7 +1800,12 @@ func (e *Engine) receiveSignalEvents() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
e.signal.WaitStreamConnected()
|
// todo: consider to remove this blocker. I do not see benefit to block the Start operations
|
||||||
|
e.signal.WaitStreamConnected(e.ctx)
|
||||||
|
if err := e.ctx.Err(); err != nil {
|
||||||
|
return fmt.Errorf("wait for signal stream: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) parseNATExternalIPMappings() []string {
|
func (e *Engine) parseNATExternalIPMappings() []string {
|
||||||
@@ -1813,6 +1892,18 @@ func (e *Engine) close() {
|
|||||||
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
|
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
|
||||||
log.Warnf("failed to gracefully stop port forwarding manager: %s", err)
|
log.Warnf("failed to gracefully stop port forwarding manager: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop any persisted sync response so its network map does not linger on
|
||||||
|
// disk after the engine stops (and cannot leak into a later run).
|
||||||
|
e.syncRespMux.Lock()
|
||||||
|
store := e.syncStore
|
||||||
|
e.syncStore = nil
|
||||||
|
e.syncRespMux.Unlock()
|
||||||
|
if store != nil {
|
||||||
|
if err := store.Clear(); err != nil {
|
||||||
|
log.Warnf("failed to clear persisted sync response on close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
||||||
@@ -1864,7 +1955,6 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
|||||||
WGPrivKey: e.config.WgPrivateKey.String(),
|
WGPrivKey: e.config.WgPrivateKey.String(),
|
||||||
MTU: e.config.MTU,
|
MTU: e.config.MTU,
|
||||||
TransportNet: transportNet,
|
TransportNet: transportNet,
|
||||||
FilterFn: e.addrViaRoutes,
|
|
||||||
DisableDNS: e.config.DisableDNS,
|
DisableDNS: e.config.DisableDNS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1967,6 +2057,29 @@ func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
|
|||||||
return e.clientMetrics
|
return e.clientMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Performance bundles runtime-adjustable tunnel pool knobs.
|
||||||
|
// See Engine.SetPerformance. Nil fields are ignored.
|
||||||
|
type Performance struct {
|
||||||
|
PreallocatedBuffersPerPool *uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPerformance applies the given tuning to this engine's live Device.
|
||||||
|
func (e *Engine) SetPerformance(t Performance) error {
|
||||||
|
e.syncMsgMux.Lock()
|
||||||
|
defer e.syncMsgMux.Unlock()
|
||||||
|
if e.wgInterface == nil {
|
||||||
|
return fmt.Errorf("wg interface not initialized")
|
||||||
|
}
|
||||||
|
dev := e.wgInterface.GetWGDevice()
|
||||||
|
if dev == nil {
|
||||||
|
return fmt.Errorf("wg device not initialized")
|
||||||
|
}
|
||||||
|
if t.PreallocatedBuffersPerPool != nil {
|
||||||
|
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
||||||
iface, err := net.InterfaceByName(ifaceName)
|
iface, err := net.InterfaceByName(ifaceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2089,21 +2202,6 @@ func (e *Engine) startNetworkMonitor() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) addrViaRoutes(addr netip.Addr) (bool, netip.Prefix, error) {
|
|
||||||
var vpnRoutes []netip.Prefix
|
|
||||||
for _, routes := range e.routeManager.GetClientRoutes() {
|
|
||||||
if len(routes) > 0 && routes[0] != nil {
|
|
||||||
vpnRoutes = append(vpnRoutes, routes[0].Network)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isVpn, prefix := systemops.IsAddrRouted(addr, vpnRoutes); isVpn {
|
|
||||||
return true, prefix, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, netip.Prefix{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) stopDNSServer() {
|
func (e *Engine) stopDNSServer() {
|
||||||
if e.dnsServer == nil {
|
if e.dnsServer == nil {
|
||||||
return
|
return
|
||||||
@@ -2119,45 +2217,42 @@ func (e *Engine) stopDNSServer() {
|
|||||||
e.statusRecorder.UpdateDNSStates(nsGroupStates)
|
e.statusRecorder.UpdateDNSStates(nsGroupStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSyncResponsePersistence enables or disables sync response persistence
|
// SetSyncResponsePersistence enables or disables sync response persistence.
|
||||||
|
// The store is only instantiated while persistence is enabled; construction
|
||||||
|
// itself drops any stale data left over from an earlier run (see syncstore).
|
||||||
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
|
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
|
||||||
e.syncRespMux.Lock()
|
e.syncRespMux.Lock()
|
||||||
defer e.syncRespMux.Unlock()
|
defer e.syncRespMux.Unlock()
|
||||||
|
|
||||||
if enabled == e.persistSyncResponse {
|
if enabled == (e.syncStore != nil) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.persistSyncResponse = enabled
|
|
||||||
log.Debugf("Sync response persistence is set to %t", enabled)
|
log.Debugf("Sync response persistence is set to %t", enabled)
|
||||||
|
|
||||||
if !enabled {
|
if !enabled {
|
||||||
e.latestSyncResponse = nil
|
if err := e.syncStore.Clear(); err != nil {
|
||||||
|
log.Warnf("failed to clear persisted sync response: %v", err)
|
||||||
|
}
|
||||||
|
e.syncStore = nil
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.syncStore = syncstore.New(e.syncStoreDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
|
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
|
||||||
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
|
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
|
||||||
|
// Hold the lock for the whole Get so the store cannot be cleared
|
||||||
|
// (disabled / engine close) mid-call.
|
||||||
e.syncRespMux.RLock()
|
e.syncRespMux.RLock()
|
||||||
enabled := e.persistSyncResponse
|
defer e.syncRespMux.RUnlock()
|
||||||
latest := e.latestSyncResponse
|
|
||||||
e.syncRespMux.RUnlock()
|
|
||||||
|
|
||||||
if !enabled {
|
if e.syncStore == nil {
|
||||||
return nil, errors.New("sync response persistence is disabled")
|
return nil, errors.New("sync response persistence is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
if latest == nil {
|
//nolint:nilnil
|
||||||
//nolint:nilnil
|
return e.syncStore.Get()
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(latest))
|
|
||||||
sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("failed to clone sync response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sr, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWgAddr returns the wireguard address
|
// GetWgAddr returns the wireguard address
|
||||||
@@ -2193,7 +2288,7 @@ func (e *Engine) updateDNSForwarder(
|
|||||||
enabled bool,
|
enabled bool,
|
||||||
fwdEntries []*dnsfwd.ForwarderEntry,
|
fwdEntries []*dnsfwd.ForwarderEntry,
|
||||||
) {
|
) {
|
||||||
if e.config.DisableServerRoutes {
|
if e.config.DisableServerRoutes || e.config.BlockInbound {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
"github.com/netbirdio/netbird/management/server/job"
|
"github.com/netbirdio/netbird/management/server/job"
|
||||||
|
|
||||||
"github.com/netbirdio/management-integrations/integrations"
|
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||||
@@ -66,8 +66,8 @@ import (
|
|||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
||||||
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
|
||||||
"github.com/netbirdio/netbird/shared/netiputil"
|
"github.com/netbirdio/netbird/shared/netiputil"
|
||||||
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||||
signal "github.com/netbirdio/netbird/shared/signal/client"
|
signal "github.com/netbirdio/netbird/shared/signal/client"
|
||||||
"github.com/netbirdio/netbird/shared/signal/proto"
|
"github.com/netbirdio/netbird/shared/signal/proto"
|
||||||
signalServer "github.com/netbirdio/netbird/signal/server"
|
signalServer "github.com/netbirdio/netbird/signal/server"
|
||||||
@@ -247,7 +247,7 @@ func TestEngine_SSH(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||||
@@ -426,7 +426,7 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||||
@@ -638,7 +638,7 @@ func TestEngine_Sync(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// feed updates to Engine via mocked Management client
|
// feed updates to Engine via mocked Management client
|
||||||
@@ -817,7 +817,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
wgIfaceName := fmt.Sprintf("utun%d", 104+n)
|
wgIfaceName := fmt.Sprintf("utun%d", 104+n)
|
||||||
@@ -1024,7 +1024,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
wgIfaceName := fmt.Sprintf("utun%d", 104+n)
|
wgIfaceName := fmt.Sprintf("utun%d", 104+n)
|
||||||
@@ -1641,7 +1641,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
||||||
|
|
||||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/hashicorp/go-version"
|
"github.com/hashicorp/go-version"
|
||||||
|
|
||||||
|
nbversion "github.com/netbirdio/netbird/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -11,7 +13,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func IsSupported(agentVersion string) bool {
|
func IsSupported(agentVersion string) bool {
|
||||||
if agentVersion == "development" {
|
if nbversion.IsDevelopmentVersion(agentVersion) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
|||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
// handle route changes
|
// handle route changes
|
||||||
case unix.RTM_ADD, syscall.RTM_DELETE:
|
case unix.RTM_ADD, syscall.RTM_DELETE:
|
||||||
route, err := parseRouteMessage(buf[:n])
|
route, flags, err := parseRouteMessage(buf[:n])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("Network monitor: error parsing routing message: %v", err)
|
log.Debugf("Network monitor: error parsing routing message: %v", err)
|
||||||
continue
|
continue
|
||||||
@@ -66,6 +66,10 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
|||||||
}
|
}
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case unix.RTM_ADD:
|
case unix.RTM_ADD:
|
||||||
|
if systemops.IgnoreAddedDefaultRoute(flags) {
|
||||||
|
log.Debugf("Network monitor: ignoring added default route via %s, interface %s, flags %#x", route.Gw, intf, flags)
|
||||||
|
continue
|
||||||
|
}
|
||||||
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
|
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
|
||||||
return nil
|
return nil
|
||||||
case unix.RTM_DELETE:
|
case unix.RTM_DELETE:
|
||||||
@@ -78,22 +82,26 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRouteMessage(buf []byte) (*systemops.Route, error) {
|
func parseRouteMessage(buf []byte) (*systemops.Route, int, error) {
|
||||||
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
|
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse RIB: %v", err)
|
return nil, 0, fmt.Errorf("parse RIB: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(msgs) != 1 {
|
if len(msgs) != 1 {
|
||||||
return nil, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
|
return nil, 0, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, ok := msgs[0].(*route.RouteMessage)
|
msg, ok := msgs[0].(*route.RouteMessage)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
|
return nil, 0, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return systemops.MsgToRoute(msg)
|
r, err := systemops.MsgToRoute(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return r, msg.Flags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitReadable blocks until fd has data to read, or ctx is cancelled.
|
// waitReadable blocks until fd has data to read, or ctx is cancelled.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/peer/id"
|
"github.com/netbirdio/netbird/client/internal/peer/id"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer/worker"
|
"github.com/netbirdio/netbird/client/internal/peer/worker"
|
||||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||||
@@ -899,7 +900,7 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to deterministic key if no NetBird PSK is configured
|
// Fallback to deterministic key if no NetBird PSK is configured
|
||||||
determKey, err := conn.rosenpassDetermKey()
|
determKey, err := rosenpass.DeterministicSeedKey(conn.config.LocalKey, conn.config.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err)
|
conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err)
|
||||||
return nil
|
return nil
|
||||||
@@ -908,26 +909,6 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
|
|||||||
return determKey
|
return determKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: move this logic into Rosenpass package
|
|
||||||
func (conn *Conn) rosenpassDetermKey() (*wgtypes.Key, error) {
|
|
||||||
lk := []byte(conn.config.LocalKey)
|
|
||||||
rk := []byte(conn.config.Key) // remote key
|
|
||||||
var keyInput []byte
|
|
||||||
if string(lk) > string(rk) {
|
|
||||||
//nolint:gocritic
|
|
||||||
keyInput = append(lk[:16], rk[:16]...)
|
|
||||||
} else {
|
|
||||||
//nolint:gocritic
|
|
||||||
keyInput = append(rk[:16], lk[:16]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := wgtypes.NewKey(keyInput)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isController(config ConnConfig) bool {
|
func isController(config ConnConfig) bool {
|
||||||
return config.LocalKey > config.Key
|
return config.LocalKey > config.Key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ type connStatusInputs struct {
|
|||||||
iceInProgress bool // a negotiation is currently in flight
|
iceInProgress bool // a negotiation is currently in flight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ConnStatus describe the status of a peer's connection
|
// ConnStatus describe the status of a peer's connection
|
||||||
type ConnStatus int32
|
type ConnStatus int32
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ type LocalPeerState struct {
|
|||||||
PubKey string
|
PubKey string
|
||||||
KernelInterface bool
|
KernelInterface bool
|
||||||
FQDN string
|
FQDN string
|
||||||
|
WgPort int
|
||||||
Routes map[string]struct{}
|
Routes map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,10 +186,14 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
return s.eventsChan
|
return s.eventsChan
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status holds a state of peers, signal, management connections and relays
|
// Status holds a state of peers, signal, management connections and relays.
|
||||||
|
// mux is an RWMutex so hot read paths (notably PeerStateByIP, called for
|
||||||
|
// every private-service request) don't contend against each other.
|
||||||
|
// Pure read methods take RLock; anything that mutates state takes Lock.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
mux sync.Mutex
|
mux sync.RWMutex
|
||||||
peers map[string]State
|
peers map[string]State
|
||||||
|
ipToKey map[string]string
|
||||||
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
|
||||||
@@ -227,6 +232,7 @@ type Status struct {
|
|||||||
func NewRecorder(mgmAddress string) *Status {
|
func NewRecorder(mgmAddress string) *Status {
|
||||||
return &Status{
|
return &Status{
|
||||||
peers: make(map[string]State),
|
peers: make(map[string]State),
|
||||||
|
ipToKey: make(map[string]string),
|
||||||
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),
|
||||||
@@ -278,13 +284,19 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
|
|||||||
Mux: new(sync.RWMutex),
|
Mux: new(sync.RWMutex),
|
||||||
}
|
}
|
||||||
d.peerListChangedForNotification = true
|
d.peerListChangedForNotification = true
|
||||||
|
if ipv6 != "" {
|
||||||
|
d.ipToKey[ipv6] = peerPubKey
|
||||||
|
}
|
||||||
|
if ip != "" {
|
||||||
|
d.ipToKey[ip] = peerPubKey
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPeer adds peer to Daemon status map
|
// GetPeer adds peer to Daemon status map
|
||||||
func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
state, ok := d.peers[peerPubKey]
|
state, ok := d.peers[peerPubKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -294,8 +306,8 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) PeerByIP(ip string) (string, bool) {
|
func (d *Status) PeerByIP(ip string) (string, bool) {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
for _, state := range d.peers {
|
for _, state := range d.peers {
|
||||||
if state.IP == ip {
|
if state.IP == ip {
|
||||||
@@ -305,17 +317,45 @@ 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. Only
|
||||||
|
// active peers are matched; peers moved into the offline slice by
|
||||||
|
// ReplaceOfflinePeers are intentionally treated as unknown.
|
||||||
|
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||||
|
if ip == "" {
|
||||||
|
return State{}, false
|
||||||
|
}
|
||||||
|
d.mux.RLock()
|
||||||
|
defer d.mux.RUnlock()
|
||||||
|
key, ok := d.ipToKey[ip]
|
||||||
|
if !ok {
|
||||||
|
return State{}, false
|
||||||
|
}
|
||||||
|
state, ok := d.peers[key]
|
||||||
|
if ok {
|
||||||
|
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()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.Unlock()
|
||||||
|
|
||||||
_, ok := d.peers[peerPubKey]
|
p, ok := d.peers[peerPubKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("no peer with to remove")
|
return errors.New("no peer with to remove")
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(d.peers, peerPubKey)
|
delete(d.peers, peerPubKey)
|
||||||
|
if mappedKey, exists := d.ipToKey[p.IP]; exists && mappedKey == peerPubKey {
|
||||||
|
delete(d.ipToKey, p.IP)
|
||||||
|
}
|
||||||
|
if mappedKey, exists := d.ipToKey[p.IPv6]; exists && mappedKey == peerPubKey {
|
||||||
|
delete(d.ipToKey, p.IPv6)
|
||||||
|
}
|
||||||
d.peerListChangedForNotification = true
|
d.peerListChangedForNotification = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -702,8 +742,8 @@ func (d *Status) UnsubscribePeerStateChanges(subscription *StatusChangeSubscript
|
|||||||
|
|
||||||
// GetLocalPeerState returns the local peer state
|
// GetLocalPeerState returns the local peer state
|
||||||
func (d *Status) GetLocalPeerState() LocalPeerState {
|
func (d *Status) GetLocalPeerState() LocalPeerState {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return d.localPeer.Clone()
|
return d.localPeer.Clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,8 +949,8 @@ func (d *Status) DeleteResolvedDomainsStates(domain domain.Domain) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetRosenpassState() RosenpassState {
|
func (d *Status) GetRosenpassState() RosenpassState {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return RosenpassState{
|
return RosenpassState{
|
||||||
d.rosenpassEnabled,
|
d.rosenpassEnabled,
|
||||||
d.rosenpassPermissive,
|
d.rosenpassPermissive,
|
||||||
@@ -918,14 +958,14 @@ func (d *Status) GetRosenpassState() RosenpassState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetLazyConnection() bool {
|
func (d *Status) GetLazyConnection() bool {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return d.lazyConnectionEnabled
|
return d.lazyConnectionEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetManagementState() ManagementState {
|
func (d *Status) GetManagementState() ManagementState {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return ManagementState{
|
return ManagementState{
|
||||||
d.mgmAddress,
|
d.mgmAddress,
|
||||||
d.managementState,
|
d.managementState,
|
||||||
@@ -951,8 +991,8 @@ func (d *Status) UpdateLatency(pubKey string, latency time.Duration) error {
|
|||||||
|
|
||||||
// IsLoginRequired determines if a peer's login has expired.
|
// IsLoginRequired determines if a peer's login has expired.
|
||||||
func (d *Status) IsLoginRequired() bool {
|
func (d *Status) IsLoginRequired() bool {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
// if peer is connected to the management then login is not expired
|
// if peer is connected to the management then login is not expired
|
||||||
if d.managementState {
|
if d.managementState {
|
||||||
@@ -967,8 +1007,8 @@ func (d *Status) IsLoginRequired() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetSignalState() SignalState {
|
func (d *Status) GetSignalState() SignalState {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return SignalState{
|
return SignalState{
|
||||||
d.signalAddress,
|
d.signalAddress,
|
||||||
d.signalState,
|
d.signalState,
|
||||||
@@ -978,20 +1018,23 @@ func (d *Status) GetSignalState() SignalState {
|
|||||||
|
|
||||||
// GetRelayStates returns the stun/turn/permanent relay states
|
// GetRelayStates returns the stun/turn/permanent relay states
|
||||||
func (d *Status) GetRelayStates() []relay.ProbeResult {
|
func (d *Status) GetRelayStates() []relay.ProbeResult {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
if d.relayMgr == nil {
|
if d.relayMgr == nil {
|
||||||
return d.relayStates
|
return d.relayStates
|
||||||
}
|
}
|
||||||
|
|
||||||
// extend the list of stun, turn servers with relay address
|
// extend the list of stun, turn servers with the relay server connections
|
||||||
relayStates := slices.Clone(d.relayStates)
|
relayStates := slices.Clone(d.relayStates)
|
||||||
|
|
||||||
// if the server connection is not established then we will use the general address
|
states := d.relayMgr.RelayStates()
|
||||||
// in case of connection we will use the instance specific address
|
if len(states) == 0 {
|
||||||
instanceAddr, _, err := d.relayMgr.RelayInstanceAddress()
|
// no relay connection tracked yet; surface configured servers as
|
||||||
if err != nil {
|
// unavailable with the real reconnect error when known
|
||||||
// TODO add their status
|
err := relayClient.ErrRelayClientNotConnected
|
||||||
|
if connErr := d.relayMgr.RelayConnectError(); connErr != nil {
|
||||||
|
err = connErr
|
||||||
|
}
|
||||||
for _, r := range d.relayMgr.ServerURLs() {
|
for _, r := range d.relayMgr.ServerURLs() {
|
||||||
relayStates = append(relayStates, relay.ProbeResult{
|
relayStates = append(relayStates, relay.ProbeResult{
|
||||||
URI: r,
|
URI: r,
|
||||||
@@ -1001,15 +1044,19 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
|
|||||||
return relayStates
|
return relayStates
|
||||||
}
|
}
|
||||||
|
|
||||||
relayState := relay.ProbeResult{
|
for _, rs := range states {
|
||||||
URI: instanceAddr,
|
relayStates = append(relayStates, relay.ProbeResult{
|
||||||
|
URI: rs.URL,
|
||||||
|
Err: rs.Err,
|
||||||
|
Transport: rs.Transport,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return append(relayStates, relayState)
|
return relayStates
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
if d.ingressGwMgr == nil {
|
if d.ingressGwMgr == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1018,16 +1065,16 @@ func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetDNSStates() []NSGroupState {
|
func (d *Status) GetDNSStates() []NSGroupState {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
// shallow copy is good enough, as slices fields are currently not updated
|
// shallow copy is good enough, as slices fields are currently not updated
|
||||||
return slices.Clone(d.nsGroupStates)
|
return slices.Clone(d.nsGroupStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
|
func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
return maps.Clone(d.resolvedDomainsStates)
|
return maps.Clone(d.resolvedDomainsStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,8 +1090,8 @@ func (d *Status) GetFullStatus() FullStatus {
|
|||||||
LazyConnectionEnabled: d.GetLazyConnection(),
|
LazyConnectionEnabled: d.GetLazyConnection(),
|
||||||
}
|
}
|
||||||
|
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
|
|
||||||
fullStatus.LocalPeerState = d.localPeer
|
fullStatus.LocalPeerState = d.localPeer
|
||||||
|
|
||||||
@@ -1219,8 +1266,8 @@ func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) PeersStatus() (*configurer.Stats, error) {
|
func (d *Status) PeersStatus() (*configurer.Stats, error) {
|
||||||
d.mux.Lock()
|
d.mux.RLock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.RUnlock()
|
||||||
if d.wgIface == nil {
|
if d.wgIface == nil {
|
||||||
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
|
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
|
||||||
}
|
}
|
||||||
@@ -1326,6 +1373,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
|
|||||||
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
|
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
|
||||||
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
|
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
|
||||||
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
|
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
|
||||||
|
pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
|
||||||
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
|
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
|
||||||
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
|
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
|
||||||
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
|
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
|
||||||
@@ -1364,6 +1412,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
|
|||||||
pbRelayState := &proto.RelayState{
|
pbRelayState := &proto.RelayState{
|
||||||
URI: relayState.URI,
|
URI: relayState.URI,
|
||||||
Available: relayState.Err == nil,
|
Available: relayState.Err == nil,
|
||||||
|
Transport: relayState.Transport,
|
||||||
}
|
}
|
||||||
if err := relayState.Err; err != nil {
|
if err := relayState.Err; err != nil {
|
||||||
pbRelayState.Error = err.Error()
|
pbRelayState.Error = err.Error()
|
||||||
|
|||||||
@@ -63,6 +63,72 @@ 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatus_PeerStateByIP_IgnoresOfflinePeers documents that peers
|
||||||
|
// moved into the offline slice via ReplaceOfflinePeers are intentionally
|
||||||
|
// not resolvable by IP: only active peers can carry traffic, so callers
|
||||||
|
// (DNS filter, embed.Client.IdentityForIP) treat them as unknown.
|
||||||
|
func TestStatus_PeerStateByIP_IgnoresOfflinePeers(t *testing.T) {
|
||||||
|
status := NewRecorder("https://mgm")
|
||||||
|
req := require.New(t)
|
||||||
|
|
||||||
|
status.ReplaceOfflinePeers([]State{
|
||||||
|
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, ok := status.PeerStateByIP("100.64.0.20")
|
||||||
|
req.False(ok, "offline peer must not resolve by IPv4 tunnel address")
|
||||||
|
|
||||||
|
_, ok = status.PeerStateByIP("fd00::20")
|
||||||
|
req.False(ok, "offline peer must not resolve by IPv6 tunnel address")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatus_PeerStateByIP_RemovedPeer verifies RemovePeer drops the
|
||||||
|
// IP index entries for both address families.
|
||||||
|
func TestStatus_PeerStateByIP_RemovedPeer(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"))
|
||||||
|
|
||||||
|
_, ok := status.PeerStateByIP("100.64.0.10")
|
||||||
|
req.True(ok, "active peer must resolve before removal")
|
||||||
|
|
||||||
|
req.NoError(status.RemovePeer("pk-1"))
|
||||||
|
|
||||||
|
_, ok = status.PeerStateByIP("100.64.0.10")
|
||||||
|
req.False(ok, "removed peer must not resolve by IPv4 tunnel address")
|
||||||
|
|
||||||
|
_, ok = status.PeerStateByIP("fd00::1")
|
||||||
|
req.False(ok, "removed peer must not resolve by IPv6 tunnel address")
|
||||||
|
}
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -165,10 +164,6 @@ func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HA
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if candidateViaRoutes(candidate, haRoutes) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.agent.AddRemoteCandidate(candidate); err != nil {
|
if err := w.agent.AddRemoteCandidate(candidate); err != nil {
|
||||||
w.log.Errorf("error while handling remote candidate")
|
w.log.Errorf("error while handling remote candidate")
|
||||||
return
|
return
|
||||||
@@ -589,34 +584,6 @@ func extraSrflxCandidate(candidate ice.Candidate) (*ice.CandidateServerReflexive
|
|||||||
return ec, nil
|
return ec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool {
|
|
||||||
addr, err := netip.ParseAddr(candidate.Address())
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to parse IP address %s: %v", candidate.Address(), err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var routePrefixes []netip.Prefix
|
|
||||||
for _, routes := range clientRoutes {
|
|
||||||
if len(routes) > 0 && routes[0] != nil {
|
|
||||||
routePrefixes = append(routePrefixes, routes[0].Network)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, prefix := range routePrefixes {
|
|
||||||
// default route is handled by route exclusion / ip rules
|
|
||||||
if prefix.Bits() == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if prefix.Contains(addr) {
|
|
||||||
log.Debugf("Ignoring candidate [%s], its address is part of routed network %s", candidate.String(), prefix)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRelayCandidate(candidate ice.Candidate) bool {
|
func isRelayCandidate(candidate ice.Candidate) bool {
|
||||||
return candidate.Type() == ice.CandidateTypeRelay
|
return candidate.Type() == ice.CandidateTypeRelay
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,8 +179,10 @@ func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dst := net.IPv4zero
|
dst := net.IPv4zero
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
||||||
// go-netroute v0.4.0 rejects unspecified destinations client-side on Linux.
|
// go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
|
||||||
|
// TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties /
|
||||||
|
// NWPathMonitor) when netlink-based lookup is restricted or unavailable.
|
||||||
dst = net.IPv4(0, 0, 0, 1)
|
dst = net.IPv4(0, 0, 0, 1)
|
||||||
}
|
}
|
||||||
_, gateway, localIP, err = router.Route(dst)
|
_, gateway, localIP, err = router.Route(dst)
|
||||||
@@ -203,7 +205,7 @@ func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dst := net.IPv6zero
|
dst := net.IPv6zero
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
||||||
// ::2
|
// ::2
|
||||||
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
|
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
"github.com/netbirdio/netbird/client/ssh"
|
"github.com/netbirdio/netbird/client/ssh"
|
||||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
@@ -57,6 +58,10 @@ var DefaultInterfaceBlacklist = []string{
|
|||||||
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadMDMPolicy is the package-level indirection used by apply() to read the
|
||||||
|
// active MDM policy. Tests override this to inject a fake policy.
|
||||||
|
var loadMDMPolicy = mdm.LoadPolicy
|
||||||
|
|
||||||
// ConfigInput carries configuration changes to the client
|
// ConfigInput carries configuration changes to the client
|
||||||
type ConfigInput struct {
|
type ConfigInput struct {
|
||||||
ManagementURL string
|
ManagementURL string
|
||||||
@@ -103,6 +108,10 @@ type ConfigInput struct {
|
|||||||
|
|
||||||
// Config Configuration type
|
// Config Configuration type
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
// Name is the human-readable profile name shown in CLI/UI listings.
|
||||||
|
// It is independent of the profile's on-disk filename (which is the ID).
|
||||||
|
Name string
|
||||||
|
|
||||||
// Wireguard private key of local peer
|
// Wireguard private key of local peer
|
||||||
PrivateKey string
|
PrivateKey string
|
||||||
PreSharedKey string
|
PreSharedKey string
|
||||||
@@ -174,6 +183,23 @@ type Config struct {
|
|||||||
LazyConnectionEnabled bool
|
LazyConnectionEnabled bool
|
||||||
|
|
||||||
MTU uint16
|
MTU uint16
|
||||||
|
|
||||||
|
// policy is the MDM policy that produced the currently-set values for
|
||||||
|
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
|
||||||
|
// and reset on every apply() invocation. Never persisted to disk.
|
||||||
|
// Callers query enforcement state via Policy() and the mdm.Policy API
|
||||||
|
// (HasKey, ManagedKeys, IsEmpty).
|
||||||
|
policy *mdm.Policy `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Policy returns the MDM policy applied to this Config. Returns a non-nil
|
||||||
|
// empty Policy when MDM enforcement is inactive; callers can always invoke
|
||||||
|
// HasKey / ManagedKeys / IsEmpty without a nil check.
|
||||||
|
func (config *Config) Policy() *mdm.Policy {
|
||||||
|
if config == nil || config.policy == nil {
|
||||||
|
return mdm.NewPolicy(nil)
|
||||||
|
}
|
||||||
|
return config.policy
|
||||||
}
|
}
|
||||||
|
|
||||||
var ConfigDirOverride string
|
var ConfigDirOverride string
|
||||||
@@ -248,6 +274,16 @@ func createNewConfig(input ConfigInput) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||||
|
if config.Name != "" {
|
||||||
|
sanitized, err := sanitizeDisplayName(config.Name)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("invalid profile name: %w", err)
|
||||||
|
}
|
||||||
|
if sanitized != config.Name {
|
||||||
|
config.Name = sanitized
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
if config.ManagementURL == nil {
|
if config.ManagementURL == nil {
|
||||||
log.Infof("using default Management URL %s", DefaultManagementURL)
|
log.Infof("using default Management URL %s", DefaultManagementURL)
|
||||||
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
|
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
|
||||||
@@ -612,10 +648,93 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MDM is the last override layer: any key present in the policy
|
||||||
|
// supersedes defaults, on-disk config, env vars and CLI input.
|
||||||
|
config.applyMDMPolicy(loadMDMPolicy())
|
||||||
|
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseURL parses and validates a service URL
|
// applyMDMPolicy overlays MDM-supplied values on top of the resolved Config.
|
||||||
|
// The provided Policy is also stored on the Config so callers can later query
|
||||||
|
// which fields are enforced. Invalid values (e.g. malformed URLs) are logged
|
||||||
|
// and skipped to avoid bricking the client; the field keeps its previous
|
||||||
|
// resolved value but is still marked as managed (Policy.HasKey returns true
|
||||||
|
// for the key, so per-field rejection of user writes still applies).
|
||||||
|
func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||||
|
config.policy = policy
|
||||||
|
if policy.IsEmpty() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: log the application of a single MDM-managed key. Values for
|
||||||
|
// keys in mdm.SecretKeys are redacted.
|
||||||
|
logApplied := func(key string, displayValue any) {
|
||||||
|
if _, secret := mdm.SecretKeys[key]; secret {
|
||||||
|
log.Infof("MDM override %s = ********** (secret)", key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("MDM override %s = %v", key, displayValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := policy.GetString(mdm.KeyManagementURL); ok {
|
||||||
|
if u, err := parseURL("Management URL", v); err != nil {
|
||||||
|
log.Warnf("MDM management URL %q invalid: %v; keeping previous value", v, err)
|
||||||
|
} else {
|
||||||
|
config.ManagementURL = u
|
||||||
|
logApplied(mdm.KeyManagementURL, u.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := policy.GetString(mdm.KeyPreSharedKey); ok {
|
||||||
|
// Defensive: refuse the redaction mask in case it round-tripped
|
||||||
|
// through a manifest by mistake.
|
||||||
|
if !isPreSharedKeyHidden(&v) {
|
||||||
|
config.PreSharedKey = v
|
||||||
|
logApplied(mdm.KeyPreSharedKey, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyBool collapses the per-key "read + set + log" boilerplate
|
||||||
|
// for every plain bool MDM key into a single helper. Keeps the
|
||||||
|
// outer function's cognitive complexity below SonarCube's
|
||||||
|
// threshold; functional behaviour is identical to the inlined
|
||||||
|
// branches it replaces.
|
||||||
|
applyBool := func(key string, setter func(bool)) {
|
||||||
|
v, ok := policy.GetBool(key)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setter(v)
|
||||||
|
logApplied(key, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
|
||||||
|
applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
|
||||||
|
applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
|
||||||
|
applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
|
||||||
|
applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
|
||||||
|
applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
|
||||||
|
applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
|
||||||
|
|
||||||
|
if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
|
||||||
|
// REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
|
||||||
|
// upper bound and reject obviously-invalid values to avoid the
|
||||||
|
// engine binding to an unusable port if the admin pushes garbage.
|
||||||
|
if v >= 1 && v <= 65535 {
|
||||||
|
config.WgPort = int(v)
|
||||||
|
logApplied(mdm.KeyWireguardPort, v)
|
||||||
|
} else {
|
||||||
|
log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseURL parses and validates the URL for the named service. The URL
|
||||||
|
// must use the http or https scheme; if no port is present, ":443" is
|
||||||
|
// appended for https or ":80" for http. The serviceName parameter is
|
||||||
|
// used to contextualise error messages. On success returns the parsed
|
||||||
|
// *url.URL; on failure returns a non-nil error.
|
||||||
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||||
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
152
client/internal/profilemanager/config_mdm_test.go
Normal file
152
client/internal/profilemanager/config_mdm_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package profilemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
|
||||||
|
// apply() observes the supplied Policy. The original loader is restored at
|
||||||
|
// test cleanup.
|
||||||
|
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
||||||
|
t.Helper()
|
||||||
|
prev := loadMDMPolicy
|
||||||
|
loadMDMPolicy = func() *mdm.Policy { return policy }
|
||||||
|
t.Cleanup(func() { loadMDMPolicy = prev })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||||
|
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||||
|
|
||||||
|
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||||
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
|
||||||
|
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||||
|
assert.Empty(t, cfg.Policy().ManagedKeys())
|
||||||
|
|
||||||
|
// Default management URL still resolves.
|
||||||
|
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
|
||||||
|
const mdmURL = "https://corp.mdm.example.com:443"
|
||||||
|
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||||
|
mdm.KeyManagementURL: mdmURL,
|
||||||
|
mdm.KeyDisableClientRoutes: true,
|
||||||
|
mdm.KeyBlockInbound: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||||
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||||
|
assert.True(t, cfg.DisableClientRoutes)
|
||||||
|
assert.True(t, cfg.BlockInbound)
|
||||||
|
|
||||||
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||||
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||||
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyBlockInbound))
|
||||||
|
assert.False(t, cfg.Policy().HasKey(mdm.KeyAllowServerSSH))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
||||||
|
const mdmURL = "https://mdm.example.com:443"
|
||||||
|
const cliURL = "https://cli.example.com:443"
|
||||||
|
|
||||||
|
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||||
|
mdm.KeyManagementURL: mdmURL,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||||
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
|
ManagementURL: cliURL,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
// MDM wins over CLI-supplied management URL.
|
||||||
|
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||||
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
|
||||||
|
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||||
|
mdm.KeyManagementURL: "not-a-url",
|
||||||
|
}))
|
||||||
|
|
||||||
|
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||||
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
// Invalid MDM URL is logged and skipped: default URL stays in place
|
||||||
|
// to keep the client functional.
|
||||||
|
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||||
|
|
||||||
|
// But the key is still considered MDM-managed (admin intent is to
|
||||||
|
// enforce, daemon rejects user writes to this field — phase-1 scaffolding
|
||||||
|
// reflects this by keeping Policy.HasKey true even on parse failure).
|
||||||
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
||||||
|
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
|
// Seed without MDM.
|
||||||
|
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||||
|
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||||
|
ConfigPath: tmp,
|
||||||
|
DisableClientRoutes: boolPtr(false),
|
||||||
|
RosenpassEnabled: boolPtr(false),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Now enable MDM enforcement for these keys.
|
||||||
|
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||||
|
mdm.KeyDisableClientRoutes: true,
|
||||||
|
mdm.KeyRosenpassEnabled: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
|
||||||
|
assert.True(t, cfg.RosenpassEnabled)
|
||||||
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||||
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
||||||
|
const maskSentinel = "**********"
|
||||||
|
|
||||||
|
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||||
|
mdm.KeyPreSharedKey: maskSentinel,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||||
|
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
// Mask sentinel must not be persisted as the actual PSK.
|
||||||
|
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
|
||||||
|
// Key still marked managed so user writes are still rejected.
|
||||||
|
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
118
client/internal/profilemanager/id.go
Normal file
118
client/internal/profilemanager/id.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package profilemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// profileIDByteLen is the number of random bytes generated for a new
|
||||||
|
// profile ID. The resulting hex string is twice this length.
|
||||||
|
profileIDByteLen = 16
|
||||||
|
|
||||||
|
// shortIDLen is the number of leading characters of an ID we render in
|
||||||
|
// list output. Profiles per device are few, so 8 chars is collision-safe
|
||||||
|
// in practice and easy to type as a prefix.
|
||||||
|
shortIDLen = 8
|
||||||
|
|
||||||
|
// maxProfileNameLen caps the human-readable profile name to keep table
|
||||||
|
// output legible and prevent denial-of-service via huge JSON fields.
|
||||||
|
maxProfileNameLen = 128
|
||||||
|
|
||||||
|
// maxProfileIDLen bounds the on-disk filename we'll accept. New
|
||||||
|
// IDs are 32 hex chars, legacy stems are sanitized profile names. The
|
||||||
|
// cap is generous enough to cover both without permitting absurdly
|
||||||
|
// long filenames.
|
||||||
|
maxProfileIDLen = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
type ID string
|
||||||
|
|
||||||
|
// generateProfileID returns a new random hex ID for a profile file.
|
||||||
|
func generateProfileID() (ID, error) {
|
||||||
|
buf := make([]byte, profileIDByteLen)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("read random bytes: %w", err)
|
||||||
|
}
|
||||||
|
return ID(hex.EncodeToString(buf)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidProfileFilenameStem reports whether id is safe to use as the stem
|
||||||
|
// of a profile JSON filename.
|
||||||
|
func IsValidProfileFilenameStem(id ID) bool {
|
||||||
|
s := id.String()
|
||||||
|
if s == "" || len(s) > maxProfileIDLen {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s == defaultProfileName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(s, `/\`) || strings.Contains(s, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// filepath.Base catches any leftover separators on platforms with
|
||||||
|
// exotic path conventions.
|
||||||
|
if filepath.Base(s) != s {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeDisplayName normalizes a user-supplied profile display name for
|
||||||
|
// storage. It strips ASCII control characters, rejects invalid UTF-8, and
|
||||||
|
// caps the length. Emojis, spaces, punctuation, and non-ASCII letters are
|
||||||
|
// preserved. Returns an error if nothing usable remains.
|
||||||
|
func sanitizeDisplayName(name string) (string, error) {
|
||||||
|
if !utf8.ValidString(name) {
|
||||||
|
return "", fmt.Errorf("name is not valid UTF-8")
|
||||||
|
}
|
||||||
|
name = StripCtrlChars(name)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return "", fmt.Errorf("name is empty after sanitization")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(name) > maxProfileNameLen {
|
||||||
|
return "", fmt.Errorf("name exceeds %d characters", maxProfileNameLen)
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripCtrlChars control characters from a name before printing it.
|
||||||
|
func StripCtrlChars(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(name))
|
||||||
|
for _, r := range name {
|
||||||
|
// Skip C0 controls and DEL, plus C1 controls (0x80–0x9F).
|
||||||
|
if r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortID truncates an ID for display.
|
||||||
|
func (id ID) ShortID() string {
|
||||||
|
if id == DefaultProfileName {
|
||||||
|
return DefaultProfileName
|
||||||
|
}
|
||||||
|
runes := []rune(id)
|
||||||
|
if len(runes) <= shortIDLen {
|
||||||
|
return id.String()
|
||||||
|
}
|
||||||
|
return string(runes[:shortIDLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id ID) String() string {
|
||||||
|
return string(id)
|
||||||
|
}
|
||||||
@@ -19,19 +19,41 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
Name string
|
// ID is the on-disk filename stem (without .json). For new profiles
|
||||||
|
// it is a 32-char hex string; legacy profiles created before the
|
||||||
|
// ID-keyed layout keep their original name as their ID. The reserved
|
||||||
|
// value "default" identifies the special default profile.
|
||||||
|
ID ID
|
||||||
|
// Name is the human-readable display name. Falls back to ID when the
|
||||||
|
// underlying JSON has no "name" field set.
|
||||||
|
Name string
|
||||||
|
// Path is the absolute path to the profile JSON. Populated by the
|
||||||
|
// loader so callers do not have to reconstruct it from ID + dir.
|
||||||
|
Path string
|
||||||
IsActive bool
|
IsActive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Profile) FilePath() (string, error) {
|
func (p *Profile) FilePath() (string, error) {
|
||||||
if p.Name == "" {
|
if p.Path != "" {
|
||||||
return "", fmt.Errorf("active profile name is empty")
|
return p.Path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.Name == defaultProfileName {
|
id := p.ID
|
||||||
|
if id == "" {
|
||||||
|
id = ID(p.Name)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
return "", fmt.Errorf("profile ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if id == defaultProfileName {
|
||||||
return DefaultConfigPath, nil
|
return DefaultConfigPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !IsValidProfileFilenameStem(id) {
|
||||||
|
return "", fmt.Errorf("invalid profile ID: %q", id)
|
||||||
|
}
|
||||||
|
|
||||||
username, err := user.Current()
|
username, err := user.Current()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get current user: %w", err)
|
return "", fmt.Errorf("failed to get current user: %w", err)
|
||||||
@@ -42,10 +64,13 @@ func (p *Profile) FilePath() (string, error) {
|
|||||||
return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err)
|
return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Join(configDir, p.Name+".json"), nil
|
return filepath.Join(configDir, id.String()+".json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Profile) IsDefault() bool {
|
func (p *Profile) IsDefault() bool {
|
||||||
|
if p.ID != "" {
|
||||||
|
return p.ID == defaultProfileName
|
||||||
|
}
|
||||||
return p.Name == defaultProfileName
|
return p.Name == defaultProfileName
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,18 +82,24 @@ func NewProfileManager() *ProfileManager {
|
|||||||
return &ProfileManager{}
|
return &ProfileManager{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetActiveProfile returns the active profile as recorded in the local
|
||||||
|
// user state file. Only ID is populated.
|
||||||
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
defer pm.mu.Unlock()
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
prof := pm.getActiveProfileState()
|
id := pm.getActiveProfileState()
|
||||||
return &Profile{Name: prof}, nil
|
return &Profile{ID: id}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *ProfileManager) SwitchProfile(profileName string) error {
|
// SwitchProfile records the given profile ID as active in the local user
|
||||||
profileName = sanitizeProfileName(profileName)
|
// state file.
|
||||||
|
func (pm *ProfileManager) SwitchProfile(id ID) error {
|
||||||
|
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||||
|
return fmt.Errorf("invalid profile ID: %q", id)
|
||||||
|
}
|
||||||
|
|
||||||
if err := pm.setActiveProfileState(profileName); err != nil {
|
if err := pm.setActiveProfileState(id); err != nil {
|
||||||
return fmt.Errorf("failed to switch profile: %w", err)
|
return fmt.Errorf("failed to switch profile: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -85,7 +116,7 @@ func sanitizeProfileName(name string) string {
|
|||||||
}, name)
|
}, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *ProfileManager) getActiveProfileState() string {
|
func (pm *ProfileManager) getActiveProfileState() ID {
|
||||||
|
|
||||||
configDir, err := getConfigDir()
|
configDir, err := getConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -113,10 +144,10 @@ func (pm *ProfileManager) getActiveProfileState() string {
|
|||||||
return defaultProfileName
|
return defaultProfileName
|
||||||
}
|
}
|
||||||
|
|
||||||
return profileName
|
return ID(profileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *ProfileManager) setActiveProfileState(profileName string) error {
|
func (pm *ProfileManager) setActiveProfileState(id ID) error {
|
||||||
|
|
||||||
configDir, err := getConfigDir()
|
configDir, err := getConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,7 +156,7 @@ func (pm *ProfileManager) setActiveProfileState(profileName string) error {
|
|||||||
|
|
||||||
statePath := filepath.Join(configDir, activeProfileStateFilename)
|
statePath := filepath.Join(configDir, activeProfileStateFilename)
|
||||||
|
|
||||||
err = os.WriteFile(statePath, []byte(profileName), 0600)
|
err = os.WriteFile(statePath, []byte(id), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write active profile state: %w", err)
|
return fmt.Errorf("failed to write active profile state: %w", err)
|
||||||
}
|
}
|
||||||
@@ -142,7 +173,7 @@ func GetLoginHint() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
profileState, err := pm.GetProfileState(activeProf.Name)
|
profileState, err := pm.GetProfileState(activeProf.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debugf("failed to get profile state for login hint: %v", err)
|
log.Debugf("failed to get profile state for login hint: %v", err)
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -50,14 +50,14 @@ func TestServiceManager_CreateAndGetDefaultProfile(t *testing.T) {
|
|||||||
|
|
||||||
state, err := sm.GetActiveProfileState()
|
state, err := sm.GetActiveProfileState()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, state.Name, defaultProfileName) // No active profile state yet
|
assert.Equal(t, defaultProfileName, state.ID.String()) // No active profile state yet
|
||||||
|
|
||||||
err = sm.SetActiveProfileStateToDefault()
|
err = sm.SetActiveProfileStateToDefault()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
active, err := sm.GetActiveProfileState()
|
active, err := sm.GetActiveProfileState()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "default", active.Name)
|
assert.Equal(t, "default", active.ID.String())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -92,14 +92,14 @@ func TestServiceManager_SetActiveProfileState(t *testing.T) {
|
|||||||
currUser, err := user.Current()
|
currUser, err := user.Current()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
sm := &ServiceManager{}
|
sm := &ServiceManager{}
|
||||||
state := &ActiveProfileState{Name: "foo", Username: currUser.Username}
|
state := &ActiveProfileState{ID: "foo", Username: currUser.Username}
|
||||||
err = sm.SetActiveProfileState(state)
|
err = sm.SetActiveProfileState(state)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Should error on nil or incomplete state
|
// Should error on nil or incomplete state
|
||||||
err = sm.SetActiveProfileState(nil)
|
err = sm.SetActiveProfileState(nil)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
err = sm.SetActiveProfileState(&ActiveProfileState{Name: "", Username: ""})
|
err = sm.SetActiveProfileState(&ActiveProfileState{ID: "", Username: ""})
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package profilemanager
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -23,12 +24,43 @@ var (
|
|||||||
DefaultConfigPathDir = ""
|
DefaultConfigPathDir = ""
|
||||||
DefaultConfigPath = ""
|
DefaultConfigPath = ""
|
||||||
ActiveProfileStatePath = ""
|
ActiveProfileStatePath = ""
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrorOldDefaultConfigNotFound = errors.New("old default config not found")
|
ErrorOldDefaultConfigNotFound = errors.New("old default config not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrAmbiguousHandle is returned when a profile handle (ID prefix or name)
|
||||||
|
// matches more than one profile. Callers can render Candidates to help the
|
||||||
|
// user disambiguate.
|
||||||
|
type ErrAmbiguousHandle struct {
|
||||||
|
Handle string
|
||||||
|
Candidates []Profile
|
||||||
|
Kind AmbiguityKind
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmbiguityKind describes which matcher produced the ambiguity, so callers
|
||||||
|
// can tailor the error message.
|
||||||
|
type AmbiguityKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AmbiguityKindIDPrefix AmbiguityKind = iota
|
||||||
|
AmbiguityKindName
|
||||||
|
)
|
||||||
|
|
||||||
|
// profileMeta is the minimal slice of a profile JSON we need, so we avoid
|
||||||
|
// reading all fields
|
||||||
|
type profileMeta struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ErrAmbiguousHandle) Error() string {
|
||||||
|
switch e.Kind {
|
||||||
|
case AmbiguityKindIDPrefix:
|
||||||
|
return fmt.Sprintf("ID prefix %q is ambiguous (matches %d profiles)", e.Handle, len(e.Candidates))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("name %q is ambiguous (%d profiles share this name)", e.Handle, len(e.Candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
||||||
DefaultConfigPathDir = "/var/lib/netbird/"
|
DefaultConfigPathDir = "/var/lib/netbird/"
|
||||||
@@ -54,25 +86,34 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ActiveProfileState struct {
|
type ActiveProfileState struct {
|
||||||
Name string `json:"name"`
|
// ID is the on-disk filename stem of the active profile. The JSON tag stays
|
||||||
|
// as "name" for backwards compatibility with active state files written
|
||||||
|
// before the ID-based config files. Legacy values were profile names, which
|
||||||
|
// were also the legacy filename stems, so they still resolve to the correct
|
||||||
|
// file on disk.
|
||||||
|
ID ID `json:"name"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ActiveProfileState) FilePath() (string, error) {
|
func (a *ActiveProfileState) FilePath() (string, error) {
|
||||||
if a.Name == "" {
|
if a.ID == "" {
|
||||||
return "", fmt.Errorf("active profile name is empty")
|
return "", fmt.Errorf("active profile ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.Name == defaultProfileName {
|
if a.ID == defaultProfileName {
|
||||||
return DefaultConfigPath, nil
|
return DefaultConfigPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !IsValidProfileFilenameStem(a.ID) {
|
||||||
|
return "", fmt.Errorf("invalid profile ID: %q", a.ID)
|
||||||
|
}
|
||||||
|
|
||||||
configDir, err := getConfigDirForUser(a.Username)
|
configDir, err := getConfigDirForUser(a.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err)
|
return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Join(configDir, a.Name+".json"), nil
|
return filepath.Join(configDir, a.ID.String()+".json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServiceManager struct {
|
type ServiceManager struct {
|
||||||
@@ -178,7 +219,7 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
|
|||||||
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
|
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
|
||||||
}
|
}
|
||||||
return &ActiveProfileState{
|
return &ActiveProfileState{
|
||||||
Name: "default",
|
ID: defaultProfileName,
|
||||||
Username: "",
|
Username: "",
|
||||||
}, nil
|
}, nil
|
||||||
} else {
|
} else {
|
||||||
@@ -186,12 +227,12 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if activeProfile.Name == "" {
|
if activeProfile.ID == "" {
|
||||||
if err := s.SetActiveProfileStateToDefault(); err != nil {
|
if err := s.SetActiveProfileStateToDefault(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
|
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
|
||||||
}
|
}
|
||||||
return &ActiveProfileState{
|
return &ActiveProfileState{
|
||||||
Name: "default",
|
ID: defaultProfileName,
|
||||||
Username: "",
|
Username: "",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -216,25 +257,29 @@ func (s *ServiceManager) setDefaultActiveState() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error {
|
func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error {
|
||||||
if a == nil || a.Name == "" {
|
if a == nil || a.ID == "" {
|
||||||
return errors.New("invalid active profile state")
|
return errors.New("invalid active profile state")
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.Name != defaultProfileName && a.Username == "" {
|
if a.ID != defaultProfileName && a.Username == "" {
|
||||||
return fmt.Errorf("username must be set for non-default profiles, got: %s", a.Name)
|
return fmt.Errorf("username must be set for non-default profiles, got: %s", a.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.ID != defaultProfileName && !IsValidProfileFilenameStem(a.ID) {
|
||||||
|
return fmt.Errorf("invalid profile ID: %q", a.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil {
|
if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil {
|
||||||
return fmt.Errorf("failed to write active profile state: %w", err)
|
return fmt.Errorf("failed to write active profile state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("active profile set to %s for %s", a.Name, a.Username)
|
log.Infof("active profile set to %s for %s", a.ID, a.Username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServiceManager) SetActiveProfileStateToDefault() error {
|
func (s *ServiceManager) SetActiveProfileStateToDefault() error {
|
||||||
return s.SetActiveProfileState(&ActiveProfileState{
|
return s.SetActiveProfileState(&ActiveProfileState{
|
||||||
Name: "default",
|
ID: defaultProfileName,
|
||||||
Username: "",
|
Username: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -243,57 +288,117 @@ func (s *ServiceManager) DefaultProfilePath() string {
|
|||||||
return DefaultConfigPath
|
return DefaultConfigPath
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServiceManager) AddProfile(profileName, username string) error {
|
// AddProfile creates a new profile with a generated ID. The user-supplied
|
||||||
|
// displayName is stored inside the JSON's name field, the on-disk filename
|
||||||
|
// uses the generated ID.
|
||||||
|
//
|
||||||
|
// The returned Profile carries the freshly-generated ID so callers can
|
||||||
|
// show it to the user (and so the gRPC AddProfileResponse can include
|
||||||
|
// it).
|
||||||
|
func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) {
|
||||||
configDir, err := s.getConfigDir(username)
|
configDir, err := s.getConfigDir(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get config directory: %w", err)
|
return nil, fmt.Errorf("failed to get config directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
profileName = sanitizeProfileName(profileName)
|
displayName, err = sanitizeDisplayName(displayName)
|
||||||
|
|
||||||
if profileName == defaultProfileName {
|
|
||||||
return fmt.Errorf("cannot create profile with reserved name: %s", defaultProfileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
profPath := filepath.Join(configDir, profileName+".json")
|
|
||||||
profileExists, err := fileExists(profPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if profile exists: %w", err)
|
return nil, fmt.Errorf("invalid profile name: %w", err)
|
||||||
}
|
|
||||||
if profileExists {
|
|
||||||
return ErrProfileAlreadyExists
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
id, err := generateProfileID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate profile id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profPath := filepath.Join(configDir, id.String()+".json")
|
||||||
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath})
|
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create new config: %w", err)
|
return nil, fmt.Errorf("failed to create new config: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Name = displayName
|
||||||
|
|
||||||
|
if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write profile config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = util.WriteJson(context.Background(), profPath, cfg)
|
return &Profile{
|
||||||
|
ID: id,
|
||||||
|
Name: displayName,
|
||||||
|
Path: profPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error {
|
||||||
|
displayName, err := sanitizeDisplayName(newName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write profile config: %w", err)
|
return fmt.Errorf("invalid profile name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !IsValidProfileFilenameStem(id) {
|
||||||
|
return fmt.Errorf("invalid profile ID: %q", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles, err := s.loadAllProfiles(username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load profiles: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var target *Profile
|
||||||
|
for i := range profiles {
|
||||||
|
if profiles[i].ID == id {
|
||||||
|
target = &profiles[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
return ErrProfileNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(target.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Name = displayName
|
||||||
|
|
||||||
|
if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to write profile name: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServiceManager) RemoveProfile(profileName, username string) error {
|
// RemoveProfile deletes the profile identified by id. Callers must have
|
||||||
configDir, err := s.getConfigDir(username)
|
// already resolved any user-supplied handle to a concrete ID via
|
||||||
if err != nil {
|
// ResolveProfile.
|
||||||
return fmt.Errorf("failed to get config directory: %w", err)
|
func (s *ServiceManager) RemoveProfile(id ID, username string) error {
|
||||||
|
if id == defaultProfileName {
|
||||||
|
defaultName := readProfileName(DefaultConfigPath)
|
||||||
|
if defaultName == "" {
|
||||||
|
defaultName = defaultProfileName
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cannot remove default profile with name: %s", defaultName)
|
||||||
|
}
|
||||||
|
if !IsValidProfileFilenameStem(id) {
|
||||||
|
return fmt.Errorf("invalid profile ID: %q", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
profileName = sanitizeProfileName(profileName)
|
profiles, err := s.loadAllProfiles(username)
|
||||||
|
|
||||||
if profileName == defaultProfileName {
|
|
||||||
return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName)
|
|
||||||
}
|
|
||||||
profPath := filepath.Join(configDir, profileName+".json")
|
|
||||||
profileExists, err := fileExists(profPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if profile exists: %w", err)
|
return fmt.Errorf("load profiles: %w", err)
|
||||||
}
|
}
|
||||||
if !profileExists {
|
|
||||||
|
var target *Profile
|
||||||
|
for i := range profiles {
|
||||||
|
if profiles[i].ID == id {
|
||||||
|
target = &profiles[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
return ErrProfileNotFound
|
return ErrProfileNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,57 +406,26 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error {
|
|||||||
if err != nil && !errors.Is(err, ErrNoActiveProfile) {
|
if err != nil && !errors.Is(err, ErrNoActiveProfile) {
|
||||||
return fmt.Errorf("failed to get active profile: %w", err)
|
return fmt.Errorf("failed to get active profile: %w", err)
|
||||||
}
|
}
|
||||||
|
if activeProf != nil && activeProf.ID == id {
|
||||||
if activeProf != nil && activeProf.Name == profileName {
|
return fmt.Errorf("cannot remove active profile: %s", id)
|
||||||
return fmt.Errorf("cannot remove active profile: %s", profileName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = util.RemoveJson(profPath)
|
if err := util.RemoveJson(target.Path); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove profile config: %w", err)
|
return fmt.Errorf("failed to remove profile config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stateFile := filepath.Join(filepath.Dir(target.Path), id.String()+".state.json")
|
||||||
|
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Warnf("failed to remove profile state file %s: %v", stateFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListProfiles returns every profile for the given user, including the
|
||||||
|
// default profile, with IsActive flags set.
|
||||||
func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) {
|
func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) {
|
||||||
configDir, err := s.getConfigDir(username)
|
return s.loadAllProfiles(username)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get config directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := util.ListFiles(configDir, "*.json")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list profile files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var filtered []string
|
|
||||||
for _, file := range files {
|
|
||||||
if strings.HasSuffix(file, "state.json") {
|
|
||||||
continue // skip state files
|
|
||||||
}
|
|
||||||
filtered = append(filtered, file)
|
|
||||||
}
|
|
||||||
sort.Strings(filtered)
|
|
||||||
|
|
||||||
var activeProfName string
|
|
||||||
activeProf, err := s.GetActiveProfileState()
|
|
||||||
if err == nil {
|
|
||||||
activeProfName = activeProf.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
var profiles []Profile
|
|
||||||
// add default profile always
|
|
||||||
profiles = append(profiles, Profile{Name: defaultProfileName, IsActive: activeProfName == "" || activeProfName == defaultProfileName})
|
|
||||||
for _, file := range filtered {
|
|
||||||
profileName := strings.TrimSuffix(filepath.Base(file), ".json")
|
|
||||||
var isActive bool
|
|
||||||
if activeProfName != "" && activeProfName == profileName {
|
|
||||||
isActive = true
|
|
||||||
}
|
|
||||||
profiles = append(profiles, Profile{Name: profileName, IsActive: isActive})
|
|
||||||
}
|
|
||||||
|
|
||||||
return profiles, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatePath returns the path to the state file based on the operating system
|
// GetStatePath returns the path to the state file based on the operating system
|
||||||
@@ -369,7 +443,12 @@ func (s *ServiceManager) GetStatePath() string {
|
|||||||
return defaultStatePath
|
return defaultStatePath
|
||||||
}
|
}
|
||||||
|
|
||||||
if activeProf.Name == defaultProfileName {
|
if activeProf.ID == defaultProfileName {
|
||||||
|
return defaultStatePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if !IsValidProfileFilenameStem(activeProf.ID) {
|
||||||
|
log.Warnf("invalid active profile ID %q, using default state path", activeProf.ID)
|
||||||
return defaultStatePath
|
return defaultStatePath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,7 +458,7 @@ func (s *ServiceManager) GetStatePath() string {
|
|||||||
return defaultStatePath
|
return defaultStatePath
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Join(configDir, activeProf.Name+".state.json")
|
return filepath.Join(configDir, activeProf.ID.String()+".state.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser
|
// getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser
|
||||||
@@ -390,3 +469,169 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) {
|
|||||||
|
|
||||||
return getConfigDirForUser(username)
|
return getConfigDirForUser(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadAllProfiles returns every profile visible to the daemon for the
|
||||||
|
// given user, including the default profile. The returned slice is sorted
|
||||||
|
// by ID for a stable display order.
|
||||||
|
//
|
||||||
|
// Each Profile is fully populated: ID is the filename stem, Name comes
|
||||||
|
// from the JSON's "name" field (falling back to the filename stem when absent)
|
||||||
|
// and Path is built from a basename read off disk.
|
||||||
|
func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) {
|
||||||
|
activeID, activeIsDefault := s.activeProfileID()
|
||||||
|
defaultName := readProfileName(DefaultConfigPath)
|
||||||
|
if defaultName == "" {
|
||||||
|
defaultName = defaultProfileName
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := []Profile{{
|
||||||
|
ID: defaultProfileName,
|
||||||
|
Name: defaultName,
|
||||||
|
Path: DefaultConfigPath,
|
||||||
|
IsActive: activeIsDefault,
|
||||||
|
}}
|
||||||
|
|
||||||
|
configDir, err := s.getConfigDir(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(configDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return profiles, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read profile directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileProfiles []Profile
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := entry.Name()
|
||||||
|
if !strings.HasSuffix(base, ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(base, ".state.json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stem := ID(strings.TrimSuffix(base, ".json"))
|
||||||
|
if stem == defaultProfileName {
|
||||||
|
// default lives at the top-level config dir, not under /<user>
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !IsValidProfileFilenameStem(ID(stem)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(configDir, base)
|
||||||
|
name := readProfileName(path)
|
||||||
|
if name == "" {
|
||||||
|
name = stem.String()
|
||||||
|
}
|
||||||
|
fileProfiles = append(fileProfiles, Profile{
|
||||||
|
ID: stem,
|
||||||
|
Name: name,
|
||||||
|
Path: path,
|
||||||
|
IsActive: stem == ID(activeID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(fileProfiles, func(i, j int) bool {
|
||||||
|
if fileProfiles[i].Name != fileProfiles[j].Name {
|
||||||
|
return fileProfiles[i].Name < fileProfiles[j].Name
|
||||||
|
}
|
||||||
|
// Sort tie-break on ID so duplicate names always render in the same order.
|
||||||
|
return fileProfiles[i].ID < fileProfiles[j].ID
|
||||||
|
})
|
||||||
|
profiles = append(profiles, fileProfiles...)
|
||||||
|
return profiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readProfileName parses just the "name" field from the profile Json.
|
||||||
|
func readProfileName(path string) string {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var meta profileMeta
|
||||||
|
if err := json.Unmarshal(data, &meta); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return meta.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeProfileID returns the currently-active profile's ID. The second
|
||||||
|
// return value is true when the active profile is the default one.
|
||||||
|
func (s *ServiceManager) activeProfileID() (ID, bool) {
|
||||||
|
state, err := s.GetActiveProfileState()
|
||||||
|
if err != nil || state == nil {
|
||||||
|
return defaultProfileName, true
|
||||||
|
}
|
||||||
|
if state.ID == "" || state.ID == defaultProfileName {
|
||||||
|
return defaultProfileName, true
|
||||||
|
}
|
||||||
|
return state.ID, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveProfile turns a user-supplied handle into a Profile. Resolution
|
||||||
|
// precedence is: exact ID match, then unique exact name, then unique ID
|
||||||
|
// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can
|
||||||
|
// surface the candidates.
|
||||||
|
func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, error) {
|
||||||
|
if handle == "" {
|
||||||
|
return nil, fmt.Errorf("profile handle is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles, err := s.loadAllProfiles(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range profiles {
|
||||||
|
if profiles[i].ID == ID(handle) {
|
||||||
|
return &profiles[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameMatches []Profile
|
||||||
|
for i := range profiles {
|
||||||
|
if profiles[i].Name == handle {
|
||||||
|
nameMatches = append(nameMatches, profiles[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(nameMatches) == 1 {
|
||||||
|
return &nameMatches[0], nil
|
||||||
|
}
|
||||||
|
if len(nameMatches) > 1 {
|
||||||
|
return nil, &ErrAmbiguousHandle{
|
||||||
|
Handle: handle,
|
||||||
|
Candidates: nameMatches,
|
||||||
|
Kind: AmbiguityKindName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID prefix match. Skip the default profile so `select d` does not
|
||||||
|
// accidentally pick it via prefix.
|
||||||
|
var prefixMatches []Profile
|
||||||
|
for i := range profiles {
|
||||||
|
if profiles[i].ID == defaultProfileName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(profiles[i].ID.String(), handle) {
|
||||||
|
prefixMatches = append(prefixMatches, profiles[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(prefixMatches) == 1 {
|
||||||
|
return &prefixMatches[0], nil
|
||||||
|
}
|
||||||
|
if len(prefixMatches) > 1 {
|
||||||
|
return nil, &ErrAmbiguousHandle{
|
||||||
|
Handle: handle,
|
||||||
|
Candidates: prefixMatches,
|
||||||
|
Kind: AmbiguityKindIDPrefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrProfileNotFound
|
||||||
|
}
|
||||||
|
|||||||
230
client/internal/profilemanager/service_test.go
Normal file
230
client/internal/profilemanager/service_test.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package profilemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withTestSM wires up patched globals + a clean config dir and returns a
|
||||||
|
// fully initialized ServiceManager plus the username we are scoped to.
|
||||||
|
func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
|
||||||
|
t.Helper()
|
||||||
|
withTempConfigDir(t, func(configDir string) {
|
||||||
|
withPatchedGlobals(t, configDir, func() {
|
||||||
|
u, err := user.Current()
|
||||||
|
require.NoError(t, err)
|
||||||
|
sm := &ServiceManager{}
|
||||||
|
require.NoError(t, sm.CreateDefaultProfile())
|
||||||
|
fn(sm, u.Username)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceProfile_ExactID(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
created, err := sm.AddProfile("work", username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := sm.ResolveProfile(created.ID.String(), username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, created.ID, got.ID)
|
||||||
|
assert.Equal(t, "work", got.Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceProfile_IDPrefix(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
created, err := sm.AddProfile("work", username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
prefix := created.ID[:4]
|
||||||
|
got, err := sm.ResolveProfile(prefix.String(), username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, created.ID, got.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
// Plant two profiles whose IDs share a known prefix by writing
|
||||||
|
// the files directly, since generated IDs are random.
|
||||||
|
configDir, err := sm.getConfigDir(username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} {
|
||||||
|
path := filepath.Join(configDir, id+".json")
|
||||||
|
require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id}))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = sm.ResolveProfile("abcd", username)
|
||||||
|
var amb *ErrAmbiguousHandle
|
||||||
|
require.ErrorAs(t, err, &amb)
|
||||||
|
assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind)
|
||||||
|
assert.Len(t, amb.Candidates, 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceProfile_ExactNameUnique(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
_, err := sm.AddProfile("work", username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := sm.ResolveProfile("work", username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "work", got.Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceProfile_AmbiguousName(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
_, err := sm.AddProfile("work", username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = sm.AddProfile("work", username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = sm.ResolveProfile("work", username)
|
||||||
|
var amb *ErrAmbiguousHandle
|
||||||
|
require.ErrorAs(t, err, &amb)
|
||||||
|
assert.Equal(t, AmbiguityKindName, amb.Kind)
|
||||||
|
assert.Len(t, amb.Candidates, 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceProfile_NotFound(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
_, err := sm.ResolveProfile("nope", username)
|
||||||
|
assert.ErrorIs(t, err, ErrProfileNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceProfile_DefaultByExactID(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
got, err := sm.ResolveProfile(defaultProfileName, username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, defaultProfileName, got.ID.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
|
||||||
|
// Legacy profiles stored as <name>.json with no "name" JSON field
|
||||||
|
// should still be discoverable by name and removable by name.
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
configDir, err := sm.getConfigDir(username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
path := filepath.Join(configDir, "legacy.json")
|
||||||
|
require.NoError(t, util.WriteJson(context.Background(), path, &Config{}))
|
||||||
|
|
||||||
|
got, err := sm.ResolveProfile("legacy", username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "legacy", got.ID.String())
|
||||||
|
// Name falls back to the filename stem when JSON omits it.
|
||||||
|
assert.Equal(t, "legacy", got.Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
first, err := sm.AddProfile("work", username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
second, err := sm.AddProfile("work", username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, first.ID, second.ID)
|
||||||
|
assert.Equal(t, "work", second.Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddProfile_RejectsInvalidNames(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
cases := []string{
|
||||||
|
"", // empty
|
||||||
|
"\x00\x01", // only control chars (becomes empty)
|
||||||
|
strings.Repeat("a", maxProfileNameLen+1), // too long
|
||||||
|
}
|
||||||
|
for _, name := range cases {
|
||||||
|
_, err := sm.AddProfile(name, username)
|
||||||
|
assert.Error(t, err, "expected error for %q", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveProfile_RejectsInvalidID(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
err := sm.RemoveProfile("../escape", username)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeDisplayName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"work", "work", false},
|
||||||
|
{"My Work Account", "My Work Account", false},
|
||||||
|
{"emoji 🚀 ok", "emoji 🚀 ok", false},
|
||||||
|
{"漢字テスト", "漢字テスト", false},
|
||||||
|
{"with\x00null", "withnull", false},
|
||||||
|
{"\x01\x02\x03", "", true},
|
||||||
|
{"", "", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, err := sanitizeDisplayName(tc.in)
|
||||||
|
if tc.wantErr {
|
||||||
|
assert.Error(t, err, "case %q", tc.in)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.NoError(t, err, "case %q", tc.in)
|
||||||
|
assert.Equal(t, tc.want, got, "case %q", tc.in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidProfileFilenameStem(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"default", true},
|
||||||
|
{"abc123def456", true},
|
||||||
|
{"legacy-name", true},
|
||||||
|
{"legacy_name", true},
|
||||||
|
{"", false},
|
||||||
|
{"..", false},
|
||||||
|
{"../etc", false},
|
||||||
|
{"foo/bar", false},
|
||||||
|
{`foo\bar`, false},
|
||||||
|
{"with space", false},
|
||||||
|
{"with.dot", false},
|
||||||
|
{strings.Repeat("a", maxProfileIDLen+1), false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := IsValidProfileFilenameStem(ID(tc.in))
|
||||||
|
assert.Equal(t, tc.want, got, "case %q", tc.in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveProfile_DeletesStateFile(t *testing.T) {
|
||||||
|
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||||
|
created, err := sm.AddProfile("work", username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
configDir, err := sm.getConfigDir(username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
statePath := filepath.Join(configDir, created.ID.String()+".state.json")
|
||||||
|
require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600))
|
||||||
|
|
||||||
|
require.NoError(t, sm.RemoveProfile(created.ID, username))
|
||||||
|
_, err = os.Stat(statePath)
|
||||||
|
assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,13 +13,20 @@ type ProfileState struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, error) {
|
// GetProfileState reads the per-profile state file keyed by profile ID.
|
||||||
|
// The state file lives in the user's config directory. Legacy state files
|
||||||
|
// keyed by the old profile name remain readable.
|
||||||
|
func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
|
||||||
configDir, err := getConfigDir()
|
configDir, err := getConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get config directory: %w", err)
|
return nil, fmt.Errorf("get config directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stateFile := filepath.Join(configDir, profileName+".state.json")
|
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||||
|
return nil, fmt.Errorf("invalid profile ID: %q", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||||
stateFileExists, err := fileExists(stateFile)
|
stateFileExists, err := fileExists(stateFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check if profile state file exists: %w", err)
|
return nil, fmt.Errorf("failed to check if profile state file exists: %w", err)
|
||||||
@@ -51,7 +58,12 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
|||||||
return fmt.Errorf("get active profile: %w", err)
|
return fmt.Errorf("get active profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stateFile := filepath.Join(configDir, activeProf.Name+".state.json")
|
id := activeProf.ID
|
||||||
|
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||||
|
return fmt.Errorf("invalid active profile ID: %q", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||||
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
|
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write profile state: %w", err)
|
return fmt.Errorf("write profile state: %w", err)
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ type ProbeResult struct {
|
|||||||
URI string
|
URI string
|
||||||
Err error
|
Err error
|
||||||
Addr string
|
Addr string
|
||||||
|
// Transport is the negotiated relay transport, empty
|
||||||
|
// for stun/turn probes or when not connected.
|
||||||
|
Transport string
|
||||||
}
|
}
|
||||||
|
|
||||||
type StunTurnProbe struct {
|
type StunTurnProbe struct {
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ func hashRosenpassKey(key []byte) string {
|
|||||||
return hex.EncodeToString(hasher.Sum(nil))
|
return hex.EncodeToString(hasher.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rpServer is the subset of rp.Server used by Manager. Defined as an interface
|
||||||
|
// so tests can substitute a mock without spinning up a real UDP server.
|
||||||
|
type rpServer interface {
|
||||||
|
AddPeer(rp.PeerConfig) (rp.PeerID, error)
|
||||||
|
RemovePeer(rp.PeerID) error
|
||||||
|
Run() error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
ifaceName string
|
ifaceName string
|
||||||
spk []byte
|
spk []byte
|
||||||
@@ -36,7 +45,7 @@ type Manager struct {
|
|||||||
preSharedKey *[32]byte
|
preSharedKey *[32]byte
|
||||||
rpPeerIDs map[string]*rp.PeerID
|
rpPeerIDs map[string]*rp.PeerID
|
||||||
rpWgHandler *NetbirdHandler
|
rpWgHandler *NetbirdHandler
|
||||||
server *rp.Server
|
server rpServer
|
||||||
lock sync.Mutex
|
lock sync.Mutex
|
||||||
port int
|
port int
|
||||||
wgIface PresharedKeySetter
|
wgIface PresharedKeySetter
|
||||||
@@ -51,7 +60,22 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
|
|||||||
|
|
||||||
rpKeyHash := hashRosenpassKey(public)
|
rpKeyHash := hashRosenpassKey(public)
|
||||||
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
|
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
|
||||||
return &Manager{ifaceName: wgIfaceName, rpKeyHash: rpKeyHash, spk: public, ssk: secret, preSharedKey: (*[32]byte)(preSharedKey), rpPeerIDs: make(map[string]*rp.PeerID), lock: sync.Mutex{}}, nil
|
return &Manager{
|
||||||
|
ifaceName: wgIfaceName,
|
||||||
|
rpKeyHash: rpKeyHash,
|
||||||
|
spk: public,
|
||||||
|
ssk: secret,
|
||||||
|
preSharedKey: (*[32]byte)(preSharedKey),
|
||||||
|
rpPeerIDs: make(map[string]*rp.PeerID),
|
||||||
|
// rpWgHandler is created here (instead of only in generateConfig) so it
|
||||||
|
// is never nil between NewManager and Run(). Otherwise an early
|
||||||
|
// OnConnected call (race observed on Android, issue #4341) panics on
|
||||||
|
// nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
|
||||||
|
// replace it with a fresh handler on each Run() to clear stale peer
|
||||||
|
// state from previous engine sessions.
|
||||||
|
rpWgHandler: NewNetbirdHandler(),
|
||||||
|
lock: sync.Mutex{},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetPubKey() []byte {
|
func (m *Manager) GetPubKey() []byte {
|
||||||
@@ -65,6 +89,16 @@ func (m *Manager) GetAddress() *net.UDPAddr {
|
|||||||
|
|
||||||
// addPeer adds a new peer to the Rosenpass server
|
// addPeer adds a new peer to the Rosenpass server
|
||||||
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
|
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
|
||||||
|
// Defense in depth against issue #4341 (Android crash): if Run() has not
|
||||||
|
// completed yet, m.server / m.rpWgHandler may be nil. Return an explicit
|
||||||
|
// error instead of panicking on nil-receiver dereference.
|
||||||
|
if m.server == nil {
|
||||||
|
return fmt.Errorf("rosenpass server not initialized")
|
||||||
|
}
|
||||||
|
if m.rpWgHandler == nil {
|
||||||
|
return fmt.Errorf("rosenpass wg handler not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
pcfg := rp.PeerConfig{PublicKey: rosenpassPubKey}
|
pcfg := rp.PeerConfig{PublicKey: rosenpassPubKey}
|
||||||
if m.preSharedKey != nil {
|
if m.preSharedKey != nil {
|
||||||
@@ -79,6 +113,16 @@ func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuar
|
|||||||
if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil {
|
if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil {
|
||||||
return fmt.Errorf("failed to resolve peer endpoint address: %w", err)
|
return fmt.Errorf("failed to resolve peer endpoint address: %w", err)
|
||||||
}
|
}
|
||||||
|
// Our local Rosenpass UDP server binds on the IPv6 wildcard ([::]) — see
|
||||||
|
// GetAddress(). The remote peer's endpoint (pcfg.Endpoint) is the destination
|
||||||
|
// our server will sendto when initiating handshakes. ResolveUDPAddr returns a
|
||||||
|
// 4-byte IPv4 for IPv4 hosts, which the kernel rejects (EDESTADDRREQ) when
|
||||||
|
// sent from an AF_INET6 socket. Normalize the remote endpoint to IPv4-mapped
|
||||||
|
// IPv6 so its address family matches our listening socket.
|
||||||
|
// TODO: maybe bind the Rosenpass UDP server to the peer wg IP addr
|
||||||
|
if v4 := pcfg.Endpoint.IP.To4(); v4 != nil {
|
||||||
|
pcfg.Endpoint.IP = v4.To16()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
peerID, err := m.server.AddPeer(pcfg)
|
peerID, err := m.server.AddPeer(pcfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,24 +226,31 @@ func (m *Manager) Run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.server, err = rp.NewUDPServer(conf)
|
server, err := rp.NewUDPServer(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.lock.Lock()
|
||||||
|
m.server = server
|
||||||
|
m.lock.Unlock()
|
||||||
|
|
||||||
log.Infof("starting rosenpass server on port %d", m.port)
|
log.Infof("starting rosenpass server on port %d", m.port)
|
||||||
|
|
||||||
return m.server.Run()
|
return server.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the Rosenpass server
|
// Close closes the Rosenpass server
|
||||||
func (m *Manager) Close() error {
|
func (m *Manager) Close() error {
|
||||||
if m.server != nil {
|
m.lock.Lock()
|
||||||
err := m.server.Close()
|
server := m.server
|
||||||
if err != nil {
|
m.server = nil
|
||||||
log.Errorf("failed closing local rosenpass server")
|
m.lock.Unlock()
|
||||||
}
|
if server == nil {
|
||||||
m.server = nil
|
return nil
|
||||||
|
}
|
||||||
|
if err := server.Close(); err != nil {
|
||||||
|
log.Errorf("failed closing local rosenpass server: %v", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,412 @@
|
|||||||
package rosenpass
|
package rosenpass
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
rp "cunicu.li/go-rosenpass"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- test doubles -----------------------------------------------------------
|
||||||
|
|
||||||
|
type addPeerCall struct {
|
||||||
|
cfg rp.PeerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type removePeerCall struct {
|
||||||
|
id rp.PeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockServer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
addCalls []addPeerCall
|
||||||
|
removed []removePeerCall
|
||||||
|
nextID rp.PeerID
|
||||||
|
addErr error
|
||||||
|
removeErr error
|
||||||
|
closed bool
|
||||||
|
ran bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.addCalls = append(m.addCalls, addPeerCall{cfg: cfg})
|
||||||
|
if m.addErr != nil {
|
||||||
|
return rp.PeerID{}, m.addErr
|
||||||
|
}
|
||||||
|
// Increment a byte in nextID so distinct peers get distinct IDs.
|
||||||
|
m.nextID[0]++
|
||||||
|
return m.nextID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockServer) RemovePeer(id rp.PeerID) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.removed = append(m.removed, removePeerCall{id: id})
|
||||||
|
return m.removeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockServer) Run() error { m.ran = true; return nil }
|
||||||
|
func (m *mockServer) Close() error { m.closed = true; return nil }
|
||||||
|
|
||||||
|
type setPSKCall struct {
|
||||||
|
peerKey string
|
||||||
|
psk wgtypes.Key
|
||||||
|
updateOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockIface struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []setPSKCall
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.calls = append(m.calls, setPSKCall{peerKey: peerKey, psk: psk, updateOnly: updateOnly})
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestManager builds a Manager with deterministic spk so tie-break
|
||||||
|
// against a peer pubkey is controllable from tests. The provided spk byte
|
||||||
|
// becomes the first byte; remaining bytes are zero.
|
||||||
|
func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
|
||||||
|
spk := make([]byte, 32)
|
||||||
|
spk[0] = spkFirstByte
|
||||||
|
return &Manager{
|
||||||
|
ifaceName: "wt0",
|
||||||
|
spk: spk,
|
||||||
|
ssk: make([]byte, 32),
|
||||||
|
rpKeyHash: "test-hash",
|
||||||
|
rpPeerIDs: make(map[string]*rp.PeerID),
|
||||||
|
rpWgHandler: NewNetbirdHandler(),
|
||||||
|
server: mock,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validWGKey returns a deterministic 32-byte wireguard public key (base64).
|
||||||
|
func validWGKey(t *testing.T, lastByte byte) string {
|
||||||
|
t.Helper()
|
||||||
|
var k wgtypes.Key
|
||||||
|
k[31] = lastByte
|
||||||
|
return k.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pure helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHashRosenpassKey_Deterministic(t *testing.T) {
|
||||||
|
key := []byte("hello-rosenpass")
|
||||||
|
require.Equal(t, hashRosenpassKey(key), hashRosenpassKey(key))
|
||||||
|
require.Len(t, hashRosenpassKey(key), 64) // sha256 hex
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashRosenpassKey_DifferentInputsDifferOutputs(t *testing.T) {
|
||||||
|
require.NotEqual(t, hashRosenpassKey([]byte("a")), hashRosenpassKey([]byte("b")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLogLevel_DefaultWhenUnset(t *testing.T) {
|
||||||
|
// Snapshot + unset to exercise the LookupEnv ok=false branch. t.Setenv
|
||||||
|
// can only set, not delete, so do it manually with restore via t.Cleanup.
|
||||||
|
prev, hadPrev := os.LookupEnv(defaultLogLevelVar)
|
||||||
|
require.NoError(t, os.Unsetenv(defaultLogLevelVar))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if hadPrev {
|
||||||
|
_ = os.Setenv(defaultLogLevelVar, prev)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv(defaultLogLevelVar)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
require.Equal(t, defaultLog.String(), getLogLevel().String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLogLevel_Cases(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"debug": "DEBUG",
|
||||||
|
"info": "INFO",
|
||||||
|
"warn": "WARN",
|
||||||
|
"error": "ERROR",
|
||||||
|
"unknown": "INFO", // default fallback
|
||||||
|
}
|
||||||
|
for input, wantStr := range cases {
|
||||||
|
input, wantStr := input, wantStr
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
t.Setenv(defaultLogLevelVar, input)
|
||||||
|
require.Equal(t, wantStr, getLogLevel().String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFindRandomAvailableUDPPort(t *testing.T) {
|
func TestFindRandomAvailableUDPPort(t *testing.T) {
|
||||||
port, err := findRandomAvailableUDPPort()
|
port, err := findRandomAvailableUDPPort()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Greater(t, port, 0)
|
require.Greater(t, port, 0)
|
||||||
require.LessOrEqual(t, port, 65535)
|
require.LessOrEqual(t, port, 65535)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- addPeer ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestAddPeer_HigherLocalPubkey_SetsEndpoint(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv) // local spk lexicographically larger
|
||||||
|
|
||||||
|
remotePubKey := make([]byte, 32) // remote spk = all zeros (smaller)
|
||||||
|
err := m.addPeer(remotePubKey, "rosenpass-host:7000", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, srv.addCalls, 1)
|
||||||
|
|
||||||
|
ep := srv.addCalls[0].cfg.Endpoint
|
||||||
|
require.NotNil(t, ep, "initiator side must set Endpoint")
|
||||||
|
require.Equal(t, 7000, ep.Port)
|
||||||
|
require.Equal(t, "100.1.1.1", ep.IP.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_HigherLocalPubkey_EndpointIPIsIPv4Mapped(t *testing.T) {
|
||||||
|
// Regression guard for the EDESTADDRREQ fix: Endpoint.IP must be 16-byte
|
||||||
|
// (IPv4-mapped IPv6) so it matches the AF_INET6 listening socket family.
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ep := srv.addCalls[0].cfg.Endpoint
|
||||||
|
require.NotNil(t, ep)
|
||||||
|
require.Len(t, ep.IP, 16, "IPv4 endpoint must be normalized to 16-byte v4-mapped form")
|
||||||
|
require.True(t, ep.IP.To4() != nil, "Endpoint must still be detected as IPv4")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_LowerLocalPubkey_LeavesEndpointNil(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0x00, srv) // local spk smaller
|
||||||
|
|
||||||
|
remotePubKey := make([]byte, 32)
|
||||||
|
remotePubKey[0] = 0xFF
|
||||||
|
err := m.addPeer(remotePubKey, "rp:5000", "100.1.1.1", validWGKey(t, 2))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Nil(t, srv.addCalls[0].cfg.Endpoint, "responder side must NOT set Endpoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_PresharedKeyPropagated(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
psk := &wgtypes.Key{0x42}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
m.preSharedKey = (*[32]byte)(psk)
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 3))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, [32]byte(*psk), [32]byte(srv.addCalls[0].cfg.PresharedKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_InvalidRosenpassAddr_ReturnsError(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv) // initiator path → parses rosenpassAddr
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "not-a-host-port", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, srv.addCalls, "server.AddPeer must not run when address parse fails")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_InvalidWireGuardPubKey_ReturnsError(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", "not-a-valid-key")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_ServerError_Propagates(t *testing.T) {
|
||||||
|
srv := &mockServer{addErr: errors.New("boom")}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for issue #4341 (Android crash). If Run() has not completed
|
||||||
|
// before OnConnected fires, m.rpWgHandler or m.server may be nil. Without the
|
||||||
|
// nil guards, m.rpWgHandler.AddPeer panics on nil receiver.
|
||||||
|
func TestAddPeer_NilHandler_ReturnsErrorNoCrash(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
m.rpWgHandler = nil // simulate Run() not yet completed
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "wg handler not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
|
||||||
|
m := newTestManager(0xFF, nil)
|
||||||
|
m.server = nil // simulate Run() not yet completed
|
||||||
|
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "server not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager must pre-initialize rpWgHandler so the nil-receiver crash from
|
||||||
|
// issue #4341 cannot occur in the window between NewManager and Run().
|
||||||
|
func TestNewManager_PreInitializesHandler(t *testing.T) {
|
||||||
|
psk := wgtypes.Key{}
|
||||||
|
m, err := NewManager(&psk, "wt0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPeer_RecordsPeerID(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
wgKey := validWGKey(t, 5)
|
||||||
|
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, m.rpPeerIDs, wgKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OnConnected / OnDisconnected ------------------------------------------
|
||||||
|
|
||||||
|
func TestOnConnected_NilRemotePubKey_NoAddPeer(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
m.OnConnected(validWGKey(t, 1), nil, "100.1.1.1", "rp:5000")
|
||||||
|
require.Empty(t, srv.addCalls, "nil remote rosenpass pubkey must skip AddPeer")
|
||||||
|
require.Empty(t, m.rpPeerIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnConnected_ValidPubKey_CallsAddPeer(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
wgKey := validWGKey(t, 1)
|
||||||
|
m.OnConnected(wgKey, make([]byte, 32), "100.1.1.1", "rp:5000")
|
||||||
|
require.Len(t, srv.addCalls, 1)
|
||||||
|
require.Contains(t, m.rpPeerIDs, wgKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnDisconnected_UnknownPeer_NoOp(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
m.OnDisconnected(validWGKey(t, 99))
|
||||||
|
require.Empty(t, srv.removed, "unknown peer key must not call RemovePeer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnDisconnected_KnownPeer_CallsRemoveAndForgets(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
wgKey := validWGKey(t, 1)
|
||||||
|
require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
|
||||||
|
require.Contains(t, m.rpPeerIDs, wgKey)
|
||||||
|
|
||||||
|
m.OnDisconnected(wgKey)
|
||||||
|
require.Len(t, srv.removed, 1)
|
||||||
|
require.NotContains(t, m.rpPeerIDs, wgKey, "peer must be forgotten after disconnect")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IsPresharedKeyInitialized ---------------------------------------------
|
||||||
|
|
||||||
|
func TestIsPresharedKeyInitialized_UnknownPeer_ReturnsFalse(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
require.False(t, m.IsPresharedKeyInitialized(validWGKey(t, 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing.T) {
|
||||||
|
srv := &mockServer{}
|
||||||
|
m := newTestManager(0xFF, srv)
|
||||||
|
|
||||||
|
wgKey := validWGKey(t, 2)
|
||||||
|
require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
|
||||||
|
require.False(t, m.IsPresharedKeyInitialized(wgKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NetbirdHandler.outputKey ----------------------------------------------
|
||||||
|
|
||||||
|
func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
iface := &mockIface{}
|
||||||
|
h.SetInterface(iface)
|
||||||
|
|
||||||
|
pid := rp.PeerID{0x01}
|
||||||
|
wgKey := wgtypes.Key{0xAA}
|
||||||
|
h.AddPeer(pid, "wt0", rp.Key(wgKey))
|
||||||
|
|
||||||
|
psk := rp.Key{0xBB}
|
||||||
|
h.HandshakeCompleted(pid, psk)
|
||||||
|
|
||||||
|
require.Len(t, iface.calls, 1)
|
||||||
|
require.False(t, iface.calls[0].updateOnly, "first PSK rotation must use updateOnly=false")
|
||||||
|
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
iface := &mockIface{}
|
||||||
|
h.SetInterface(iface)
|
||||||
|
|
||||||
|
pid := rp.PeerID{0x02}
|
||||||
|
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xCC}))
|
||||||
|
|
||||||
|
h.HandshakeCompleted(pid, rp.Key{0x01}) // first
|
||||||
|
h.HandshakeCompleted(pid, rp.Key{0x02}) // second
|
||||||
|
|
||||||
|
require.Len(t, iface.calls, 2)
|
||||||
|
require.False(t, iface.calls[0].updateOnly)
|
||||||
|
require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
// no SetInterface — iface remains nil
|
||||||
|
pid := rp.PeerID{0x03}
|
||||||
|
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
|
||||||
|
|
||||||
|
// Must not panic.
|
||||||
|
h.HandshakeCompleted(pid, rp.Key{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
iface := &mockIface{}
|
||||||
|
h.SetInterface(iface)
|
||||||
|
|
||||||
|
h.HandshakeCompleted(rp.PeerID{0xFF}, rp.Key{})
|
||||||
|
require.Empty(t, iface.calls, "unknown peer id must not trigger SetPresharedKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
iface := &mockIface{}
|
||||||
|
h.SetInterface(iface)
|
||||||
|
|
||||||
|
pid := rp.PeerID{0x04}
|
||||||
|
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xDD}))
|
||||||
|
h.HandshakeCompleted(pid, rp.Key{0x01})
|
||||||
|
require.True(t, h.IsPeerInitialized(pid))
|
||||||
|
|
||||||
|
h.RemovePeer(pid)
|
||||||
|
require.False(t, h.IsPeerInitialized(pid), "RemovePeer must clear initialized flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
|
||||||
|
h := NewNetbirdHandler()
|
||||||
|
pid := rp.PeerID{0x05}
|
||||||
|
wgKey := wgtypes.Key{0xEE}
|
||||||
|
h.AddPeer(pid, "wt0", rp.Key(wgKey))
|
||||||
|
|
||||||
|
iface := &mockIface{}
|
||||||
|
h.SetInterface(iface) // set after AddPeer
|
||||||
|
|
||||||
|
h.HandshakeCompleted(pid, rp.Key{0x42})
|
||||||
|
require.Len(t, iface.calls, 1)
|
||||||
|
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
||||||
|
}
|
||||||
|
|||||||
42
client/internal/rosenpass/seed.go
Normal file
42
client/internal/rosenpass/seed.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package rosenpass
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
|
||||||
|
// of peer public keys. Both peers, given the same key pair, produce the same
|
||||||
|
// output regardless of which side runs the function: the inputs are ordered
|
||||||
|
// lexicographically before concatenation.
|
||||||
|
//
|
||||||
|
// NetBird uses this value as the initial Rosenpass-side preshared key when no
|
||||||
|
// explicit account-level PSK is configured, so both peers converge on the same
|
||||||
|
// PSK before the first post-quantum handshake completes.
|
||||||
|
//
|
||||||
|
// The resulting key MUST NOT be treated as quantum-safe: it is deterministic
|
||||||
|
// from public keys and exists only to seed WireGuard until Rosenpass rotates
|
||||||
|
// in a real post-quantum PSK.
|
||||||
|
func DeterministicSeedKey(localKey, remoteKey string) (*wgtypes.Key, error) {
|
||||||
|
lk := []byte(localKey)
|
||||||
|
rk := []byte(remoteKey)
|
||||||
|
if len(lk) < 16 || len(rk) < 16 {
|
||||||
|
return nil, fmt.Errorf("rosenpass: peer keys must be at least 16 bytes (got local=%d, remote=%d)", len(lk), len(rk))
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyInput []byte
|
||||||
|
if localKey > remoteKey {
|
||||||
|
keyInput = append(keyInput, lk[:16]...)
|
||||||
|
keyInput = append(keyInput, rk[:16]...)
|
||||||
|
} else {
|
||||||
|
keyInput = append(keyInput, rk[:16]...)
|
||||||
|
keyInput = append(keyInput, lk[:16]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := wgtypes.NewKey(keyInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rosenpass: deterministic seed key: %w", err)
|
||||||
|
}
|
||||||
|
return &key, nil
|
||||||
|
}
|
||||||
43
client/internal/rosenpass/seed_test.go
Normal file
43
client/internal/rosenpass/seed_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user