mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-22 15:59:59 +00:00
Compare commits
100 Commits
client-jso
...
client_lif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7706f578fe | ||
|
|
daf5026192 | ||
|
|
ec18b07959 | ||
|
|
9628f016da | ||
|
|
b39e9df194 | ||
|
|
0388e0f262 | ||
|
|
86f896723d | ||
|
|
d3710d4bb2 | ||
|
|
29ee84999c | ||
|
|
0e8fd22f36 | ||
|
|
ff98105212 | ||
|
|
6465997a69 | ||
|
|
3204270c4b | ||
|
|
6d3bcef2c4 | ||
|
|
5d7cb30e5b | ||
|
|
aff5da2c8e | ||
|
|
ee360963f9 | ||
|
|
9b179be324 | ||
|
|
33e7b6a8f1 | ||
|
|
e0cff5e240 | ||
|
|
8d9580e491 | ||
|
|
5bd7c6c7ea | ||
|
|
8ae2cd0a08 | ||
|
|
e4397d4d46 | ||
|
|
6fbc90b4d3 | ||
|
|
5095e17cc5 | ||
|
|
0085aebf77 | ||
|
|
91d2d341b7 | ||
|
|
8d46580c13 | ||
|
|
b42fe6a10f | ||
|
|
0f5d7fdc07 | ||
|
|
13c78d98f5 | ||
|
|
d1229ed84c | ||
|
|
9758145517 | ||
|
|
200a5a6a70 | ||
|
|
1f7b1ea863 | ||
|
|
4abb10c1aa | ||
|
|
a45cefe57a | ||
|
|
a6d504633f | ||
|
|
70f2097fff | ||
|
|
befa9a879c | ||
|
|
4152c41796 | ||
|
|
8b76b3d824 | ||
|
|
0503a18644 | ||
|
|
6df0175607 | ||
|
|
3c23700e56 | ||
|
|
ec6512d660 | ||
|
|
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 | ||
|
|
eac6d501c3 | ||
|
|
deeae30612 | ||
|
|
f3cdf163e1 | ||
|
|
3e61ccb162 | ||
|
|
a48c20d8d8 | ||
|
|
2b57a7d43b | ||
|
|
fa1e241aea | ||
|
|
e7c9182ff9 | ||
|
|
9189625487 | ||
|
|
e9dbf9db6f | ||
|
|
5a9e9e7bc9 | ||
|
|
43e041cf9f | ||
|
|
77e5693200 | ||
|
|
174dc24867 | ||
|
|
7ea5e37dd4 | ||
|
|
9d7ef9b255 | ||
|
|
944a258459 | ||
|
|
1f9a829f2c |
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for problematic license dependencies
|
- name: Check for problematic license dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -56,55 +59,57 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: "go.mod"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Install go-licenses
|
- name: Install go-licenses
|
||||||
run: go install github.com/google/go-licenses@v1.6.0
|
run: go install github.com/google/go-licenses@v1.6.0
|
||||||
|
|
||||||
- name: Check for GPL/AGPL licensed dependencies
|
- name: Check for GPL/AGPL licensed dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
|
echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check all Go packages for copyleft licenses, excluding internal netbird packages
|
|
||||||
COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
|
|
||||||
|
|
||||||
if [ -n "$COPYLEFT_DEPS" ]; then
|
|
||||||
echo "Found copyleft licensed dependencies:"
|
|
||||||
echo "$COPYLEFT_DEPS"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Filter out dependencies that are only pulled in by internal AGPL packages
|
# Check all Go packages for copyleft licenses, excluding internal netbird packages
|
||||||
INCOMPATIBLE=""
|
COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
|
||||||
while IFS=',' read -r package url license; do
|
|
||||||
if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
|
|
||||||
# Find ALL packages that import this GPL package using go list
|
|
||||||
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
|
||||||
|
|
||||||
# Check if any importer is NOT in management/signal/relay
|
if [ -n "$COPYLEFT_DEPS" ]; then
|
||||||
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
echo "Found copyleft licensed dependencies:"
|
||||||
|
echo "$COPYLEFT_DEPS"
|
||||||
if [ -n "$BSD_IMPORTER" ]; then
|
|
||||||
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
|
||||||
INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
|
|
||||||
else
|
|
||||||
echo "✓ $package ($license) is only used by internal AGPL packages - OK"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<< "$COPYLEFT_DEPS"
|
|
||||||
|
|
||||||
if [ -n "$INCOMPATIBLE" ]; then
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
|
|
||||||
echo -e "$INCOMPATIBLE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ All external license dependencies are compatible with BSD-3-Clause"
|
# Filter out dependencies that are only pulled in by internal AGPL packages
|
||||||
|
INCOMPATIBLE=""
|
||||||
|
while IFS=',' read -r package url license; do
|
||||||
|
if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
|
||||||
|
# Find ALL packages that import this GPL package using go list
|
||||||
|
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
||||||
|
|
||||||
|
# Check if any importer is NOT in management/signal/relay
|
||||||
|
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
||||||
|
|
||||||
|
if [ -n "$BSD_IMPORTER" ]; then
|
||||||
|
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
||||||
|
INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
|
||||||
|
else
|
||||||
|
echo "✓ $package ($license) is only used by internal AGPL packages - OK"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<< "$COPYLEFT_DEPS"
|
||||||
|
|
||||||
|
if [ -n "$INCOMPATIBLE" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
|
||||||
|
echo -e "$INCOMPATIBLE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ All external license dependencies are compatible with BSD-3-Clause"
|
||||||
|
|||||||
2
.github/workflows/docs-ack.yml
vendored
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: git-town/action@v1.2.1
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1
|
||||||
with:
|
with:
|
||||||
skip-single-stacks: true
|
skip-single-stacks: true
|
||||||
|
|||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: macos-gotest-${{ hashFiles('**/go.sum') }}
|
key: macos-gotest-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -43,5 +45,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@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||||
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Read Go version from go.mod
|
||||||
|
id: goversion
|
||||||
|
run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Test in FreeBSD
|
- name: Test in FreeBSD
|
||||||
id: test
|
id: test
|
||||||
uses: vmactions/freebsd-vm@v1
|
env:
|
||||||
|
GO_VERSION: ${{ steps.goversion.outputs.version }}
|
||||||
|
uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
|
||||||
with:
|
with:
|
||||||
usesh: true
|
usesh: true
|
||||||
copyback: false
|
copyback: false
|
||||||
release: "14.2"
|
release: "15.0"
|
||||||
|
envs: "GO_VERSION"
|
||||||
prepare: |
|
prepare: |
|
||||||
pkg install -y curl pkgconf xorg
|
pkg install -y curl pkgconf xorg
|
||||||
GO_TARBALL="go1.25.3.freebsd-amd64.tar.gz"
|
GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
|
||||||
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
||||||
curl -vLO "$GO_URL"
|
curl -vLO "$GO_URL"
|
||||||
tar -C /usr/local -vxzf "$GO_TARBALL"
|
tar -C /usr/local -vxzf "$GO_TARBALL"
|
||||||
|
|
||||||
# -x - to print all executed commands
|
# -x - to print all executed commands
|
||||||
# -e - to faile on first error
|
# -e - to faile on first error
|
||||||
|
|||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||||
id: filter
|
id: filter
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
@@ -28,7 +30,7 @@ jobs:
|
|||||||
- 'management/**'
|
- 'management/**'
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -36,10 +38,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
id: cache
|
id: cache
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -113,14 +115,16 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: [ '386','amd64' ]
|
arch: ["386", "amd64"]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -128,10 +132,10 @@ jobs:
|
|||||||
- name: Get Go environment
|
- name: Get Go environment
|
||||||
run: |
|
run: |
|
||||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -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@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||||
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -177,7 +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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -246,10 +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@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||||
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -298,7 +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@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||||
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -343,10 +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@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||||
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -390,10 +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@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||||
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -485,10 +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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -577,10 +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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -644,10 +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@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||||
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
id: go
|
id: go
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
@@ -33,7 +35,7 @@ jobs:
|
|||||||
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
|
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ env.cache }}
|
${{ env.cache }}
|
||||||
@@ -44,16 +46,15 @@ jobs:
|
|||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Download wintun
|
- name: Download wintun
|
||||||
uses: carlosperate/download-file-action@v2
|
|
||||||
id: download-wintun
|
id: download-wintun
|
||||||
|
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
with:
|
||||||
file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||||
file-name: wintun.zip
|
destination: ${{ env.downloadPath }}\wintun.zip
|
||||||
location: ${{ env.downloadPath }}
|
sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
|
||||||
sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
|
|
||||||
|
|
||||||
- name: Decompressing wintun files
|
- name: Decompressing wintun files
|
||||||
run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
run: tar -xvf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
||||||
|
|
||||||
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
|
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
|
||||||
|
|
||||||
|
|||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: codespell
|
- name: codespell
|
||||||
uses: codespell-project/actions-codespell@v2
|
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
||||||
skip: go.mod,go.sum,**/proxy/web/**
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Check for duplicate constants
|
- name: Check for duplicate constants
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
|
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: run install script
|
- name: run install script
|
||||||
env:
|
env:
|
||||||
|
|||||||
18
.github/workflows/mobile-build-validation.yml
vendored
18
.github/workflows/mobile-build-validation.yml
vendored
@@ -16,23 +16,25 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||||
with:
|
with:
|
||||||
cmdline-tools-version: 8512546
|
cmdline-tools-version: 8512546
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
|
||||||
with:
|
with:
|
||||||
java-version: "11"
|
java-version: "11"
|
||||||
distribution: "adopt"
|
distribution: "adopt"
|
||||||
- name: NDK Cache
|
- name: NDK Cache
|
||||||
id: ndk-cache
|
id: ndk-cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: /usr/local/lib/android/sdk/ndk
|
path: /usr/local/lib/android/sdk/ndk
|
||||||
key: ndk-cache-23.1.7779620
|
key: ndk-cache-23.1.7779620
|
||||||
@@ -52,9 +54,11 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: install gomobile
|
- name: install gomobile
|
||||||
|
|||||||
2
.github/workflows/pr-title-check.yml
vendored
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;
|
||||||
|
|||||||
41
.github/workflows/proto-version-check.yml
vendored
41
.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,15 +20,30 @@ jobs:
|
|||||||
per_page: 100,
|
per_page: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
const modifiedPbFiles = files.filter(
|
// Cover renamed .pb.go files in addition to plain edits.
|
||||||
f => f.filename.endsWith('.pb.go') && f.status === 'modified'
|
// Renamed entries land under the new path with previous_filename
|
||||||
);
|
// pointing at the base-side name, so we read the base content
|
||||||
if (modifiedPbFiles.length === 0) {
|
// from the old path when present.
|
||||||
console.log('No modified .pb.go files to check');
|
const changedPbFiles = files
|
||||||
|
.filter(f => (f.status === 'modified' || f.status === 'renamed')
|
||||||
|
&& 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.]+/;
|
// Matches the generator version headers protoc writes at the top
|
||||||
|
// of generated files:
|
||||||
|
// // protoc v3.21.12
|
||||||
|
// // protoc-gen-go v1.26.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 baseSha = context.payload.pull_request.base.sha;
|
||||||
const headSha = context.payload.pull_request.head.sha;
|
const headSha = context.payload.pull_request.head.sha;
|
||||||
|
|
||||||
@@ -55,20 +70,22 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const violations = [];
|
const violations = [];
|
||||||
for (const file of modifiedPbFiles) {
|
for (const file of changedPbFiles) {
|
||||||
const [base, head] = await Promise.all([
|
const [base, head] = await Promise.all([
|
||||||
getVersionHeader(file.filename, baseSha),
|
getVersionHeader(file.basePath, baseSha),
|
||||||
getVersionHeader(file.filename, headSha),
|
getVersionHeader(file.headPath, headSha),
|
||||||
]);
|
]);
|
||||||
if (!base.ok || !head.ok) {
|
if (!base.ok || !head.ok) {
|
||||||
core.warning(
|
core.warning(
|
||||||
`Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
`Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (base.lines.join('\n') !== head.lines.join('\n')) {
|
if (base.lines.join('\n') !== head.lines.join('\n')) {
|
||||||
violations.push({
|
violations.push({
|
||||||
file: file.filename,
|
file: file.basePath === file.headPath
|
||||||
|
? file.headPath
|
||||||
|
: `${file.basePath} → ${file.headPath}`,
|
||||||
base: base.lines,
|
base: base.lines,
|
||||||
head: head.lines,
|
head: head.lines,
|
||||||
});
|
});
|
||||||
|
|||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Generate FreeBSD port diff
|
- name: Generate FreeBSD port diff
|
||||||
run: bash release_files/freebsd-port-diff.sh
|
run: bash -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@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
|
||||||
with:
|
with:
|
||||||
usesh: true
|
usesh: true
|
||||||
copyback: false
|
copyback: false
|
||||||
release: "15.0"
|
release: "15.0"
|
||||||
|
envs: "GO_VERSION"
|
||||||
prepare: |
|
prepare: |
|
||||||
# Install required packages
|
# Install required packages
|
||||||
pkg install -y git curl portlint go
|
pkg install -y git curl portlint
|
||||||
|
|
||||||
# Install Go for building
|
# Install Go for building
|
||||||
GO_TARBALL="go1.25.5.freebsd-amd64.tar.gz"
|
GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
|
||||||
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
||||||
curl -LO "$GO_URL"
|
curl -LO "$GO_URL"
|
||||||
tar -C /usr/local -xzf "$GO_TARBALL"
|
tar -C /usr/local -xzf "$GO_TARBALL"
|
||||||
@@ -93,19 +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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: 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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -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@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
|
||||||
- name: Login to Docker hub
|
- name: Login to Docker hub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
- name: Log in to the GitHub container registry
|
- name: Log in to the GitHub container registry
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -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@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: 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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -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@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.GORELEASER_VER }}
|
version: ${{ env.GORELEASER_VER }}
|
||||||
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
|
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
|
||||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
@@ -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@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.GORELEASER_VER }}
|
version: ${{ env.GORELEASER_VER }}
|
||||||
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
|
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
|
||||||
@@ -449,7 +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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Parse semver string
|
- name: Parse semver string
|
||||||
id: semver_parser
|
id: semver_parser
|
||||||
uses: booxmedialtd/ws-action-parse-semver@v1
|
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||||
with:
|
|
||||||
input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
|
|
||||||
version_extractor_regex: '\/v(.*)$'
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Add 7-Zip to PATH
|
- name: Add 7-Zip to PATH
|
||||||
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
|
||||||
- name: Download release artifacts
|
- name: Download release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: release
|
path: release
|
||||||
|
|
||||||
- name: Download UI release artifacts
|
- name: Download UI release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: release-ui
|
name: release-ui
|
||||||
path: release-ui
|
path: release-ui
|
||||||
@@ -514,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 }}" }'
|
||||||
|
|||||||
26
.github/workflows/test-infrastructure-files.yml
vendored
26
.github/workflows/test-infrastructure-files.yml
vendored
@@ -6,10 +6,10 @@ on:
|
|||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'infrastructure_files/**'
|
- "infrastructure_files/**"
|
||||||
- '.github/workflows/test-infrastructure-files.yml'
|
- ".github/workflows/test-infrastructure-files.yml"
|
||||||
- 'management/cmd/**'
|
- "management/cmd/**"
|
||||||
- 'signal/cmd/**'
|
- "signal/cmd/**"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
store: [ 'sqlite', 'postgres', 'mysql' ]
|
store: ["sqlite", "postgres", "mysql"]
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
||||||
@@ -68,15 +68,17 @@ jobs:
|
|||||||
run: sudo apt-get install -y curl
|
run: sudo apt-get install -y curl
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -139,8 +141,8 @@ jobs:
|
|||||||
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
|
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
|
||||||
CI_NETBIRD_SIGNAL_PORT: 12345
|
CI_NETBIRD_SIGNAL_PORT: 12345
|
||||||
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
||||||
NETBIRD_STORE_ENGINE_POSTGRES_DSN: '${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$'
|
NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
|
||||||
NETBIRD_STORE_ENGINE_MYSQL_DSN: '${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$'
|
NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
|
||||||
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
||||||
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
|
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
|
||||||
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
|
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
|
||||||
@@ -254,7 +256,9 @@ jobs:
|
|||||||
run: sudo apt-get install -y jq
|
run: sudo apt-get install -y jq
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: run script with Zitadel PostgreSQL
|
- name: run script with Zitadel PostgreSQL
|
||||||
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
|
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
|
||||||
|
|||||||
8
.github/workflows/update-docs.yml
vendored
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Build Wasm client
|
- name: Build Wasm client
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
862
.goreleaser.yaml
862
.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:
|
- "v{{ .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:
|
- "v{{ .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
|
- "v{{ .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
|
- "v{{ .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}}"
|
- "v{{ .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:
|
- "v{{ .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
|
- "v{{ .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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -151,9 +151,9 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
|||||||
|
|
||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, c.recorder)
|
||||||
c.setState(cfg, cacheDir, connectClient)
|
c.setState(cfg, cacheDir, connectClient)
|
||||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
return connectClient.RunOnAndroid(cfg, c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunWithoutLogin we apply this type of run function when the backed has been started without UI (i.e. after reboot).
|
// RunWithoutLogin we apply this type of run function when the backed has been started without UI (i.e. after reboot).
|
||||||
@@ -186,9 +186,9 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
|||||||
|
|
||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, c.recorder)
|
||||||
c.setState(cfg, cacheDir, connectClient)
|
c.setState(cfg, cacheDir, connectClient)
|
||||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
return connectClient.RunOnAndroid(cfg, c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the internal client and free the resources
|
// Stop the internal client and free the resources
|
||||||
|
|||||||
@@ -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: %v", 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 {
|
||||||
@@ -121,21 +150,82 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
daemonClient := proto.NewDaemonServiceClient(conn)
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||||
|
|
||||||
profileName := args[0]
|
profileName := args[0]
|
||||||
|
|
||||||
_, err = daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{
|
resp, err := daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{
|
||||||
ProfileName: profileName,
|
ProfileName: profileName,
|
||||||
Username: currUser.Username,
|
Username: currUser.Username,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return fmt.Errorf("add profile request: %w", 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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := profilemanager.ID(resp.Id)
|
||||||
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Println("Profile added successfully:", profileName)
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to service CLI interface: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
currUser, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get current user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
||||||
|
handle := args[0]
|
||||||
|
newProfilename := args[1]
|
||||||
|
|
||||||
|
resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{
|
||||||
|
Handle: handle,
|
||||||
|
Username: currUser.Username,
|
||||||
|
NewProfileName: newProfilename,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return wrapAmbiguityError(err, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +243,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 +263,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 +280,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 +303,30 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/connectivity"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||||
@@ -95,7 +96,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 +106,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 +181,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)
|
||||||
|
|
||||||
@@ -242,17 +262,46 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
|
|||||||
return prefix + upper
|
return prefix + upper
|
||||||
}
|
}
|
||||||
|
|
||||||
// DialClientGRPCServer returns client connection to the daemon server.
|
// DialClientGRPCServer returns client connection to the daemon server. It waits
|
||||||
|
// (up to the timeout) for the daemon to become reachable so an `up` issued right
|
||||||
|
// after `service start` tolerates the startup race. Instead of grpc's blocking
|
||||||
|
// dial — whose raw "transport failed" retry warnings are silenced by the logger
|
||||||
|
// config — we drive the wait ourselves and emit one clean line per failed attempt.
|
||||||
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
|
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
|
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
return grpc.DialContext(
|
conn, err := grpc.DialContext(
|
||||||
ctx,
|
ctx,
|
||||||
strings.TrimPrefix(addr, "tcp://"),
|
strings.TrimPrefix(addr, "tcp://"),
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
grpc.WithBlock(),
|
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.Connect()
|
||||||
|
for {
|
||||||
|
state := conn.GetState()
|
||||||
|
if state == connectivity.Ready {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
// Log only once the connection has actually failed — not during the
|
||||||
|
// brief Idle/Connecting phase on a healthy daemon (avoids a spurious
|
||||||
|
// line + wait when the daemon is already up).
|
||||||
|
if state == connectivity.TransientFailure {
|
||||||
|
log.Infof("waiting for the netbird daemon to become available at %s...", addr)
|
||||||
|
}
|
||||||
|
// Wake on the next state change, but at least every second so a stuck
|
||||||
|
// TransientFailure re-logs at a steady cadence until the timeout.
|
||||||
|
waitCtx, waitCancel := context.WithTimeout(ctx, time.Second)
|
||||||
|
conn.WaitForStateChange(waitCtx, state)
|
||||||
|
waitCancel()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("daemon not reachable at %s: %w", addr, ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithBackOff execute function in backoff cycle.
|
// WithBackOff execute function in backoff cycle.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,13 +128,12 @@ 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)
|
resolvedID, err := switchProfile(cmd.Context(), profileName, username.Username)
|
||||||
if 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 := pm.SwitchProfile(resolvedID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("switch profile: %v", err)
|
return fmt.Errorf("switch profile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +189,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)
|
||||||
}
|
}
|
||||||
@@ -202,10 +201,10 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
|
|||||||
r := peer.NewRecorder(config.ManagementURL.String())
|
r := peer.NewRecorder(config.ManagementURL.String())
|
||||||
r.GetFullStatus()
|
r.GetFullStatus()
|
||||||
|
|
||||||
connectClient := internal.NewConnectClient(ctx, config, r)
|
connectClient := internal.NewConnectClient(ctx, r)
|
||||||
SetupDebugHandler(ctx, config, r, connectClient, "")
|
SetupDebugHandler(ctx, config, r, connectClient, "")
|
||||||
|
|
||||||
return connectClient.Run(nil, util.FindFirstLogPath(logFiles))
|
return connectClient.Run(config, nil, util.FindFirstLogPath(logFiles))
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager.ProfileManager, activeProf *profilemanager.Profile, profileSwitched bool) error {
|
func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager.ProfileManager, activeProf *profilemanager.Profile, profileSwitched bool) error {
|
||||||
@@ -261,10 +260,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 +288,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 +329,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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -264,28 +264,24 @@ func (c *Client) Start(startCtx context.Context) error {
|
|||||||
if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil {
|
if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil {
|
||||||
return fmt.Errorf("login: %w", err)
|
return fmt.Errorf("login: %w", err)
|
||||||
}
|
}
|
||||||
client := internal.NewConnectClient(ctx, c.config, c.recorder)
|
client := internal.NewConnectClient(ctx, c.recorder)
|
||||||
client.SetSyncResponsePersistence(true)
|
client.SetSyncResponsePersistence(true)
|
||||||
|
|
||||||
// either startup error (permanent backoff err) or nil err (successful engine up)
|
// The supervisor owns the run; we wait until it is established, ends with a
|
||||||
|
// startup error (permanent backoff err), or startCtx expires.
|
||||||
// TODO: make after-startup backoff err available
|
// TODO: make after-startup backoff err available
|
||||||
run := make(chan struct{})
|
client.RunAsync(c.config, nil)
|
||||||
clientErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
if err := client.Run(run, ""); err != nil {
|
|
||||||
clientErr <- err
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
if err := client.WaitEstablishedOrDone(startCtx); err != nil {
|
||||||
case <-startCtx.Done():
|
// Either startCtx expired while connecting, or the run ended before it
|
||||||
|
// established. Cancel the client context before stopping: Engine.Start
|
||||||
|
// blocks on the signal stream while holding the engine mutex and only
|
||||||
|
// unblocks on cancellation. Stopping first would deadlock on that mutex.
|
||||||
|
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 startup failure. Stop error: %w. Startup: %w", stopErr, err)
|
||||||
}
|
}
|
||||||
return startCtx.Err()
|
|
||||||
case err := <-clientErr:
|
|
||||||
return fmt.Errorf("startup: %w", err)
|
return fmt.Errorf("startup: %w", err)
|
||||||
case <-run:
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.connect = client
|
c.connect = client
|
||||||
@@ -442,8 +438,8 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
|
|||||||
|
|
||||||
// IdentityForIP looks up a remote peer by its tunnel IP using the
|
// IdentityForIP looks up a remote peer by its tunnel IP using the
|
||||||
// embedded client's status recorder. Returns the peer's WireGuard public
|
// embedded client's status recorder. Returns the peer's WireGuard public
|
||||||
// key and FQDN. ok=false means the IP isn't in this client's peer
|
// key and FQDN. ok=false means the IP doesn't belong to an active peer
|
||||||
// roster — callers should treat that as "unknown peer".
|
// — offline roster peers are treated as unknown, same as foreign IPs.
|
||||||
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
|
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
|
||||||
if !ip.IsValid() || c.recorder == nil {
|
if !ip.IsValid() || c.recorder == nil {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
|
|||||||
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,
|
||||||
|
|||||||
@@ -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,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
gstatus "google.golang.org/grpc/status"
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
@@ -47,13 +49,23 @@ import (
|
|||||||
"github.com/netbirdio/netbird/version"
|
"github.com/netbirdio/netbird/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// androidRunOverride is set on Android to inject mobile dependencies
|
// androidMobileDep is set on Android to inject the MobileDependency for runs
|
||||||
// when using embed.Client (which calls Run() with empty MobileDependency).
|
// started through the generic entry points (Run/RunAsync, e.g. embed.Client).
|
||||||
var androidRunOverride func(c *ConnectClient, runningChan chan struct{}, logPath string) error
|
// nil on other platforms, where the dependency is empty.
|
||||||
|
var androidMobileDep func(config *profilemanager.Config) MobileDependency
|
||||||
|
|
||||||
|
// mobileDependency returns the MobileDependency for a run started via the
|
||||||
|
// generic entry points. On Android the androidMobileDep provider supplies
|
||||||
|
// platform stubs (or real implementations); elsewhere it is empty.
|
||||||
|
func (c *ConnectClient) mobileDependency(config *profilemanager.Config) MobileDependency {
|
||||||
|
if androidMobileDep != nil {
|
||||||
|
return androidMobileDep(config)
|
||||||
|
}
|
||||||
|
return MobileDependency{}
|
||||||
|
}
|
||||||
|
|
||||||
type ConnectClient struct {
|
type ConnectClient struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
config *profilemanager.Config
|
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
|
|
||||||
engine *Engine
|
engine *Engine
|
||||||
@@ -62,35 +74,62 @@ type ConnectClient struct {
|
|||||||
updateManager *updater.Manager
|
updateManager *updater.Manager
|
||||||
|
|
||||||
persistSyncResponse bool
|
persistSyncResponse bool
|
||||||
|
|
||||||
|
// sup serializes all start/stop requests so two lifecycle operations can
|
||||||
|
// never overlap. See connect_lifecycle.go.
|
||||||
|
sup *supervisor
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConnectClient(
|
func NewConnectClient(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
config *profilemanager.Config,
|
|
||||||
statusRecorder *peer.Status,
|
statusRecorder *peer.Status,
|
||||||
) *ConnectClient {
|
) *ConnectClient {
|
||||||
return &ConnectClient{
|
c := &ConnectClient{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
config: config,
|
|
||||||
statusRecorder: statusRecorder,
|
statusRecorder: statusRecorder,
|
||||||
engineMutex: sync.Mutex{},
|
engineMutex: sync.Mutex{},
|
||||||
}
|
}
|
||||||
|
c.sup = newSupervisor(ctx, c.run)
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
||||||
c.updateManager = um
|
c.updateManager = um
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run with main logic.
|
// Run with main logic. md carries optional gRPC metadata (e.g. the UI
|
||||||
func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error {
|
// user-agent) to forward to the management/signal services; nil when none.
|
||||||
if androidRunOverride != nil {
|
func (c *ConnectClient) Run(config *profilemanager.Config, md metadata.MD, logPath string) error {
|
||||||
return androidRunOverride(c, runningChan, logPath)
|
return c.sup.start(config, md, c.mobileDependency(config), logPath)
|
||||||
}
|
}
|
||||||
return c.run(MobileDependency{}, runningChan, logPath)
|
|
||||||
|
// RunAsync starts a client run without blocking. Used by the daemon and embed,
|
||||||
|
// which drive the lifecycle through the supervisor rather than blocking on Run;
|
||||||
|
// they then wait for the outcome via WaitEstablishedOrDone. The run's lifecycle
|
||||||
|
// channels are created and owned by the supervisor — callers never hold them.
|
||||||
|
func (c *ConnectClient) RunAsync(config *profilemanager.Config, md metadata.MD) {
|
||||||
|
c.sup.startAsync(config, md, c.mobileDependency(config), "", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart atomically stops any in-flight run and starts a fresh one with the
|
||||||
|
// given config. The stop+start happens as a single supervisor operation, so no
|
||||||
|
// other lifecycle request can interleave between them — used for explicit
|
||||||
|
// restarts (e.g. an MDM policy change) that must not expose a "stopped" window.
|
||||||
|
func (c *ConnectClient) Restart(config *profilemanager.Config, md metadata.MD) {
|
||||||
|
c.sup.restartAsync(config, md, c.mobileDependency(config), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitEstablishedOrDone blocks until the in-flight run becomes established (nil),
|
||||||
|
// ends before that (the run error, or a sentinel on a clean stop), or ctx is
|
||||||
|
// cancelled. Returns errNoRunInFlight if no run is in flight. Wraps the wait on
|
||||||
|
// the supervisor-owned channels so callers never touch them directly.
|
||||||
|
func (c *ConnectClient) WaitEstablishedOrDone(ctx context.Context) error {
|
||||||
|
return c.sup.waitEstablishedOrDone(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunOnAndroid with main logic on mobile system
|
// RunOnAndroid with main logic on mobile system
|
||||||
func (c *ConnectClient) RunOnAndroid(
|
func (c *ConnectClient) RunOnAndroid(
|
||||||
|
config *profilemanager.Config,
|
||||||
tunAdapter device.TunAdapter,
|
tunAdapter device.TunAdapter,
|
||||||
iFaceDiscover stdnet.ExternalIFaceDiscover,
|
iFaceDiscover stdnet.ExternalIFaceDiscover,
|
||||||
networkChangeListener listener.NetworkChangeListener,
|
networkChangeListener listener.NetworkChangeListener,
|
||||||
@@ -109,14 +148,17 @@ func (c *ConnectClient) RunOnAndroid(
|
|||||||
StateFilePath: stateFilePath,
|
StateFilePath: stateFilePath,
|
||||||
TempDir: cacheDir,
|
TempDir: cacheDir,
|
||||||
}
|
}
|
||||||
return c.run(mobileDependency, nil, "")
|
return c.sup.start(config, nil, mobileDependency, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ConnectClient) RunOniOS(
|
func (c *ConnectClient) RunOniOS(
|
||||||
|
config *profilemanager.Config,
|
||||||
fileDescriptor int32,
|
fileDescriptor int32,
|
||||||
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 +168,14 @@ 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.sup.start(config, nil, mobileDependency, logFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error {
|
// run executes a single client run. runCtx is owned by the supervisor: cancelling
|
||||||
|
// it tears the run down (it is the parent of the per-attempt engine context).
|
||||||
|
func (c *ConnectClient) run(runCtx context.Context, config *profilemanager.Config, mobileDependency MobileDependency, connEstablishedChan chan struct{}, logPath string) error {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
rec := c.statusRecorder
|
rec := c.statusRecorder
|
||||||
@@ -194,18 +239,18 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
wrapErr := state.Wrap
|
wrapErr := state.Wrap
|
||||||
myPrivateKey, err := wgtypes.ParseKey(c.config.PrivateKey)
|
myPrivateKey, err := wgtypes.ParseKey(config.PrivateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed parsing Wireguard key %s: [%s]", c.config.PrivateKey, err.Error())
|
log.Errorf("failed parsing Wireguard key %s: [%s]", config.PrivateKey, err.Error())
|
||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var mgmTlsEnabled bool
|
var mgmTlsEnabled bool
|
||||||
if c.config.ManagementURL.Scheme == "https" {
|
if config.ManagementURL.Scheme == "https" {
|
||||||
mgmTlsEnabled = true
|
mgmTlsEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
publicSSHKey, err := ssh.GeneratePublicKey([]byte(c.config.SSHKey))
|
publicSSHKey, err := ssh.GeneratePublicKey([]byte(config.SSHKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -239,13 +284,13 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
defer c.statusRecorder.ClientStop()
|
defer c.statusRecorder.ClientStop()
|
||||||
operation := func() error {
|
operation := func() error {
|
||||||
// if context cancelled we not start new backoff cycle
|
// if context cancelled we not start new backoff cycle
|
||||||
if c.ctx.Err() != nil {
|
if runCtx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
state.Set(StatusConnecting)
|
state.Set(StatusConnecting)
|
||||||
|
|
||||||
engineCtx, cancel := context.WithCancel(c.ctx)
|
engineCtx, cancel := context.WithCancel(runCtx)
|
||||||
defer func() {
|
defer func() {
|
||||||
_, err := state.Status()
|
_, err := state.Status()
|
||||||
c.statusRecorder.MarkManagementDisconnected(err)
|
c.statusRecorder.MarkManagementDisconnected(err)
|
||||||
@@ -253,8 +298,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
log.Debugf("connecting to the Management service %s", config.ManagementURL.Host)
|
||||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
mgmClient, err := mgm.NewClient(engineCtx, config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err))
|
return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err))
|
||||||
}
|
}
|
||||||
@@ -271,7 +316,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}
|
}
|
||||||
c.clientMetrics.UpdateAgentInfo(agentInfo, myPrivateKey.PublicKey().String())
|
c.clientMetrics.UpdateAgentInfo(agentInfo, myPrivateKey.PublicKey().String())
|
||||||
|
|
||||||
log.Debugf("connected to the Management service %s", c.config.ManagementURL.Host)
|
log.Debugf("connected to the Management service %s", config.ManagementURL.Host)
|
||||||
defer func() {
|
defer func() {
|
||||||
if err = mgmClient.Close(); err != nil {
|
if err = mgmClient.Close(); err != nil {
|
||||||
log.Warnf("failed to close the Management service client %v", err)
|
log.Warnf("failed to close the Management service client %v", err)
|
||||||
@@ -280,13 +325,14 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
|
|
||||||
// connect (just a connection, no stream yet) and login to Management Service to get an initial global Netbird config
|
// connect (just a connection, no stream yet) and login to Management Service to get an initial global Netbird config
|
||||||
loginStarted := time.Now()
|
loginStarted := time.Now()
|
||||||
loginResp, err := loginToManagement(engineCtx, mgmClient, publicSSHKey, c.config)
|
loginResp, err := loginToManagement(engineCtx, mgmClient, publicSSHKey, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), false)
|
c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), false)
|
||||||
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()
|
// No teardown needed: login fails before the engine is started
|
||||||
|
// (engine.Start is below), so there is nothing running to stop.
|
||||||
return backoff.Permanent(wrapErr(err)) // unrecoverable error
|
return backoff.Permanent(wrapErr(err)) // unrecoverable error
|
||||||
}
|
}
|
||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
@@ -340,12 +386,17 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}
|
}
|
||||||
peerConfig := loginResp.GetPeerConfig()
|
peerConfig := loginResp.GetPeerConfig()
|
||||||
|
|
||||||
engineConfig, err := createEngineConfig(myPrivateKey, c.config, peerConfig, logPath)
|
engineConfig, err := createEngineConfig(myPrivateKey, config, peerConfig, logPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
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)
|
||||||
@@ -379,7 +430,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
c.engine = engine
|
c.engine = engine
|
||||||
c.engineMutex.Unlock()
|
c.engineMutex.Unlock()
|
||||||
|
|
||||||
if err := engine.Start(loginResp.GetNetbirdConfig(), c.config.ManagementURL); err != nil {
|
if err := engine.Start(loginResp.GetNetbirdConfig(), config.ManagementURL); err != nil {
|
||||||
log.Errorf("error while starting Netbird Connection Engine: %s", err)
|
log.Errorf("error while starting Netbird Connection Engine: %s", err)
|
||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
@@ -387,12 +438,13 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress())
|
log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress())
|
||||||
state.Set(StatusConnected)
|
state.Set(StatusConnected)
|
||||||
|
|
||||||
if runningChan != nil {
|
// The supervisor owns connEstablishedChan and it is always present. Guard
|
||||||
select {
|
// against a double close: operation re-runs on ErrResetConnection retries
|
||||||
case <-runningChan:
|
// within the same run, and the channel is closed only on the first connect.
|
||||||
default:
|
select {
|
||||||
close(runningChan)
|
case <-connEstablishedChan:
|
||||||
}
|
default:
|
||||||
|
close(connEstablishedChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
<-engineCtx.Done()
|
<-engineCtx.Done()
|
||||||
@@ -401,14 +453,12 @@ 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.
|
// Always tear the engine down once its context is cancelled. engine.Stop
|
||||||
// We should always call Stop(), but we need to verify that it is idempotent
|
// is nil-guarded per component, so calling it unconditionally is safe and
|
||||||
if engine.wgInterface != nil {
|
// avoids both the data race on engine.wgInterface and skipping teardown
|
||||||
log.Infof("ensuring %s is removed, Netbird engine context cancelled", engine.wgInterface.Name())
|
// when the interface was never brought up (e.g. a mid-start failure).
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -428,8 +478,9 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
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) {
|
||||||
|
// Login failed permanently: the engine was never started, so there
|
||||||
|
// is nothing to tear down — just record that a login is needed.
|
||||||
state.Set(StatusNeedsLogin)
|
state.Set(StatusNeedsLogin)
|
||||||
_ = c.Stop()
|
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -450,6 +501,22 @@ func parseRelayInfo(loginResp *mgmProto.LoginResponse) ([]string, *hmac.Token) {
|
|||||||
return relayCfg.GetUrls(), token
|
return relayCfg.GetUrls(), token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConnectionRunning reports whether a connection run is currently in flight
|
||||||
|
// (connecting, connected, or reconnecting). Answered by the supervisor via a
|
||||||
|
// serialized query, so it settles behind an in-flight stop. Distinct from
|
||||||
|
// ServiceRunning, which reports whether the service itself is alive.
|
||||||
|
func (c *ConnectClient) ConnectionRunning() bool {
|
||||||
|
return c.sup.isRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceRunning reports whether the client's lifecycle supervisor is alive and
|
||||||
|
// able to accept start/stop commands — i.e. its context has not been cancelled
|
||||||
|
// (the daemon is not shutting down). Independent of whether a connection run is
|
||||||
|
// up (that is ConnectionRunning).
|
||||||
|
func (c *ConnectClient) ServiceRunning() bool {
|
||||||
|
return c.sup.ctx.Err() == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ConnectClient) Engine() *Engine {
|
func (c *ConnectClient) Engine() *Engine {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -506,14 +573,10 @@ func (c *ConnectClient) Status() StatusType {
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop serializes a stop request through the lifecycle supervisor and blocks
|
||||||
|
// until the in-flight run is fully torn down.
|
||||||
func (c *ConnectClient) Stop() error {
|
func (c *ConnectClient) Stop() error {
|
||||||
engine := c.Engine()
|
return c.sup.stop()
|
||||||
if engine != nil {
|
|
||||||
if err := engine.Stop(); err != nil {
|
|
||||||
return fmt.Errorf("stop engine: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSyncResponsePersistence enables or disables sync response persistence.
|
// SetSyncResponsePersistence enables or disables sync response persistence.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/dns"
|
"github.com/netbirdio/netbird/client/internal/dns"
|
||||||
"github.com/netbirdio/netbird/client/internal/listener"
|
"github.com/netbirdio/netbird/client/internal/listener"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,19 +60,17 @@ var _ listener.NetworkChangeListener = noopNetworkChangeListener{}
|
|||||||
var _ dns.ReadyListener = noopDnsReadyListener{}
|
var _ dns.ReadyListener = noopDnsReadyListener{}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Wire up the default override so embed.Client.Start() works on Android
|
// Wire up the default MobileDependency provider so embed.Client.Start() works
|
||||||
// with netstack mode. Provides complete no-op stubs for all mobile
|
// on Android with netstack mode. Provides complete no-op stubs for all mobile
|
||||||
// dependencies so the engine's existing Android code paths work unchanged.
|
// dependencies so the engine's existing Android code paths work unchanged.
|
||||||
// Applications that need P2P ICE or real DNS should replace this by
|
// Applications that need P2P ICE or real DNS should replace this by setting
|
||||||
// setting androidRunOverride before calling Start().
|
// androidMobileDep before calling Start().
|
||||||
androidRunOverride = func(c *ConnectClient, runningChan chan struct{}, logPath string) error {
|
androidMobileDep = func(config *profilemanager.Config) MobileDependency {
|
||||||
return c.runOnAndroidEmbed(
|
return mobileDependencyForEmbed(
|
||||||
noopIFaceDiscover{},
|
noopIFaceDiscover{},
|
||||||
noopNetworkChangeListener{},
|
noopNetworkChangeListener{},
|
||||||
[]netip.AddrPort{},
|
[]netip.AddrPort{},
|
||||||
noopDnsReadyListener{},
|
noopDnsReadyListener{},
|
||||||
runningChan,
|
|
||||||
logPath,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,23 +10,18 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runOnAndroidEmbed is like RunOnAndroid but accepts a runningChan
|
// mobileDependencyForEmbed builds the MobileDependency used by embed.Client on
|
||||||
// so embed.Client.Start() can detect when the engine is ready.
|
// Android so the engine's existing Android code paths work unchanged.
|
||||||
// It provides complete MobileDependency so the engine's existing
|
func mobileDependencyForEmbed(
|
||||||
// Android code paths work unchanged.
|
|
||||||
func (c *ConnectClient) runOnAndroidEmbed(
|
|
||||||
iFaceDiscover stdnet.ExternalIFaceDiscover,
|
iFaceDiscover stdnet.ExternalIFaceDiscover,
|
||||||
networkChangeListener listener.NetworkChangeListener,
|
networkChangeListener listener.NetworkChangeListener,
|
||||||
dnsAddresses []netip.AddrPort,
|
dnsAddresses []netip.AddrPort,
|
||||||
dnsReadyListener dns.ReadyListener,
|
dnsReadyListener dns.ReadyListener,
|
||||||
runningChan chan struct{},
|
) MobileDependency {
|
||||||
logPath string,
|
return MobileDependency{
|
||||||
) error {
|
|
||||||
mobileDependency := MobileDependency{
|
|
||||||
IFaceDiscover: iFaceDiscover,
|
IFaceDiscover: iFaceDiscover,
|
||||||
NetworkChangeListener: networkChangeListener,
|
NetworkChangeListener: networkChangeListener,
|
||||||
HostDNSAddresses: dnsAddresses,
|
HostDNSAddresses: dnsAddresses,
|
||||||
DnsReadyListener: dnsReadyListener,
|
DnsReadyListener: dnsReadyListener,
|
||||||
}
|
}
|
||||||
return c.run(mobileDependency, runningChan, logPath)
|
|
||||||
}
|
}
|
||||||
|
|||||||
362
client/internal/connect_lifecycle.go
Normal file
362
client/internal/connect_lifecycle.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errAlreadyRunning is returned when a start is requested while a run is already
|
||||||
|
// in flight.
|
||||||
|
var errAlreadyRunning = errors.New("client is already running")
|
||||||
|
|
||||||
|
// errNoRunInFlight is returned by waitEstablishedOrDone when no run is active.
|
||||||
|
var errNoRunInFlight = errors.New("no connection run in flight")
|
||||||
|
|
||||||
|
// errStoppedBeforeEstablished is returned when a run ended (cleanly) before the
|
||||||
|
// connection was established.
|
||||||
|
var errStoppedBeforeEstablished = errors.New("run stopped before the connection was established")
|
||||||
|
|
||||||
|
// lifecycleOp is a serialized lifecycle operation processed by the supervisor.
|
||||||
|
type lifecycleOp int
|
||||||
|
|
||||||
|
const (
|
||||||
|
opStart lifecycleOp = iota
|
||||||
|
opStop
|
||||||
|
opRestart
|
||||||
|
opStatus
|
||||||
|
opWaitEstablished
|
||||||
|
)
|
||||||
|
|
||||||
|
// lifecycleCmd is a single lifecycle request handed to the supervisor goroutine.
|
||||||
|
// They all flow through the same cmdCh so they are strictly ordered (FIFO) with
|
||||||
|
// respect to each other.
|
||||||
|
type lifecycleCmd struct {
|
||||||
|
op lifecycleOp
|
||||||
|
config *profilemanager.Config
|
||||||
|
md metadata.MD
|
||||||
|
mobileDep MobileDependency
|
||||||
|
logPath string
|
||||||
|
|
||||||
|
// done is the caller's notification channel (nil for fire-and-forget). Its
|
||||||
|
// meaning depends on op:
|
||||||
|
// - opStart: receives the run's end result when the run terminates, or
|
||||||
|
// errAlreadyRunning immediately if a run is already in flight.
|
||||||
|
// - opStop: receives nil once the in-flight run has fully unwound.
|
||||||
|
// - opWaitEstablished: receives the wait outcome (see waitEstablishedOrDone).
|
||||||
|
done chan error
|
||||||
|
|
||||||
|
reply chan bool // opStatus only: receives whether a run is in flight
|
||||||
|
waitCtx context.Context // opWaitEstablished only: the waiter's cancellation context
|
||||||
|
}
|
||||||
|
|
||||||
|
// runState holds the lifecycle channels of a single in-flight run, owned by the
|
||||||
|
// loop goroutine. It never escapes the supervisor as an API; the only readers
|
||||||
|
// are the per-wait goroutines the loop spawns for opWaitEstablished.
|
||||||
|
//
|
||||||
|
// connEstablishedChan is closed by the run once the connection is established.
|
||||||
|
// The supervisor creates and owns it — callers no longer supply it; they observe
|
||||||
|
// it through waitEstablishedOrDone. ended is closed (broadcast) when the run
|
||||||
|
// terminates, so any number of waiters can observe it; err is the run's end
|
||||||
|
// result, valid only after ended is closed.
|
||||||
|
type runState struct {
|
||||||
|
connEstablishedChan chan struct{} // closed by the run on established
|
||||||
|
ended chan struct{} // closed by finishRun when the run terminates
|
||||||
|
err error // run end result, valid after ended is closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEndResult is sent by the run goroutine to the supervisor when a run ends,
|
||||||
|
// whether on its own (error / external context cancellation) or because of a Stop.
|
||||||
|
type runEndResult struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// runFunc executes a single client run bound to the supervisor-owned context,
|
||||||
|
// with the config supplied by the start request.
|
||||||
|
type runFunc func(ctx context.Context, config *profilemanager.Config, mobileDep MobileDependency, connEstablishedChan chan struct{}, logPath string) error
|
||||||
|
|
||||||
|
// supervisor serializes start/stop of a single client run. Every request goes
|
||||||
|
// through cmdCh and is handled one at a time by the loop goroutine, so two
|
||||||
|
// lifecycle operations can never overlap and their order is preserved (FIFO).
|
||||||
|
// The loop goroutine is the sole owner of curStart/runCancel, so that state
|
||||||
|
// needs no locking. The loop exits when the parent context is cancelled.
|
||||||
|
type supervisor struct {
|
||||||
|
ctx context.Context
|
||||||
|
run runFunc
|
||||||
|
cmdCh chan lifecycleCmd
|
||||||
|
runEnded chan runEndResult
|
||||||
|
|
||||||
|
// owned exclusively by the loop goroutine. curStart is the in-flight start
|
||||||
|
// command (nil = idle); its done channel is notified when the run ends.
|
||||||
|
// curRun holds that run's lifecycle channels; runCancel cancels it.
|
||||||
|
curStart *lifecycleCmd
|
||||||
|
curRun *runState
|
||||||
|
runCancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSupervisor(ctx context.Context, run runFunc) *supervisor {
|
||||||
|
s := &supervisor{
|
||||||
|
ctx: ctx,
|
||||||
|
run: run,
|
||||||
|
cmdCh: make(chan lifecycleCmd, 16),
|
||||||
|
runEnded: make(chan runEndResult, 1),
|
||||||
|
}
|
||||||
|
go s.loop()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *supervisor) loop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
s.shutdown()
|
||||||
|
return
|
||||||
|
case cmd := <-s.cmdCh:
|
||||||
|
switch cmd.op {
|
||||||
|
case opStart:
|
||||||
|
s.handleStart(cmd)
|
||||||
|
case opStop:
|
||||||
|
s.handleStop(cmd)
|
||||||
|
case opRestart:
|
||||||
|
s.handleRestart(cmd)
|
||||||
|
case opStatus:
|
||||||
|
cmd.reply <- (s.isRunningInternal())
|
||||||
|
case opWaitEstablished:
|
||||||
|
s.handleWaitEstablished(cmd)
|
||||||
|
}
|
||||||
|
case res := <-s.runEnded:
|
||||||
|
// Run ended on its own, without an explicit Stop.
|
||||||
|
s.finishRun(res.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *supervisor) handleStart(cmd lifecycleCmd) {
|
||||||
|
if s.isRunningInternal() {
|
||||||
|
notify(cmd.done, errAlreadyRunning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithCancel(s.ctx)
|
||||||
|
if cmd.md != nil {
|
||||||
|
// Carry caller-supplied gRPC metadata (e.g. UI user-agent) into the run
|
||||||
|
// context so the engine's management/signal calls forward it. The cancel
|
||||||
|
// still drives runCtx (metadata wrapping preserves cancellation).
|
||||||
|
runCtx = metadata.NewOutgoingContext(runCtx, cmd.md)
|
||||||
|
}
|
||||||
|
s.runCancel = cancel
|
||||||
|
s.curStart = &cmd
|
||||||
|
s.curRun = &runState{connEstablishedChan: make(chan struct{}), ended: make(chan struct{})}
|
||||||
|
|
||||||
|
go func(ctx context.Context, cfg *profilemanager.Config, m MobileDependency, established chan struct{}, lp string) {
|
||||||
|
err := s.run(ctx, cfg, m, established, lp)
|
||||||
|
s.runEnded <- runEndResult{err: err}
|
||||||
|
}(runCtx, cmd.config, cmd.mobileDep, s.curRun.connEstablishedChan, cmd.logPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *supervisor) handleStop(cmd lifecycleCmd) {
|
||||||
|
if !s.isRunningInternal() {
|
||||||
|
notify(cmd.done, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.stopCurrentRun()
|
||||||
|
notify(cmd.done, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRestart tears down any in-flight run and starts a fresh one in a single
|
||||||
|
// loop turn. No other command can interleave between the stop and the start
|
||||||
|
// (the loop is single-threaded), so the swap is atomic without relying on any
|
||||||
|
// daemon-side lock — that is what an explicit restart (e.g. MDM config change)
|
||||||
|
// needs to avoid a window where the client is observably stopped.
|
||||||
|
func (s *supervisor) handleRestart(cmd lifecycleCmd) {
|
||||||
|
if s.isRunningInternal() {
|
||||||
|
s.stopCurrentRun()
|
||||||
|
}
|
||||||
|
s.handleStart(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopCurrentRun cancels the in-flight run and blocks the supervisor until it
|
||||||
|
// has fully unwound, so the next action starts from a clean slate. The run
|
||||||
|
// goroutine reports completion via runEnded. Caller must hold an in-flight run
|
||||||
|
// (curStart != nil).
|
||||||
|
func (s *supervisor) stopCurrentRun() {
|
||||||
|
s.runCancel()
|
||||||
|
res := <-s.runEnded
|
||||||
|
s.finishRun(res.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishRun resets lifecycle state after a run terminates and hands the run
|
||||||
|
// error back to whoever asked to be notified of the start.
|
||||||
|
func (s *supervisor) finishRun(err error) {
|
||||||
|
s.runCancel = nil
|
||||||
|
if s.isRunningInternal() {
|
||||||
|
// Publish the result to the broadcast channel before nil-ing curRun, so
|
||||||
|
// any opWaitEstablished goroutines blocked on ended observe err.
|
||||||
|
s.curRun.err = err
|
||||||
|
close(s.curRun.ended)
|
||||||
|
s.curRun = nil
|
||||||
|
|
||||||
|
notify(s.curStart.done, err)
|
||||||
|
s.curStart = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWaitEstablished answers an opWaitEstablished request. The select itself
|
||||||
|
// runs in a spawned goroutine on the run's channels so it never blocks the loop;
|
||||||
|
// the loop only snapshots the in-flight run's channels (which it owns) here.
|
||||||
|
func (s *supervisor) handleWaitEstablished(cmd lifecycleCmd) {
|
||||||
|
caller := cmd.done
|
||||||
|
if !s.isRunningInternal() {
|
||||||
|
notify(caller, errNoRunInFlight)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rs := s.curRun
|
||||||
|
established := rs.connEstablishedChan
|
||||||
|
ctx := cmd.waitCtx
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-established:
|
||||||
|
notify(caller, nil)
|
||||||
|
case <-rs.ended:
|
||||||
|
if rs.err != nil {
|
||||||
|
notify(caller, rs.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notify(caller, errStoppedBeforeEstablished)
|
||||||
|
case <-ctx.Done():
|
||||||
|
notify(caller, ctx.Err())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdown tears down the in-flight run when the parent context is cancelled,
|
||||||
|
// then fails any still-queued commands so their callers never hang.
|
||||||
|
func (s *supervisor) shutdown() {
|
||||||
|
if s.runCancel != nil {
|
||||||
|
s.runCancel()
|
||||||
|
res := <-s.runEnded
|
||||||
|
s.finishRun(res.err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case cmd := <-s.cmdCh:
|
||||||
|
notify(cmd.done, s.ctx.Err())
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAsync enqueues a start without blocking. If done is non-nil it receives
|
||||||
|
// the run's end result (or errAlreadyRunning on rejection, or the context error
|
||||||
|
// on shutdown).
|
||||||
|
func (s *supervisor) startAsync(config *profilemanager.Config, md metadata.MD, mobileDep MobileDependency, logPath string, done chan error) {
|
||||||
|
cmd := lifecycleCmd{op: opStart, config: config, md: md, mobileDep: mobileDep, logPath: logPath, done: done}
|
||||||
|
select {
|
||||||
|
case s.cmdCh <- cmd:
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
notify(done, s.ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restartAsync enqueues an atomic stop+start without blocking. The supervisor
|
||||||
|
// tears down any in-flight run and starts a fresh one with the supplied config
|
||||||
|
// in a single loop turn (see handleRestart). Fire-and-forget: the new run owns
|
||||||
|
// its lifecycle channels, observed via waitEstablishedOrDone.
|
||||||
|
func (s *supervisor) restartAsync(config *profilemanager.Config, md metadata.MD, mobileDep MobileDependency, logPath string) {
|
||||||
|
cmd := lifecycleCmd{op: opRestart, config: config, md: md, mobileDep: mobileDep, logPath: logPath}
|
||||||
|
select {
|
||||||
|
case s.cmdCh <- cmd:
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start enqueues a start and blocks until the run terminates, preserving the
|
||||||
|
// blocking contract of the legacy Run entry points.
|
||||||
|
func (s *supervisor) start(config *profilemanager.Config, md metadata.MD, mobileDep MobileDependency, logPath string) error {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
s.startAsync(config, md, mobileDep, logPath, done)
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return s.ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRunning asks the loop whether a run is in flight. The query is serialized
|
||||||
|
// with start/stop, so during a stop it waits for the teardown to settle and
|
||||||
|
// then reports the final state — never a transient "half-stopped".
|
||||||
|
func (s *supervisor) isRunning() bool {
|
||||||
|
reply := make(chan bool, 1)
|
||||||
|
select {
|
||||||
|
case s.cmdCh <- lifecycleCmd{op: opStatus, reply: reply}:
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r := <-reply:
|
||||||
|
return r
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *supervisor) isRunningInternal() bool {
|
||||||
|
return s.curStart != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitEstablishedOrDone blocks until the in-flight run becomes established
|
||||||
|
// (returns nil) or ends before that (returns the run error, or
|
||||||
|
// errStoppedBeforeEstablished on a clean stop), or ctx is cancelled. Returns
|
||||||
|
// errNoRunInFlight if no run is in flight. The wait is performed by a goroutine
|
||||||
|
// spawned inside the loop (see handleWaitEstablished); the run's channels never
|
||||||
|
// leave the supervisor.
|
||||||
|
func (s *supervisor) waitEstablishedOrDone(ctx context.Context) error {
|
||||||
|
reply := make(chan error, 1)
|
||||||
|
select {
|
||||||
|
case s.cmdCh <- lifecycleCmd{op: opWaitEstablished, waitCtx: ctx, done: reply}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return s.ctx.Err()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-reply:
|
||||||
|
return err
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return s.ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop enqueues a stop and blocks until the in-flight run is fully torn down.
|
||||||
|
func (s *supervisor) stop() error {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
select {
|
||||||
|
case s.cmdCh <- lifecycleCmd{op: opStop, done: done}:
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return s.ctx.Err()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return s.ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify sends on a caller-supplied channel without blocking. The channel is
|
||||||
|
// expected to be buffered (cap 1); a nil channel means the caller did not ask
|
||||||
|
// to be notified.
|
||||||
|
func notify(ch chan error, err error) {
|
||||||
|
if ch == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
|
|||||||
// completely when every proxy peer is offline (the upstream may still
|
// completely when every proxy peer is offline (the upstream may still
|
||||||
// be reachable some other way, or the peerstore may be stale).
|
// 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 {
|
func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
|
||||||
if len(records) == 0 {
|
if len(records) < 2 {
|
||||||
return records
|
return records
|
||||||
}
|
}
|
||||||
d.mu.RLock()
|
d.mu.RLock()
|
||||||
|
|||||||
@@ -2738,6 +2738,17 @@ func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
|
|||||||
connByIP: nil,
|
connByIP: nil,
|
||||||
wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
|
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 {
|
for _, tc := range tests {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
122
client/internal/dns/resutil/resolve_test.go
Normal file
122
client/internal/dns/resutil/resolve_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -777,13 +777,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,
|
||||||
@@ -797,11 +808,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
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ 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"
|
"google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
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 +55,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 +73,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.
|
||||||
@@ -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.
|
||||||
@@ -226,11 +232,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
|
||||||
@@ -292,6 +303,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())
|
||||||
@@ -520,6 +532,10 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
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()
|
e.close()
|
||||||
return err
|
return err
|
||||||
@@ -869,63 +885,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 +915,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 +1099,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)
|
||||||
|
|
||||||
@@ -1090,6 +1127,20 @@ func (e *Engine) hasIPv6Changed(conf *mgmProto.PeerConfig) bool {
|
|||||||
return !current.HasIPv6() || current.IPv6 != prefix.Addr() || current.IPv6Net != prefix.Masked()
|
return !current.HasIPv6() || current.IPv6 != prefix.Addr() || current.IPv6Net != prefix.Masked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wrapDisconnectError classifies a receive-loop failure before the run is torn
|
||||||
|
// down. An auth rejection (PermissionDenied/Unauthenticated) means the session
|
||||||
|
// needs re-login and retrying is futile, so mark it terminal (NeedsLogin) — run()
|
||||||
|
// then exits on its own instead of spinning the backoff. Any other failure is a
|
||||||
|
// recoverable connection reset that the backoff should retry.
|
||||||
|
func (e *Engine) wrapDisconnectError(err error) {
|
||||||
|
state := CtxGetState(e.ctx)
|
||||||
|
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied || s.Code() == codes.Unauthenticated) {
|
||||||
|
state.Set(StatusNeedsLogin)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = state.Wrap(ErrResetConnection)
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) receiveJobEvents() {
|
func (e *Engine) receiveJobEvents() {
|
||||||
e.jobExecutorWG.Add(1)
|
e.jobExecutorWG.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -1116,9 +1167,9 @@ func (e *Engine) receiveJobEvents() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// happens if management is unavailable for a long time.
|
// happens if management is unavailable for a long time, or rejects
|
||||||
// We want to cancel the operation of the whole client
|
// us (auth). wrapDisconnectError decides retry vs needs-login.
|
||||||
_ = CtxGetState(e.ctx).Wrap(ErrResetConnection)
|
e.wrapDisconnectError(err)
|
||||||
e.clientCancel()
|
e.clientCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1141,6 +1192,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)
|
||||||
},
|
},
|
||||||
@@ -1199,9 +1251,9 @@ func (e *Engine) receiveManagementEvents() {
|
|||||||
|
|
||||||
err = e.mgmClient.Sync(e.ctx, info, e.handleSync)
|
err = e.mgmClient.Sync(e.ctx, info, e.handleSync)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// happens if management is unavailable for a long time.
|
// happens if management is unavailable for a long time, or rejects
|
||||||
// We want to cancel the operation of the whole client
|
// us (auth). wrapDisconnectError decides retry vs needs-login.
|
||||||
_ = CtxGetState(e.ctx).Wrap(ErrResetConnection)
|
e.wrapDisconnectError(err)
|
||||||
e.clientCancel()
|
e.clientCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1678,6 +1730,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)
|
||||||
@@ -1718,9 +1777,9 @@ func (e *Engine) receiveSignalEvents() {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// happens if signal is unavailable for a long time.
|
// happens if signal is unavailable for a long time, or rejects us
|
||||||
// We want to cancel the operation of the whole client
|
// (auth). wrapDisconnectError decides retry vs needs-login.
|
||||||
_ = CtxGetState(e.ctx).Wrap(ErrResetConnection)
|
e.wrapDisconnectError(err)
|
||||||
e.clientCancel()
|
e.clientCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1813,6 +1872,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 +1935,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2112,21 +2182,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
|
||||||
@@ -2142,45 +2197,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
|
||||||
@@ -2216,7 +2268,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +193,7 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
type Status struct {
|
type Status struct {
|
||||||
mux sync.RWMutex
|
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
|
||||||
@@ -230,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),
|
||||||
@@ -281,6 +284,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,19 +319,22 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
|
|||||||
|
|
||||||
// PeerStateByIP returns the full peer State for the given tunnel IP.
|
// PeerStateByIP returns the full peer State for the given tunnel IP.
|
||||||
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
||||||
// address so dual-stack peers are reachable on either family. Returns the
|
// address so dual-stack peers are reachable on either family. Only
|
||||||
// zero State and false when no peer matches or the input is empty.
|
// 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) {
|
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
return State{}, false
|
return State{}, false
|
||||||
}
|
}
|
||||||
d.mux.RLock()
|
d.mux.RLock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.RUnlock()
|
||||||
|
key, ok := d.ipToKey[ip]
|
||||||
for _, state := range d.peers {
|
if !ok {
|
||||||
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
|
return State{}, false
|
||||||
return state, true
|
}
|
||||||
}
|
state, ok := d.peers[key]
|
||||||
|
if ok {
|
||||||
|
return state, true
|
||||||
}
|
}
|
||||||
return State{}, false
|
return State{}, false
|
||||||
}
|
}
|
||||||
@@ -332,12 +344,18 @@ 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
|
||||||
}
|
}
|
||||||
@@ -1006,14 +1024,17 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
|
|||||||
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,
|
||||||
@@ -1023,10 +1044,14 @@ 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 {
|
||||||
@@ -1348,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)
|
||||||
@@ -1386,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()
|
||||||
|
|||||||
@@ -90,6 +90,45 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
|
|||||||
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
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)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -332,6 +333,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.notifier.Close()
|
||||||
|
|
||||||
m.mux.Lock()
|
m.mux.Lock()
|
||||||
defer m.mux.Unlock()
|
defer m.mux.Unlock()
|
||||||
m.clientRoutes = nil
|
m.clientRoutes = nil
|
||||||
@@ -700,6 +703,15 @@ func resolveURLsToIPs(urls []string) []net.IP {
|
|||||||
|
|
||||||
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
|
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
|
||||||
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
|
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
|
||||||
|
m.mirrorV6ExitPairSelections(clientRoutes)
|
||||||
|
|
||||||
|
// An explicit user "deselect all" must not be overridden by management auto-apply.
|
||||||
|
// Auto-applying an exit node here would call SelectRoutes, which clears the
|
||||||
|
// deselect-all flag and re-enables every route the user turned off.
|
||||||
|
if m.routeSelector.IsDeselectAll() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
|
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
|
||||||
if len(exitNodeInfo.allIDs) == 0 {
|
if len(exitNodeInfo.allIDs) == 0 {
|
||||||
return
|
return
|
||||||
@@ -709,6 +721,24 @@ func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HA
|
|||||||
m.logExitNodeUpdate(exitNodeInfo)
|
m.logExitNodeUpdate(exitNodeInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection
|
||||||
|
// consistent with its v4 base. The v4/v6 exit pair is a single toggle, so the v6
|
||||||
|
// entry always follows the base: deselecting the v4 exit node also drops its ::/0
|
||||||
|
// pair, and any stale (orphaned) explicit selection on the v6 entry is reset. This
|
||||||
|
// runs before selection is read so both collectExitNodeInfo and FilterSelectedExitNodes
|
||||||
|
// see consistent state, including pairs loaded from persisted selector state.
|
||||||
|
func (m *DefaultManager) mirrorV6ExitPairSelections(clientRoutes route.HAMap) {
|
||||||
|
routesByNetID := make(map[route.NetID][]*route.Route, len(clientRoutes))
|
||||||
|
for haID, routes := range clientRoutes {
|
||||||
|
routesByNetID[haID.NetID()] = routes
|
||||||
|
}
|
||||||
|
|
||||||
|
for v6ID := range route.V6ExitMergeSet(routesByNetID) {
|
||||||
|
baseID := route.NetID(strings.TrimSuffix(string(v6ID), route.V6ExitSuffix))
|
||||||
|
m.routeSelector.SyncPairedSelection(baseID, v6ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type exitNodeInfo struct {
|
type exitNodeInfo struct {
|
||||||
allIDs []route.NetID
|
allIDs []route.NetID
|
||||||
selectedByManagement []route.NetID
|
selectedByManagement []route.NetID
|
||||||
|
|||||||
47
client/internal/routemanager/manager_v6exit_test.go
Normal file
47
client/internal/routemanager/manager_v6exit_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package routemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||||
|
"github.com/netbirdio/netbird/route"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair reproduces the bug seen
|
||||||
|
// in netbird-engine.log: persisted selector state has the v4 exit node deselected
|
||||||
|
// but its synthesized "-v6" pair explicitly selected (orphaned), so the ::/0 route
|
||||||
|
// leaked onto the tunnel. The management update must mirror the v4 deselect onto the
|
||||||
|
// v6 pair so FilterSelectedExitNodes drops it.
|
||||||
|
func TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair(t *testing.T) {
|
||||||
|
const (
|
||||||
|
v4ID = route.NetID("Exit Node (raspberrypi)")
|
||||||
|
v6ID = route.NetID("Exit Node (raspberrypi)-v6")
|
||||||
|
)
|
||||||
|
all := []route.NetID{v4ID, v6ID}
|
||||||
|
|
||||||
|
rs := routeselector.NewRouteSelector()
|
||||||
|
// Orphan the v6 selection: select the pair, then deselect only the v4 base.
|
||||||
|
require.NoError(t, rs.SelectRoutes([]route.NetID{v4ID, v6ID}, true, all))
|
||||||
|
require.NoError(t, rs.DeselectRoutes([]route.NetID{v4ID}, all))
|
||||||
|
require.True(t, rs.IsSelected(v6ID), "precondition: orphaned v6 selection survives v4 deselect")
|
||||||
|
|
||||||
|
m := &DefaultManager{routeSelector: rs}
|
||||||
|
|
||||||
|
v4Route := &route.Route{NetID: v4ID, Network: netip.MustParsePrefix("0.0.0.0/0")}
|
||||||
|
v6Route := &route.Route{NetID: v6ID, Network: netip.MustParsePrefix("::/0")}
|
||||||
|
clientRoutes := route.HAMap{
|
||||||
|
"Exit Node (raspberrypi)|0.0.0.0/0": {v4Route},
|
||||||
|
"Exit Node (raspberrypi)-v6|::/0": {v6Route},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(clientRoutes)
|
||||||
|
|
||||||
|
assert.False(t, rs.IsSelected(v6ID), "v6 pair must follow the v4 base deselect after the management update")
|
||||||
|
|
||||||
|
filtered := rs.FilterSelectedExitNodes(clientRoutes)
|
||||||
|
assert.Empty(t, filtered, "deselected v4 exit node must not leak its ::/0 pair onto the tunnel")
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
type Notifier struct {
|
type Notifier struct {
|
||||||
initialRoutes []*route.Route
|
initialRoutes []*route.Route
|
||||||
currentRoutes []*route.Route
|
currentRoutes []*route.Route
|
||||||
fakeIPRoutes []*route.Route
|
fakeIPRoutes []*route.Route
|
||||||
|
|
||||||
listener listener.NetworkChangeListener
|
listener listener.NetworkChangeListener
|
||||||
listenerMux sync.Mutex
|
listenerMux sync.Mutex
|
||||||
@@ -119,3 +119,7 @@ func (n *Notifier) GetInitialRouteRanges() []string {
|
|||||||
sort.Strings(initialStrings)
|
sort.Strings(initialStrings)
|
||||||
return initialStrings
|
return initialStrings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) Close() {
|
||||||
|
// unused
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package notifier
|
package notifier
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"container/list"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -14,19 +15,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Notifier struct {
|
type Notifier struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cond *sync.Cond
|
||||||
currentPrefixes []string
|
currentPrefixes []string
|
||||||
|
listener listener.NetworkChangeListener
|
||||||
listener listener.NetworkChangeListener
|
queue *list.List
|
||||||
listenerMux sync.Mutex
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotifier() *Notifier {
|
func NewNotifier() *Notifier {
|
||||||
return &Notifier{}
|
n := &Notifier{
|
||||||
|
queue: list.New(),
|
||||||
|
}
|
||||||
|
n.cond = sync.NewCond(&n.mu)
|
||||||
|
go n.deliverLoop()
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||||
n.listenerMux.Lock()
|
n.mu.Lock()
|
||||||
defer n.listenerMux.Unlock()
|
defer n.mu.Unlock()
|
||||||
n.listener = listener
|
n.listener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,32 +51,52 @@ func (n *Notifier) OnNewRoutes(route.HAMap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
||||||
newNets := make([]string, 0)
|
newNets := make([]string, 0, len(prefixes))
|
||||||
for _, prefix := range prefixes {
|
for _, prefix := range prefixes {
|
||||||
newNets = append(newNets, prefix.String())
|
newNets = append(newNets, prefix.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(newNets)
|
sort.Strings(newNets)
|
||||||
|
|
||||||
|
n.mu.Lock()
|
||||||
if slices.Equal(n.currentPrefixes, newNets) {
|
if slices.Equal(n.currentPrefixes, newNets) {
|
||||||
|
n.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
n.currentPrefixes = newNets
|
n.currentPrefixes = newNets
|
||||||
n.notify()
|
routes := strings.Join(n.currentPrefixes, ",")
|
||||||
|
n.queue.PushBack(routes)
|
||||||
|
n.cond.Signal()
|
||||||
|
n.mu.Unlock()
|
||||||
}
|
}
|
||||||
func (n *Notifier) notify() {
|
|
||||||
n.listenerMux.Lock()
|
|
||||||
defer n.listenerMux.Unlock()
|
|
||||||
if n.listener == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go func(l listener.NetworkChangeListener) {
|
func (n *Notifier) Close() {
|
||||||
l.OnNetworkChanged(strings.Join(n.currentPrefixes, ","))
|
n.mu.Lock()
|
||||||
}(n.listener)
|
n.closed = true
|
||||||
|
n.cond.Signal()
|
||||||
|
n.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) deliverLoop() {
|
||||||
|
for {
|
||||||
|
n.mu.Lock()
|
||||||
|
for n.queue.Len() == 0 && !n.closed {
|
||||||
|
n.cond.Wait()
|
||||||
|
}
|
||||||
|
if n.closed && n.queue.Len() == 0 {
|
||||||
|
n.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
routes := n.queue.Remove(n.queue.Front()).(string)
|
||||||
|
l := n.listener
|
||||||
|
n.mu.Unlock()
|
||||||
|
|
||||||
|
if l != nil {
|
||||||
|
l.OnNetworkChanged(routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,3 +38,7 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
|||||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) Close() {
|
||||||
|
// unused
|
||||||
|
}
|
||||||
|
|||||||
71
client/internal/routemanager/selector_management_test.go
Normal file
71
client/internal/routemanager/selector_management_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package routemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/routeselector"
|
||||||
|
"github.com/netbirdio/netbird/route"
|
||||||
|
)
|
||||||
|
|
||||||
|
func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap {
|
||||||
|
haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0")
|
||||||
|
return route.HAMap{
|
||||||
|
haID: []*route.Route{
|
||||||
|
{
|
||||||
|
ID: "r-" + route.ID(netID),
|
||||||
|
NetID: netID,
|
||||||
|
Network: netip.MustParsePrefix("0.0.0.0/0"),
|
||||||
|
NetworkType: route.IPv4Network,
|
||||||
|
Enabled: true,
|
||||||
|
SkipAutoApply: skipAutoApply,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRouteSelectorFromManagement(t *testing.T) {
|
||||||
|
t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) {
|
||||||
|
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||||
|
routes := exitNodeRoutes("exit1", false)
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected")
|
||||||
|
require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) {
|
||||||
|
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||||
|
routes := exitNodeRoutes("exit1", true)
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected")
|
||||||
|
require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("user selection is not overridden by management", func(t *testing.T) {
|
||||||
|
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||||
|
require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"}))
|
||||||
|
routes := exitNodeRoutes("exit1", true)
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply")
|
||||||
|
require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deselect-all is preserved across a management sync", func(t *testing.T) {
|
||||||
|
m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
|
||||||
|
m.routeSelector.DeselectAllRoutes()
|
||||||
|
routes := exitNodeRoutes("exit1", false)
|
||||||
|
|
||||||
|
m.updateRouteSelectorFromManagement(routes)
|
||||||
|
|
||||||
|
require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply")
|
||||||
|
require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -121,9 +121,12 @@ func (r *SysOps) addRouteToNonVPNIntf(prefix netip.Prefix, vpnIntf wgIface, init
|
|||||||
return Nexthop{}, vars.ErrRouteNotAllowed
|
return Nexthop{}, vars.ErrRouteNotAllowed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the prefix is part of any local subnets
|
// BSDs blackhole a /32 added inside a directly-connected subnet; Linux/Windows need it to beat the wt0 route.
|
||||||
if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal {
|
switch runtime.GOOS {
|
||||||
return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed)
|
case "darwin", "freebsd", "netbsd", "openbsd", "dragonfly":
|
||||||
|
if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal {
|
||||||
|
return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the exit interface and next hop for the prefix, so we can add a specific route
|
// Determine the exit interface and next hop for the prefix, so we can add a specific route
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
@@ -116,6 +115,14 @@ func (rs *RouteSelector) DeselectAllRoutes() {
|
|||||||
clear(rs.selectedRoutes)
|
clear(rs.selectedRoutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsDeselectAll reports whether the user has explicitly deselected all routes.
|
||||||
|
func (rs *RouteSelector) IsDeselectAll() bool {
|
||||||
|
rs.mu.RLock()
|
||||||
|
defer rs.mu.RUnlock()
|
||||||
|
|
||||||
|
return rs.deselectAll
|
||||||
|
}
|
||||||
|
|
||||||
// IsSelected checks if a specific route is selected.
|
// IsSelected checks if a specific route is selected.
|
||||||
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
|
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
|
||||||
rs.mu.RLock()
|
rs.mu.RLock()
|
||||||
@@ -124,6 +131,33 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
|
|||||||
return rs.isSelectedLocked(routeID)
|
return rs.isSelectedLocked(routeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncPairedSelection forces pairedID's explicit selection state to match baseID's,
|
||||||
|
// so a synthesized "-v6" exit route always follows its v4 base: selecting or
|
||||||
|
// deselecting the v4 exit node governs the ::/0 pair, and any stale (orphaned)
|
||||||
|
// explicit state on the v6 entry is reset. The v4/v6 exit pair is treated as a single
|
||||||
|
// toggle, so the v6 entry carries no independent selection of its own.
|
||||||
|
func (rs *RouteSelector) SyncPairedSelection(baseID, pairedID route.NetID) {
|
||||||
|
rs.mu.Lock()
|
||||||
|
defer rs.mu.Unlock()
|
||||||
|
|
||||||
|
if rs.deselectAll {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, baseSelected := rs.selectedRoutes[baseID]
|
||||||
|
_, baseDeselected := rs.deselectedRoutes[baseID]
|
||||||
|
|
||||||
|
delete(rs.selectedRoutes, pairedID)
|
||||||
|
delete(rs.deselectedRoutes, pairedID)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case baseSelected:
|
||||||
|
rs.selectedRoutes[pairedID] = struct{}{}
|
||||||
|
case baseDeselected:
|
||||||
|
rs.deselectedRoutes[pairedID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FilterSelected removes unselected routes from the provided map.
|
// FilterSelected removes unselected routes from the provided map.
|
||||||
func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
|
func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
|
||||||
rs.mu.RLock()
|
rs.mu.RLock()
|
||||||
@@ -143,14 +177,13 @@ func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route.
|
// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route.
|
||||||
// Intended for exit-node code paths: a v6 exit-node pair (e.g. "MyExit-v6") with no explicit state of
|
// The lookup is literal; v4/v6 exit pairs are kept consistent at write time via SyncPairedSelection,
|
||||||
// its own inherits its v4 base's state, so legacy persisted selections that predate v6 pairing
|
// so a synthesized "-v6" entry carries the same explicit state as its v4 base.
|
||||||
// transparently apply to the synthesized v6 entry.
|
|
||||||
func (rs *RouteSelector) HasUserSelectionForRoute(routeID route.NetID) bool {
|
func (rs *RouteSelector) HasUserSelectionForRoute(routeID route.NetID) bool {
|
||||||
rs.mu.RLock()
|
rs.mu.RLock()
|
||||||
defer rs.mu.RUnlock()
|
defer rs.mu.RUnlock()
|
||||||
|
|
||||||
return rs.hasUserSelectionForRouteLocked(rs.effectiveNetID(routeID))
|
return rs.hasUserSelectionForRouteLocked(routeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap {
|
func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap {
|
||||||
@@ -179,83 +212,6 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
// effectiveNetID returns the v4 base for a "-v6" exit pair entry that has no explicit
|
|
||||||
// state of its own, so selections made on the v4 entry govern the v6 entry automatically.
|
|
||||||
// Only call this from exit-node-specific code paths: applying it to a non-exit "-v6" route
|
|
||||||
// would make it inherit unrelated v4 state. Must be called with rs.mu held.
|
|
||||||
func (rs *RouteSelector) effectiveNetID(id route.NetID) route.NetID {
|
|
||||||
name := string(id)
|
|
||||||
if !strings.HasSuffix(name, route.V6ExitSuffix) {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
if _, ok := rs.selectedRoutes[id]; ok {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
if _, ok := rs.deselectedRoutes[id]; ok {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
return route.NetID(strings.TrimSuffix(name, route.V6ExitSuffix))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
|
|
||||||
if rs.deselectAll {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, deselected := rs.deselectedRoutes[routeID]
|
|
||||||
return !deselected
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
|
|
||||||
if rs.deselectAll {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, deselected := rs.deselectedRoutes[netID]
|
|
||||||
return deselected
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
|
|
||||||
_, selected := rs.selectedRoutes[routeID]
|
|
||||||
_, deselected := rs.deselectedRoutes[routeID]
|
|
||||||
return selected || deselected
|
|
||||||
}
|
|
||||||
|
|
||||||
func isExitNode(rt []*route.Route) bool {
|
|
||||||
return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rs *RouteSelector) applyExitNodeFilter(
|
|
||||||
id route.HAUniqueID,
|
|
||||||
netID route.NetID,
|
|
||||||
rt []*route.Route,
|
|
||||||
out route.HAMap,
|
|
||||||
) {
|
|
||||||
// Exit-node path: apply the v4/v6 pair mirror so a deselect on the v4 base also
|
|
||||||
// drops the synthesized v6 entry that lacks its own explicit state.
|
|
||||||
effective := rs.effectiveNetID(netID)
|
|
||||||
if rs.hasUserSelectionForRouteLocked(effective) {
|
|
||||||
if rs.isSelectedLocked(effective) {
|
|
||||||
out[id] = rt
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// no explicit selection for this route: defer to management's SkipAutoApply flag
|
|
||||||
sel := collectSelected(rt)
|
|
||||||
if len(sel) > 0 {
|
|
||||||
out[id] = sel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectSelected(rt []*route.Route) []*route.Route {
|
|
||||||
var sel []*route.Route
|
|
||||||
for _, r := range rt {
|
|
||||||
if !r.SkipAutoApply {
|
|
||||||
sel = append(sel, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sel
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON implements the json.Marshaler interface
|
// MarshalJSON implements the json.Marshaler interface
|
||||||
func (rs *RouteSelector) MarshalJSON() ([]byte, error) {
|
func (rs *RouteSelector) MarshalJSON() ([]byte, error) {
|
||||||
rs.mu.RLock()
|
rs.mu.RLock()
|
||||||
@@ -309,3 +265,59 @@ func (rs *RouteSelector) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
|
||||||
|
if rs.deselectAll {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, deselected := rs.deselectedRoutes[routeID]
|
||||||
|
return !deselected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
|
||||||
|
if rs.deselectAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, deselected := rs.deselectedRoutes[netID]
|
||||||
|
return deselected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
|
||||||
|
_, selected := rs.selectedRoutes[routeID]
|
||||||
|
_, deselected := rs.deselectedRoutes[routeID]
|
||||||
|
return selected || deselected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *RouteSelector) applyExitNodeFilter(
|
||||||
|
id route.HAUniqueID,
|
||||||
|
netID route.NetID,
|
||||||
|
rt []*route.Route,
|
||||||
|
out route.HAMap,
|
||||||
|
) {
|
||||||
|
if rs.hasUserSelectionForRouteLocked(netID) {
|
||||||
|
if rs.isSelectedLocked(netID) {
|
||||||
|
out[id] = rt
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// no explicit selection for this route: defer to management's SkipAutoApply flag
|
||||||
|
sel := collectSelected(rt)
|
||||||
|
if len(sel) > 0 {
|
||||||
|
out[id] = sel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExitNode(rt []*route.Route) bool {
|
||||||
|
return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectSelected(rt []*route.Route) []*route.Route {
|
||||||
|
var sel []*route.Route
|
||||||
|
for _, r := range rt {
|
||||||
|
if !r.SkipAutoApply {
|
||||||
|
sel = append(sel, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user