Compare commits
105 Commits
deploy/pro
...
client-ipv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6acc6a13f1 | ||
|
|
58eb519dbc | ||
|
|
145d82f322 | ||
|
|
a8b9570700 | ||
|
|
6ff6d84646 | ||
|
|
baf2c03508 | ||
|
|
878dc45abf | ||
|
|
b852ce1a99 | ||
|
|
013770070a | ||
|
|
9aaa05e8ea | ||
|
|
0af5a0441f | ||
|
|
0fc63ea0ba | ||
|
|
0b329f7881 | ||
|
|
5b85edb753 | ||
|
|
17cfa5fe1e | ||
|
|
2313494e0e | ||
|
|
fd9d430334 | ||
|
|
acdf8d981a | ||
|
|
91f0d5cefd | ||
|
|
82762280ee | ||
|
|
e2f774824b | ||
|
|
3963072c43 | ||
|
|
8550765f38 | ||
|
|
67fb6be40a | ||
|
|
cd7290a497 | ||
|
|
63c19dbf2e | ||
|
|
01c4d5761d | ||
|
|
e916e0d7fa | ||
|
|
b550a2face | ||
|
|
ab77508950 | ||
|
|
b9462f5c6b | ||
|
|
5ffaa5cdd6 | ||
|
|
a1858a9cb7 | ||
|
|
212b34f639 | ||
|
|
af8eaa23e2 | ||
|
|
f0eed50678 | ||
|
|
19d94c6158 | ||
|
|
628eb56073 | ||
|
|
a590c38d8b | ||
|
|
4e149c9222 | ||
|
|
59f5b34280 | ||
|
|
dff06d0898 | ||
|
|
80a8816b1d | ||
|
|
387e374e4b | ||
|
|
3e6baea405 | ||
|
|
fe9b844511 | ||
|
|
2e1aa497d2 | ||
|
|
529c0314f8 | ||
|
|
d86875aeac | ||
|
|
f80fe506d5 | ||
|
|
967c6f3cd3 | ||
|
|
e50e124e70 | ||
|
|
c545689448 | ||
|
|
8f389fef19 | ||
|
|
d3d6a327e0 | ||
|
|
b5489d4986 | ||
|
|
7a23c57cf8 | ||
|
|
11f891220e | ||
|
|
5585adce18 | ||
|
|
f884299823 | ||
|
|
15aa6bae1b | ||
|
|
11eb725ac8 | ||
|
|
30c02ab78c | ||
|
|
3acd86e346 | ||
|
|
5c20f13c48 | ||
|
|
e6587b071d | ||
|
|
85451ab4cd | ||
|
|
a7f3ba03eb | ||
|
|
4f0a3a77ad | ||
|
|
44655ca9b5 | ||
|
|
e601278117 | ||
|
|
8e7b016be2 | ||
|
|
9e01ea7aae | ||
|
|
cfc7ec8bb9 | ||
|
|
b3bbc0e5c6 | ||
|
|
d7c8e37ff4 | ||
|
|
05b66e73bc | ||
|
|
01ceedac89 | ||
|
|
403babd433 | ||
|
|
47133031e5 | ||
|
|
82da606886 | ||
|
|
bbe5ae2145 | ||
|
|
0b21498b39 | ||
|
|
0ca59535f1 | ||
|
|
59c77d0658 | ||
|
|
333e045099 | ||
|
|
c2c4d9d336 | ||
|
|
9a6a72e88e | ||
|
|
afe6d9fca4 | ||
|
|
ef82905526 | ||
|
|
d18747e846 | ||
|
|
f341d69314 | ||
|
|
327142837c | ||
|
|
f8c0321aee | ||
|
|
89115ff76a | ||
|
|
63c83aa8d2 | ||
|
|
37f025c966 | ||
|
|
4a54f0d670 | ||
|
|
98890a29e3 | ||
|
|
9d123ec059 | ||
|
|
5d171f181a | ||
|
|
22f878b3b7 | ||
|
|
44ef1a18dd | ||
|
|
2b98dc4e52 | ||
|
|
2a26cb4567 |
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: Community Support
|
||||||
|
url: https://forum.netbird.io/
|
||||||
|
about: Community support forum
|
||||||
|
- name: Cloud Support
|
||||||
|
url: https://docs.netbird.io/help/report-bug-issues
|
||||||
|
about: Contact us for support
|
||||||
|
- name: Client/Connection Troubleshooting
|
||||||
|
url: https://docs.netbird.io/help/troubleshooting-client
|
||||||
|
about: See our client troubleshooting guide for help addressing common issues
|
||||||
|
- name: Self-host Troubleshooting
|
||||||
|
url: https://docs.netbird.io/selfhosted/troubleshooting
|
||||||
|
about: See our self-host troubleshooting guide for help addressing common issues
|
||||||
9
.github/workflows/golang-test-windows.yml
vendored
9
.github/workflows/golang-test-windows.yml
vendored
@@ -63,10 +63,15 @@ jobs:
|
|||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOMODCACHE=${{ env.cache }}
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOMODCACHE=${{ env.cache }}
|
||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
||||||
- run: echo "files=$(go list ./... | ForEach-Object { $_ } | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' })" >> $env:GITHUB_ENV
|
- name: Generate test script
|
||||||
|
run: |
|
||||||
|
$packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
|
||||||
|
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
||||||
|
$cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
||||||
|
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe test -tags=devcert -timeout 10m -p 1 ${{ env.files }} > test-out.txt 2>&1"
|
run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "${{ github.workspace }}\run-tests.cmd"
|
||||||
- name: test output
|
- name: test output
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
run: Get-Content test-out.txt
|
run: Get-Content test-out.txt
|
||||||
|
|||||||
2
.github/workflows/golangci-lint.yml
vendored
2
.github/workflows/golangci-lint.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: codespell
|
- name: codespell
|
||||||
uses: codespell-project/actions-codespell@v2
|
uses: codespell-project/actions-codespell@v2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te
|
||||||
skip: go.mod,go.sum,**/proxy/web/**
|
skip: go.mod,go.sum,**/proxy/web/**
|
||||||
golangci:
|
golangci:
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
51
.github/workflows/pr-title-check.yml
vendored
Normal file
51
.github/workflows/pr-title-check.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: PR Title Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, edited, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-title:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Validate PR title prefix
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const title = context.payload.pull_request.title;
|
||||||
|
const allowedTags = [
|
||||||
|
'management',
|
||||||
|
'client',
|
||||||
|
'signal',
|
||||||
|
'proxy',
|
||||||
|
'relay',
|
||||||
|
'misc',
|
||||||
|
'infrastructure',
|
||||||
|
'self-hosted',
|
||||||
|
'doc',
|
||||||
|
];
|
||||||
|
|
||||||
|
const pattern = /^\[([^\]]+)\]\s+.+/;
|
||||||
|
const match = title.match(pattern);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
core.setFailed(
|
||||||
|
`PR title must start with a tag in brackets.\n` +
|
||||||
|
`Example: [client] fix something\n` +
|
||||||
|
`Allowed tags: ${allowedTags.join(', ')}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = match[1].split(',').map(t => t.trim().toLowerCase());
|
||||||
|
|
||||||
|
const invalid = tags.filter(t => !allowedTags.includes(t));
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
core.setFailed(
|
||||||
|
`Invalid tag(s): ${invalid.join(', ')}\n` +
|
||||||
|
`Allowed tags: ${allowedTags.join(', ')}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Valid PR title tags: [${tags.join(', ')}]`);
|
||||||
88
.github/workflows/release.yml
vendored
88
.github/workflows/release.yml
vendored
@@ -10,7 +10,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
SIGN_PIPE_VER: "v0.1.1"
|
SIGN_PIPE_VER: "v0.1.1"
|
||||||
GORELEASER_VER: "v2.3.2"
|
GORELEASER_VER: "v2.14.3"
|
||||||
PRODUCT_NAME: "NetBird"
|
PRODUCT_NAME: "NetBird"
|
||||||
COPYRIGHT: "NetBird GmbH"
|
COPYRIGHT: "NetBird GmbH"
|
||||||
|
|
||||||
@@ -169,6 +169,14 @@ jobs:
|
|||||||
- name: Install OS build dependencies
|
- name: Install OS build dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu
|
run: sudo apt update && sudo apt install -y -q gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu
|
||||||
|
|
||||||
|
- name: Decode GPG signing key
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
env:
|
||||||
|
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
|
||||||
|
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||||
- name: Generate windows syso amd64
|
- name: Generate windows syso amd64
|
||||||
@@ -186,18 +194,54 @@ jobs:
|
|||||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||||
- name: Tag and push PR images (amd64 only)
|
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||||
|
- name: Verify RPM signatures
|
||||||
run: |
|
run: |
|
||||||
PR_TAG="pr-${{ github.event.pull_request.number }}"
|
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||||
|
dnf install -y -q rpm-sign curl >/dev/null 2>&1
|
||||||
|
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
|
||||||
|
rpm --import /tmp/rpm-pub.key
|
||||||
|
echo "=== Verifying RPM signatures ==="
|
||||||
|
for rpm_file in /dist/*amd64*.rpm; do
|
||||||
|
[ -f "$rpm_file" ] || continue
|
||||||
|
echo "--- $(basename $rpm_file) ---"
|
||||||
|
rpm -K "$rpm_file"
|
||||||
|
done
|
||||||
|
'
|
||||||
|
- name: Clean up GPG key
|
||||||
|
if: always()
|
||||||
|
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||||
|
- name: Tag and push images (amd64 only)
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||||
|
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||||
|
run: |
|
||||||
|
resolve_tags() {
|
||||||
|
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||||
|
echo "pr-${{ github.event.pull_request.number }}"
|
||||||
|
else
|
||||||
|
echo "main sha-$(git rev-parse --short HEAD)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
tag_and_push() {
|
||||||
|
local src="$1" img_name tag dst
|
||||||
|
img_name="${src%%:*}"
|
||||||
|
for tag in $(resolve_tags); do
|
||||||
|
dst="${img_name}:${tag}"
|
||||||
|
echo "Tagging ${src} -> ${dst}"
|
||||||
|
docker tag "$src" "$dst"
|
||||||
|
docker push "$dst"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
export -f tag_and_push resolve_tags
|
||||||
|
|
||||||
echo '${{ steps.goreleaser.outputs.artifacts }}' | \
|
echo '${{ steps.goreleaser.outputs.artifacts }}' | \
|
||||||
jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name' | \
|
jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name' | \
|
||||||
grep '^ghcr.io/' | while read -r SRC; do
|
grep '^ghcr.io/' | while read -r SRC; do
|
||||||
IMG_NAME="${SRC%%:*}"
|
tag_and_push "$SRC"
|
||||||
DST="${IMG_NAME}:${PR_TAG}"
|
|
||||||
echo "Tagging ${SRC} -> ${DST}"
|
|
||||||
docker tag "$SRC" "$DST"
|
|
||||||
docker push "$DST"
|
|
||||||
done
|
done
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -265,6 +309,14 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
|
- name: Decode GPG signing key
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
env:
|
||||||
|
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
|
||||||
|
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install LLVM-MinGW for ARM64 cross-compilation
|
- name: Install LLVM-MinGW for ARM64 cross-compilation
|
||||||
run: |
|
run: |
|
||||||
cd /tmp
|
cd /tmp
|
||||||
@@ -289,6 +341,24 @@ jobs:
|
|||||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||||
|
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||||
|
NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||||
|
- name: Verify RPM signatures
|
||||||
|
run: |
|
||||||
|
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||||
|
dnf install -y -q rpm-sign curl >/dev/null 2>&1
|
||||||
|
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
|
||||||
|
rpm --import /tmp/rpm-pub.key
|
||||||
|
echo "=== Verifying RPM signatures ==="
|
||||||
|
for rpm_file in /dist/*.rpm; do
|
||||||
|
[ -f "$rpm_file" ] || continue
|
||||||
|
echo "--- $(basename $rpm_file) ---"
|
||||||
|
rpm -K "$rpm_file"
|
||||||
|
done
|
||||||
|
'
|
||||||
|
- name: Clean up GPG key
|
||||||
|
if: always()
|
||||||
|
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||||
- name: upload non tags for debug purposes
|
- name: upload non tags for debug purposes
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
4
.github/workflows/wasm-build-validation.yml
vendored
4
.github/workflows/wasm-build-validation.yml
vendored
@@ -61,8 +61,8 @@ jobs:
|
|||||||
|
|
||||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||||
|
|
||||||
if [ ${SIZE} -gt 57671680 ]; then
|
if [ ${SIZE} -gt 58720256 ]; then
|
||||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 55MB limit!"
|
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -171,13 +171,13 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client.
|
description: Netbird client.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
id: netbird-deb
|
license: BSD-3-Clause
|
||||||
|
id: netbird_deb
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
builds:
|
builds:
|
||||||
- netbird
|
- netbird
|
||||||
formats:
|
formats:
|
||||||
- deb
|
- deb
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/post_install.sh"
|
postinstall: "release_files/post_install.sh"
|
||||||
preremove: "release_files/pre_remove.sh"
|
preremove: "release_files/pre_remove.sh"
|
||||||
@@ -185,16 +185,19 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client.
|
description: Netbird client.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
id: netbird-rpm
|
license: BSD-3-Clause
|
||||||
|
id: netbird_rpm
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
builds:
|
builds:
|
||||||
- netbird
|
- netbird
|
||||||
formats:
|
formats:
|
||||||
- rpm
|
- rpm
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/post_install.sh"
|
postinstall: "release_files/post_install.sh"
|
||||||
preremove: "release_files/pre_remove.sh"
|
preremove: "release_files/pre_remove.sh"
|
||||||
|
rpm:
|
||||||
|
signature:
|
||||||
|
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||||
dockers:
|
dockers:
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- netbirdio/netbird:{{ .Version }}-amd64
|
- netbirdio/netbird:{{ .Version }}-amd64
|
||||||
@@ -876,7 +879,7 @@ brews:
|
|||||||
uploads:
|
uploads:
|
||||||
- name: debian
|
- name: debian
|
||||||
ids:
|
ids:
|
||||||
- netbird-deb
|
- netbird_deb
|
||||||
mode: archive
|
mode: archive
|
||||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||||
username: dev@wiretrustee.com
|
username: dev@wiretrustee.com
|
||||||
@@ -884,7 +887,7 @@ uploads:
|
|||||||
|
|
||||||
- name: yum
|
- name: yum
|
||||||
ids:
|
ids:
|
||||||
- netbird-rpm
|
- netbird_rpm
|
||||||
mode: archive
|
mode: archive
|
||||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||||
username: dev@wiretrustee.com
|
username: dev@wiretrustee.com
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
id: netbird-ui-deb
|
id: netbird_ui_deb
|
||||||
package_name: netbird-ui
|
package_name: netbird-ui
|
||||||
builds:
|
builds:
|
||||||
- netbird-ui
|
- netbird-ui
|
||||||
@@ -80,7 +80,7 @@ nfpms:
|
|||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
homepage: https://netbird.io/
|
homepage: https://netbird.io/
|
||||||
id: netbird-ui-rpm
|
id: netbird_ui_rpm
|
||||||
package_name: netbird-ui
|
package_name: netbird-ui
|
||||||
builds:
|
builds:
|
||||||
- netbird-ui
|
- netbird-ui
|
||||||
@@ -95,11 +95,14 @@ nfpms:
|
|||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
|
rpm:
|
||||||
|
signature:
|
||||||
|
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||||
|
|
||||||
uploads:
|
uploads:
|
||||||
- name: debian
|
- name: debian
|
||||||
ids:
|
ids:
|
||||||
- netbird-ui-deb
|
- netbird_ui_deb
|
||||||
mode: archive
|
mode: archive
|
||||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||||
username: dev@wiretrustee.com
|
username: dev@wiretrustee.com
|
||||||
@@ -107,7 +110,7 @@ uploads:
|
|||||||
|
|
||||||
- name: yum
|
- name: yum
|
||||||
ids:
|
ids:
|
||||||
- netbird-ui-rpm
|
- netbird_ui_rpm
|
||||||
mode: archive
|
mode: archive
|
||||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||||
username: dev@wiretrustee.com
|
username: dev@wiretrustee.com
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ See a complete [architecture overview](https://docs.netbird.io/about-netbird/how
|
|||||||
### Community projects
|
### Community projects
|
||||||
- [NetBird installer script](https://github.com/physk/netbird-installer)
|
- [NetBird installer script](https://github.com/physk/netbird-installer)
|
||||||
- [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/)
|
- [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/)
|
||||||
|
- [netbird-tui](https://github.com/n0pashkov/netbird-tui) — terminal UI for managing NetBird peers, routes, and settings
|
||||||
|
|
||||||
**Note**: The `main` branch may be in an *unstable or even broken state* during development.
|
**Note**: The `main` branch may be in an *unstable or even broken state* during development.
|
||||||
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
|
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
|
||||||
|
|||||||
@@ -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.2
|
FROM alpine:3.23.3
|
||||||
# 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 \
|
||||||
@@ -17,8 +17,7 @@ ENV \
|
|||||||
NETBIRD_BIN="/usr/local/bin/netbird" \
|
NETBIRD_BIN="/usr/local/bin/netbird" \
|
||||||
NB_LOG_FILE="console,/var/log/netbird/client.log" \
|
NB_LOG_FILE="console,/var/log/netbird/client.log" \
|
||||||
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
|
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
|
||||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="5" \
|
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||||
NB_ENTRYPOINT_LOGIN_TIMEOUT="5"
|
|
||||||
|
|
||||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ ENV \
|
|||||||
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
|
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
|
||||||
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
|
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
|
||||||
NB_DISABLE_DNS="true" \
|
NB_DISABLE_DNS="true" \
|
||||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="5" \
|
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||||
NB_ENTRYPOINT_LOGIN_TIMEOUT="1"
|
|
||||||
|
|
||||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ 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)
|
||||||
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder, false)
|
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile)
|
return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ 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)
|
||||||
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder, false)
|
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile)
|
return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,10 +203,11 @@ func (c *Client) PeersList() *PeerInfoArray {
|
|||||||
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
|
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
|
||||||
for n, p := range fullStatus.Peers {
|
for n, p := range fullStatus.Peers {
|
||||||
pi := PeerInfo{
|
pi := PeerInfo{
|
||||||
p.IP,
|
IP: p.IP,
|
||||||
p.FQDN,
|
IPv6: p.IPv6,
|
||||||
p.ConnStatus.String(),
|
FQDN: p.FQDN,
|
||||||
PeerRoutes{routes: maps.Keys(p.GetRoutes())},
|
ConnStatus: p.ConnStatus.String(),
|
||||||
|
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
|
||||||
}
|
}
|
||||||
peerInfos[n] = pi
|
peerInfos[n] = pi
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package android
|
|||||||
// PeerInfo describe information about the peers. It designed for the UI usage
|
// PeerInfo describe information about the peers. It designed for the UI usage
|
||||||
type PeerInfo struct {
|
type PeerInfo struct {
|
||||||
IP string
|
IP string
|
||||||
|
IPv6 string
|
||||||
FQDN string
|
FQDN string
|
||||||
ConnStatus string // Todo replace to enum
|
ConnStatus string // Todo replace to enum
|
||||||
Routes PeerRoutes
|
Routes PeerRoutes
|
||||||
|
|||||||
@@ -307,6 +307,24 @@ func (p *Preferences) SetBlockInbound(block bool) {
|
|||||||
p.configInput.BlockInbound = &block
|
p.configInput.BlockInbound = &block
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDisableIPv6 reads disable IPv6 setting from config file
|
||||||
|
func (p *Preferences) GetDisableIPv6() (bool, error) {
|
||||||
|
if p.configInput.DisableIPv6 != nil {
|
||||||
|
return *p.configInput.DisableIPv6, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return cfg.DisableIPv6, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDisableIPv6 stores the given value and waits for commit
|
||||||
|
func (p *Preferences) SetDisableIPv6(disable bool) {
|
||||||
|
p.configInput.DisableIPv6 = &disable
|
||||||
|
}
|
||||||
|
|
||||||
// Commit writes out the changes to the config file
|
// Commit writes out the changes to the config file
|
||||||
func (p *Preferences) Commit() error {
|
func (p *Preferences) Commit() error {
|
||||||
_, err := profilemanager.UpdateOrCreateConfig(p.configInput)
|
_, err := profilemanager.UpdateOrCreateConfig(p.configInput)
|
||||||
|
|||||||
@@ -181,10 +181,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
if stateWasDown {
|
if stateWasDown {
|
||||||
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
||||||
return fmt.Errorf("failed to up: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
cmd.Println("netbird up")
|
||||||
|
time.Sleep(time.Second * 10)
|
||||||
}
|
}
|
||||||
cmd.Println("netbird up")
|
|
||||||
time.Sleep(time.Second * 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initialLevelTrace := initialLogLevel.GetLevel() >= proto.LogLevel_TRACE
|
initialLevelTrace := initialLogLevel.GetLevel() >= proto.LogLevel_TRACE
|
||||||
@@ -199,9 +200,10 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
||||||
return fmt.Errorf("failed to down: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to bring service down: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
cmd.Println("netbird down")
|
||||||
}
|
}
|
||||||
cmd.Println("netbird down")
|
|
||||||
|
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
@@ -209,13 +211,14 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
|||||||
if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{
|
if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("failed to enable sync response persistence: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to enable sync response persistence: %v\n", status.Convert(err).Message())
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
|
||||||
return fmt.Errorf("failed to up: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
cmd.Println("netbird up")
|
||||||
}
|
}
|
||||||
cmd.Println("netbird up")
|
|
||||||
|
|
||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
|
|
||||||
@@ -263,16 +266,18 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
if stateWasDown {
|
if stateWasDown {
|
||||||
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
|
||||||
return fmt.Errorf("failed to down: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to restore service down state: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
cmd.Println("netbird down")
|
||||||
}
|
}
|
||||||
cmd.Println("netbird down")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !initialLevelTrace {
|
if !initialLevelTrace {
|
||||||
if _, err := client.SetLogLevel(cmd.Context(), &proto.SetLogLevelRequest{Level: initialLogLevel.GetLevel()}); err != nil {
|
if _, err := client.SetLogLevel(cmd.Context(), &proto.SetLogLevelRequest{Level: initialLogLevel.GetLevel()}); err != nil {
|
||||||
return fmt.Errorf("failed to restore log level: %v", status.Convert(err).Message())
|
cmd.PrintErrf("Failed to restore log level: %v\n", status.Convert(err).Message())
|
||||||
|
} else {
|
||||||
|
cmd.Println("Log level restored to", initialLogLevel.GetLevel())
|
||||||
}
|
}
|
||||||
cmd.Println("Log level restored to", initialLogLevel.GetLevel())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Local file:\n%s\n", resp.GetPath())
|
cmd.Printf("Local file:\n%s\n", resp.GetPath())
|
||||||
|
|||||||
280
client/cmd/expose.go
Normal file
280
client/cmd/expose.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
"github.com/netbirdio/netbird/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pinRegexp = regexp.MustCompile(`^\d{6}$`)
|
||||||
|
|
||||||
|
var (
|
||||||
|
exposePin string
|
||||||
|
exposePassword string
|
||||||
|
exposeUserGroups []string
|
||||||
|
exposeDomain string
|
||||||
|
exposeNamePrefix string
|
||||||
|
exposeProtocol string
|
||||||
|
exposeExternalPort uint16
|
||||||
|
)
|
||||||
|
|
||||||
|
var exposeCmd = &cobra.Command{
|
||||||
|
Use: "expose <port>",
|
||||||
|
Short: "Expose a local port via the NetBird reverse proxy",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Example: ` netbird expose --with-password safe-pass 8080
|
||||||
|
netbird expose --protocol tcp 5432
|
||||||
|
netbird expose --protocol tcp --with-external-port 5433 5432
|
||||||
|
netbird expose --protocol tls --with-custom-domain tls.example.com 4443`,
|
||||||
|
RunE: exposeFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
exposeCmd.Flags().StringVar(&exposePin, "with-pin", "", "Protect the exposed service with a 6-digit PIN (e.g. --with-pin 123456)")
|
||||||
|
exposeCmd.Flags().StringVar(&exposePassword, "with-password", "", "Protect the exposed service with a password (e.g. --with-password my-secret)")
|
||||||
|
exposeCmd.Flags().StringSliceVar(&exposeUserGroups, "with-user-groups", nil, "Restrict access to specific user groups with SSO (e.g. --with-user-groups devops,Backend)")
|
||||||
|
exposeCmd.Flags().StringVar(&exposeDomain, "with-custom-domain", "", "Custom domain for the exposed service, must be configured to your account (e.g. --with-custom-domain myapp.example.com)")
|
||||||
|
exposeCmd.Flags().StringVar(&exposeNamePrefix, "with-name-prefix", "", "Prefix for the generated service name (e.g. --with-name-prefix my-app)")
|
||||||
|
exposeCmd.Flags().StringVar(&exposeProtocol, "protocol", "http", "Protocol to use: http, https, tcp, udp, or tls (e.g. --protocol tcp)")
|
||||||
|
exposeCmd.Flags().Uint16Var(&exposeExternalPort, "with-external-port", 0, "Public-facing external port on the proxy cluster (defaults to the target port for L4)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isClusterProtocol returns true for L4/TLS protocols that reject HTTP-style auth flags.
|
||||||
|
func isClusterProtocol(protocol string) bool {
|
||||||
|
switch strings.ToLower(protocol) {
|
||||||
|
case "tcp", "udp", "tls":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPortBasedProtocol returns true for pure port-based protocols (TCP/UDP)
|
||||||
|
// where domain display doesn't apply. TLS uses SNI so it has a domain.
|
||||||
|
func isPortBasedProtocol(protocol string) bool {
|
||||||
|
switch strings.ToLower(protocol) {
|
||||||
|
case "tcp", "udp":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPort returns the port portion of a URL like "tcp://host:12345", or
|
||||||
|
// falls back to the given default formatted as a string.
|
||||||
|
func extractPort(serviceURL string, fallback uint16) string {
|
||||||
|
u := serviceURL
|
||||||
|
if idx := strings.Index(u, "://"); idx != -1 {
|
||||||
|
u = u[idx+3:]
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(u, ":"); i != -1 {
|
||||||
|
if p := u[i+1:]; p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(uint64(fallback), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveExternalPort returns the effective external port, defaulting to the target port.
|
||||||
|
func resolveExternalPort(targetPort uint64) uint16 {
|
||||||
|
if exposeExternalPort != 0 {
|
||||||
|
return exposeExternalPort
|
||||||
|
}
|
||||||
|
return uint16(targetPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
|
||||||
|
port, err := strconv.ParseUint(portStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid port number: %s", portStr)
|
||||||
|
}
|
||||||
|
if port == 0 || port > 65535 {
|
||||||
|
return 0, fmt.Errorf("invalid port number: must be between 1 and 65535")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isProtocolValid(exposeProtocol) {
|
||||||
|
return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", exposeProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isClusterProtocol(exposeProtocol) {
|
||||||
|
if exposePin != "" || exposePassword != "" || len(exposeUserGroups) > 0 {
|
||||||
|
return 0, fmt.Errorf("auth flags (--with-pin, --with-password, --with-user-groups) are not supported for %s protocol", exposeProtocol)
|
||||||
|
}
|
||||||
|
} else if cmd.Flags().Changed("with-external-port") {
|
||||||
|
return 0, fmt.Errorf("--with-external-port is not supported for %s protocol", exposeProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exposePin != "" && !pinRegexp.MatchString(exposePin) {
|
||||||
|
return 0, fmt.Errorf("invalid pin: must be exactly 6 digits")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Flags().Changed("with-password") && exposePassword == "" {
|
||||||
|
return 0, fmt.Errorf("password cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Flags().Changed("with-user-groups") && len(exposeUserGroups) == 0 {
|
||||||
|
return 0, fmt.Errorf("user groups cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProtocolValid(exposeProtocol string) bool {
|
||||||
|
switch strings.ToLower(exposeProtocol) {
|
||||||
|
case "http", "https", "tcp", "udp", "tls":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exposeFn(cmd *cobra.Command, args []string) error {
|
||||||
|
SetFlagsFromEnvVars(rootCmd)
|
||||||
|
|
||||||
|
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||||
|
log.Errorf("failed initializing log %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Root().SilenceUsage = false
|
||||||
|
|
||||||
|
port, err := validateExposeFlags(cmd, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Root().SilenceUsage = true
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(cmd.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigCh
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to daemon: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := conn.Close(); err != nil {
|
||||||
|
log.Debugf("failed to close daemon connection: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := proto.NewDaemonServiceClient(conn)
|
||||||
|
|
||||||
|
protocol, err := toExposeProtocol(exposeProtocol)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &proto.ExposeServiceRequest{
|
||||||
|
Port: uint32(port),
|
||||||
|
Protocol: protocol,
|
||||||
|
Pin: exposePin,
|
||||||
|
Password: exposePassword,
|
||||||
|
UserGroups: exposeUserGroups,
|
||||||
|
Domain: exposeDomain,
|
||||||
|
NamePrefix: exposeNamePrefix,
|
||||||
|
}
|
||||||
|
if isClusterProtocol(exposeProtocol) {
|
||||||
|
req.ListenPort = uint32(resolveExternalPort(port))
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := client.ExposeService(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("expose service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handleExposeReady(cmd, stream, port); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return waitForExposeEvents(cmd, ctx, stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) {
|
||||||
|
switch strings.ToLower(exposeProtocol) {
|
||||||
|
case "http":
|
||||||
|
return proto.ExposeProtocol_EXPOSE_HTTP, nil
|
||||||
|
case "https":
|
||||||
|
return proto.ExposeProtocol_EXPOSE_HTTPS, nil
|
||||||
|
case "tcp":
|
||||||
|
return proto.ExposeProtocol_EXPOSE_TCP, nil
|
||||||
|
case "udp":
|
||||||
|
return proto.ExposeProtocol_EXPOSE_UDP, nil
|
||||||
|
case "tls":
|
||||||
|
return proto.ExposeProtocol_EXPOSE_TLS, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", exposeProtocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleExposeReady(cmd *cobra.Command, stream proto.DaemonService_ExposeServiceClient, port uint64) error {
|
||||||
|
event, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("receive expose event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ready, ok := event.Event.(*proto.ExposeServiceEvent_Ready)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected expose event: %T", event.Event)
|
||||||
|
}
|
||||||
|
printExposeReady(cmd, ready.Ready, port)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printExposeReady(cmd *cobra.Command, r *proto.ExposeServiceReady, port uint64) {
|
||||||
|
cmd.Println("Service exposed successfully!")
|
||||||
|
cmd.Printf(" Name: %s\n", r.ServiceName)
|
||||||
|
if r.ServiceUrl != "" {
|
||||||
|
cmd.Printf(" URL: %s\n", r.ServiceUrl)
|
||||||
|
}
|
||||||
|
if r.Domain != "" && !isPortBasedProtocol(exposeProtocol) {
|
||||||
|
cmd.Printf(" Domain: %s\n", r.Domain)
|
||||||
|
}
|
||||||
|
cmd.Printf(" Protocol: %s\n", exposeProtocol)
|
||||||
|
cmd.Printf(" Internal: %d\n", port)
|
||||||
|
if isClusterProtocol(exposeProtocol) {
|
||||||
|
cmd.Printf(" External: %s\n", extractPort(r.ServiceUrl, resolveExternalPort(port)))
|
||||||
|
}
|
||||||
|
if r.PortAutoAssigned && exposeExternalPort != 0 {
|
||||||
|
cmd.Printf("\n Note: requested port %d was reassigned\n", exposeExternalPort)
|
||||||
|
}
|
||||||
|
cmd.Println()
|
||||||
|
cmd.Println("Press Ctrl+C to stop exposing.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForExposeEvents(cmd *cobra.Command, ctx context.Context, stream proto.DaemonService_ExposeServiceClient) error {
|
||||||
|
for {
|
||||||
|
_, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
cmd.Println("\nService stopped.")
|
||||||
|
//nolint:nilerr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return fmt.Errorf("connection to daemon closed unexpectedly")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stream error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
|
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,6 +81,15 @@ var (
|
|||||||
Short: "",
|
Short: "",
|
||||||
Long: "",
|
Long: "",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
SetFlagsFromEnvVars(cmd.Root())
|
||||||
|
|
||||||
|
// Don't resolve for service commands — they create the socket, not connect to it.
|
||||||
|
if !isServiceCmd(cmd) {
|
||||||
|
daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -144,6 +154,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(forwardingRulesCmd)
|
rootCmd.AddCommand(forwardingRulesCmd)
|
||||||
rootCmd.AddCommand(debugCmd)
|
rootCmd.AddCommand(debugCmd)
|
||||||
rootCmd.AddCommand(profileCmd)
|
rootCmd.AddCommand(profileCmd)
|
||||||
|
rootCmd.AddCommand(exposeCmd)
|
||||||
|
|
||||||
networksCMD.AddCommand(routesListCmd)
|
networksCMD.AddCommand(routesListCmd)
|
||||||
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
||||||
@@ -385,7 +396,6 @@ func migrateToNetbird(oldPath, newPath string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) {
|
func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) {
|
||||||
SetFlagsFromEnvVars(rootCmd)
|
|
||||||
cmd.SetOut(cmd.OutOrStdout())
|
cmd.SetOut(cmd.OutOrStdout())
|
||||||
|
|
||||||
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
||||||
@@ -398,3 +408,13 @@ func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) {
|
|||||||
|
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isServiceCmd returns true if cmd is the "service" command or a child of it.
|
||||||
|
func isServiceCmd(cmd *cobra.Command) bool {
|
||||||
|
for c := cmd; c != nil; c = c.Parent() {
|
||||||
|
if c.Name() == "service" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,7 +103,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) (service.Service, error) {
|
||||||
SetFlagsFromEnvVars(rootCmd)
|
// rootCmd env vars are already applied by PersistentPreRunE.
|
||||||
SetFlagsFromEnvVars(serviceCmd)
|
SetFlagsFromEnvVars(serviceCmd)
|
||||||
|
|
||||||
cmd.SetOut(cmd.OutOrStdout())
|
cmd.SetOut(cmd.OutOrStdout())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/updatemanager/reposign"
|
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/updatemanager/reposign"
|
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/updatemanager/reposign"
|
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/updatemanager/reposign"
|
"github.com/netbirdio/netbird/client/internal/updater/reposign"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
detailFlag bool
|
detailFlag bool
|
||||||
ipv4Flag bool
|
ipv4Flag bool
|
||||||
|
ipv6Flag bool
|
||||||
jsonFlag bool
|
jsonFlag bool
|
||||||
yamlFlag bool
|
yamlFlag bool
|
||||||
ipsFilter []string
|
ipsFilter []string
|
||||||
@@ -28,6 +29,7 @@ var (
|
|||||||
ipsFilterMap map[string]struct{}
|
ipsFilterMap map[string]struct{}
|
||||||
prefixNamesFilterMap map[string]struct{}
|
prefixNamesFilterMap map[string]struct{}
|
||||||
connectionTypeFilter string
|
connectionTypeFilter string
|
||||||
|
checkFlag string
|
||||||
)
|
)
|
||||||
|
|
||||||
var statusCmd = &cobra.Command{
|
var statusCmd = &cobra.Command{
|
||||||
@@ -44,11 +46,13 @@ func init() {
|
|||||||
statusCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "display detailed status information in json format")
|
statusCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "display detailed status information in json format")
|
||||||
statusCmd.PersistentFlags().BoolVar(&yamlFlag, "yaml", false, "display detailed status information in yaml format")
|
statusCmd.PersistentFlags().BoolVar(&yamlFlag, "yaml", false, "display detailed status information in yaml format")
|
||||||
statusCmd.PersistentFlags().BoolVar(&ipv4Flag, "ipv4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33")
|
statusCmd.PersistentFlags().BoolVar(&ipv4Flag, "ipv4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33")
|
||||||
statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4")
|
statusCmd.PersistentFlags().BoolVar(&ipv6Flag, "ipv6", false, "display only NetBird IPv6 of this peer")
|
||||||
statusCmd.PersistentFlags().StringSliceVar(&ipsFilter, "filter-by-ips", []string{}, "filters the detailed output by a list of one or more IPs, e.g., --filter-by-ips 100.64.0.100,100.64.0.200")
|
statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4", "ipv6")
|
||||||
|
statusCmd.PersistentFlags().StringSliceVar(&ipsFilter, "filter-by-ips", []string{}, "filters the detailed output by a list of one or more IPs (v4 or v6), e.g., --filter-by-ips 100.64.0.100,fd00::1")
|
||||||
statusCmd.PersistentFlags().StringSliceVar(&prefixNamesFilter, "filter-by-names", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud")
|
statusCmd.PersistentFlags().StringSliceVar(&prefixNamesFilter, "filter-by-names", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud")
|
||||||
statusCmd.PersistentFlags().StringVar(&statusFilter, "filter-by-status", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected")
|
statusCmd.PersistentFlags().StringVar(&statusFilter, "filter-by-status", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected")
|
||||||
statusCmd.PersistentFlags().StringVar(&connectionTypeFilter, "filter-by-connection-type", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P")
|
statusCmd.PersistentFlags().StringVar(&connectionTypeFilter, "filter-by-connection-type", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P")
|
||||||
|
statusCmd.PersistentFlags().StringVar(&checkFlag, "check", "", "run a health check and exit with code 0 on success, 1 on failure (live|ready|startup)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusFunc(cmd *cobra.Command, args []string) error {
|
func statusFunc(cmd *cobra.Command, args []string) error {
|
||||||
@@ -56,6 +60,10 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
cmd.SetOut(cmd.OutOrStdout())
|
cmd.SetOut(cmd.OutOrStdout())
|
||||||
|
|
||||||
|
if checkFlag != "" {
|
||||||
|
return runHealthCheck(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
err := parseFilters()
|
err := parseFilters()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -68,15 +76,17 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
ctx := internal.CtxInitState(cmd.Context())
|
ctx := internal.CtxInitState(cmd.Context())
|
||||||
|
|
||||||
resp, err := getStatus(ctx, false)
|
resp, err := getStatus(ctx, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
status := resp.GetStatus()
|
status := resp.GetStatus()
|
||||||
|
|
||||||
if status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) ||
|
needsAuth := status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) ||
|
||||||
status == string(internal.StatusSessionExpired) {
|
status == string(internal.StatusSessionExpired)
|
||||||
|
|
||||||
|
if needsAuth && !jsonFlag && !yamlFlag {
|
||||||
cmd.Printf("Daemon status: %s\n\n"+
|
cmd.Printf("Daemon status: %s\n\n"+
|
||||||
"Run UP command to log in with SSO (interactive login):\n\n"+
|
"Run UP command to log in with SSO (interactive login):\n\n"+
|
||||||
" netbird up \n\n"+
|
" netbird up \n\n"+
|
||||||
@@ -93,13 +103,31 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ipv6Flag {
|
||||||
|
ipv6 := resp.GetFullStatus().GetLocalPeerState().GetIpv6()
|
||||||
|
if ipv6 != "" {
|
||||||
|
cmd.Print(parseInterfaceIP(ipv6))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
pm := profilemanager.NewProfileManager()
|
pm := profilemanager.NewProfileManager()
|
||||||
var profName string
|
var profName string
|
||||||
if activeProf, err := pm.GetActiveProfile(); err == nil {
|
if activeProf, err := pm.GetActiveProfile(); err == nil {
|
||||||
profName = activeProf.Name
|
profName = activeProf.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), anonymizeFlag, resp.GetDaemonVersion(), statusFilter, prefixNamesFilter, prefixNamesFilterMap, ipsFilterMap, connectionTypeFilter, profName)
|
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||||
|
Anonymize: anonymizeFlag,
|
||||||
|
DaemonVersion: resp.GetDaemonVersion(),
|
||||||
|
DaemonStatus: nbstatus.ParseDaemonStatus(status),
|
||||||
|
StatusFilter: statusFilter,
|
||||||
|
PrefixNamesFilter: prefixNamesFilter,
|
||||||
|
PrefixNamesFilterMap: prefixNamesFilterMap,
|
||||||
|
IPsFilter: ipsFilterMap,
|
||||||
|
ConnectionTypeFilter: connectionTypeFilter,
|
||||||
|
ProfileName: profName,
|
||||||
|
})
|
||||||
var statusOutputString string
|
var statusOutputString string
|
||||||
switch {
|
switch {
|
||||||
case detailFlag:
|
case detailFlag:
|
||||||
@@ -121,7 +149,7 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStatus(ctx context.Context, shouldRunProbes bool) (*proto.StatusResponse, error) {
|
func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (*proto.StatusResponse, error) {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//nolint
|
//nolint
|
||||||
@@ -131,7 +159,7 @@ func getStatus(ctx context.Context, shouldRunProbes bool) (*proto.StatusResponse
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true, ShouldRunProbes: shouldRunProbes})
|
resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{GetFullPeerStatus: fullPeerStatus, ShouldRunProbes: shouldRunProbes})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("status failed: %v", status.Convert(err).Message())
|
return nil, fmt.Errorf("status failed: %v", status.Convert(err).Message())
|
||||||
}
|
}
|
||||||
@@ -185,6 +213,83 @@ func enableDetailFlagWhenFilterFlag() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runHealthCheck(cmd *cobra.Command) error {
|
||||||
|
check := strings.ToLower(checkFlag)
|
||||||
|
switch check {
|
||||||
|
case "live", "ready", "startup":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown check %q, must be one of: live, ready, startup", checkFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||||
|
return fmt.Errorf("init log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := internal.CtxInitState(cmd.Context())
|
||||||
|
|
||||||
|
isStartup := check == "startup"
|
||||||
|
resp, err := getStatus(ctx, isStartup, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch check {
|
||||||
|
case "live":
|
||||||
|
return nil
|
||||||
|
case "ready":
|
||||||
|
return checkReadiness(resp)
|
||||||
|
case "startup":
|
||||||
|
return checkStartup(resp)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkReadiness(resp *proto.StatusResponse) error {
|
||||||
|
daemonStatus := internal.StatusType(resp.GetStatus())
|
||||||
|
switch daemonStatus {
|
||||||
|
case internal.StatusIdle, internal.StatusConnecting, internal.StatusConnected:
|
||||||
|
return nil
|
||||||
|
case internal.StatusNeedsLogin, internal.StatusLoginFailed, internal.StatusSessionExpired:
|
||||||
|
return fmt.Errorf("readiness check: daemon status is %s", daemonStatus)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("readiness check: unexpected daemon status %q", daemonStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkStartup(resp *proto.StatusResponse) error {
|
||||||
|
fullStatus := resp.GetFullStatus()
|
||||||
|
if fullStatus == nil {
|
||||||
|
return fmt.Errorf("startup check: no full status available")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fullStatus.GetManagementState().GetConnected() {
|
||||||
|
return fmt.Errorf("startup check: management not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fullStatus.GetSignalState().GetConnected() {
|
||||||
|
return fmt.Errorf("startup check: signal not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
var relayCount, relaysConnected int
|
||||||
|
for _, r := range fullStatus.GetRelays() {
|
||||||
|
uri := r.GetURI()
|
||||||
|
if !strings.HasPrefix(uri, "rel://") && !strings.HasPrefix(uri, "rels://") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
relayCount++
|
||||||
|
if r.GetAvailable() {
|
||||||
|
relaysConnected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if relayCount > 0 && relaysConnected == 0 {
|
||||||
|
return fmt.Errorf("startup check: no relay servers available (0/%d connected)", relayCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseInterfaceIP(interfaceIP string) string {
|
func parseInterfaceIP(interfaceIP string) string {
|
||||||
ip, _, err := net.ParseCIDR(interfaceIP)
|
ip, _, err := net.ParseCIDR(interfaceIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const (
|
|||||||
disableFirewallFlag = "disable-firewall"
|
disableFirewallFlag = "disable-firewall"
|
||||||
blockLANAccessFlag = "block-lan-access"
|
blockLANAccessFlag = "block-lan-access"
|
||||||
blockInboundFlag = "block-inbound"
|
blockInboundFlag = "block-inbound"
|
||||||
|
disableIPv6Flag = "disable-ipv6"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -17,6 +18,7 @@ var (
|
|||||||
disableFirewall bool
|
disableFirewall bool
|
||||||
blockLANAccess bool
|
blockLANAccess bool
|
||||||
blockInbound bool
|
blockInbound bool
|
||||||
|
disableIPv6 bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -39,4 +41,7 @@ func init() {
|
|||||||
upCmd.PersistentFlags().BoolVar(&blockInbound, blockInboundFlag, false,
|
upCmd.PersistentFlags().BoolVar(&blockInbound, blockInboundFlag, false,
|
||||||
"Block inbound connections. If enabled, the client will not allow any inbound connections to the local machine nor routed networks.\n"+
|
"Block inbound connections. If enabled, the client will not allow any inbound connections to the local machine nor routed networks.\n"+
|
||||||
"This overrides any policies received from the management service.")
|
"This overrides any policies received from the management service.")
|
||||||
|
|
||||||
|
upCmd.PersistentFlags().BoolVar(&disableIPv6, disableIPv6Flag, false,
|
||||||
|
"Disable IPv6 overlay. If enabled, the client won't request or use an IPv6 overlay address.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ 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, false)
|
connectClient := internal.NewConnectClient(ctx, config, r)
|
||||||
SetupDebugHandler(ctx, config, r, connectClient, "")
|
SetupDebugHandler(ctx, config, r, connectClient, "")
|
||||||
|
|
||||||
return connectClient.Run(nil, util.FindFirstLogPath(logFiles))
|
return connectClient.Run(nil, util.FindFirstLogPath(logFiles))
|
||||||
@@ -430,6 +430,10 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
|||||||
req.BlockInbound = &blockInbound
|
req.BlockInbound = &blockInbound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(disableIPv6Flag).Changed {
|
||||||
|
req.DisableIpv6 = &disableIPv6
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
||||||
req.LazyConnectionEnabled = &lazyConnEnabled
|
req.LazyConnectionEnabled = &lazyConnEnabled
|
||||||
}
|
}
|
||||||
@@ -547,6 +551,10 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
|||||||
ic.BlockInbound = &blockInbound
|
ic.BlockInbound = &blockInbound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(disableIPv6Flag).Changed {
|
||||||
|
ic.DisableIPv6 = &disableIPv6
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
||||||
ic.LazyConnectionEnabled = &lazyConnEnabled
|
ic.LazyConnectionEnabled = &lazyConnEnabled
|
||||||
}
|
}
|
||||||
@@ -661,6 +669,10 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
|||||||
loginRequest.BlockInbound = &blockInbound
|
loginRequest.BlockInbound = &blockInbound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(disableIPv6Flag).Changed {
|
||||||
|
loginRequest.DisableIpv6 = &disableIPv6
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
if cmd.Flag(enableLazyConnectionFlag).Changed {
|
||||||
loginRequest.LazyConnectionEnabled = &lazyConnEnabled
|
loginRequest.LazyConnectionEnabled = &lazyConnEnabled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/updatemanager/installer"
|
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,10 +79,20 @@ type Options struct {
|
|||||||
StatePath string
|
StatePath string
|
||||||
// DisableClientRoutes disables the client routes
|
// DisableClientRoutes disables the client routes
|
||||||
DisableClientRoutes bool
|
DisableClientRoutes bool
|
||||||
|
// DisableIPv6 disables IPv6 overlay addressing
|
||||||
|
DisableIPv6 bool
|
||||||
// BlockInbound blocks all inbound connections from peers
|
// BlockInbound blocks all inbound connections from peers
|
||||||
BlockInbound bool
|
BlockInbound bool
|
||||||
// WireguardPort is the port for the WireGuard interface. Use 0 for a random port.
|
// WireguardPort is the port for the WireGuard interface. Use 0 for a random port.
|
||||||
WireguardPort *int
|
WireguardPort *int
|
||||||
|
// MTU is the MTU for the WireGuard interface.
|
||||||
|
// Valid values are in the range 576..8192 bytes.
|
||||||
|
// If non-nil, this value overrides any value stored in the config file.
|
||||||
|
// If nil, the existing config MTU (if non-zero) is preserved; otherwise it defaults to 1280.
|
||||||
|
// Set to a higher value (e.g. 1400) if carrying QUIC or other protocols that require larger datagrams.
|
||||||
|
MTU *uint16
|
||||||
|
// DNSLabels defines additional DNS labels configured in the peer.
|
||||||
|
DNSLabels []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCredentials checks that exactly one credential type is provided
|
// validateCredentials checks that exactly one credential type is provided
|
||||||
@@ -112,6 +124,12 @@ func New(opts Options) (*Client, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.MTU != nil {
|
||||||
|
if err := iface.ValidateMTU(*opts.MTU); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid MTU: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if opts.LogOutput != nil {
|
if opts.LogOutput != nil {
|
||||||
logrus.SetOutput(opts.LogOutput)
|
logrus.SetOutput(opts.LogOutput)
|
||||||
}
|
}
|
||||||
@@ -140,17 +158,25 @@ func New(opts Options) (*Client, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var parsedLabels domain.List
|
||||||
|
if parsedLabels, err = domain.FromStringList(opts.DNSLabels); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid dns labels: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
t := true
|
t := true
|
||||||
var config *profilemanager.Config
|
var config *profilemanager.Config
|
||||||
var err error
|
|
||||||
input := profilemanager.ConfigInput{
|
input := profilemanager.ConfigInput{
|
||||||
ConfigPath: opts.ConfigPath,
|
ConfigPath: opts.ConfigPath,
|
||||||
ManagementURL: opts.ManagementURL,
|
ManagementURL: opts.ManagementURL,
|
||||||
PreSharedKey: &opts.PreSharedKey,
|
PreSharedKey: &opts.PreSharedKey,
|
||||||
DisableServerRoutes: &t,
|
DisableServerRoutes: &t,
|
||||||
DisableClientRoutes: &opts.DisableClientRoutes,
|
DisableClientRoutes: &opts.DisableClientRoutes,
|
||||||
|
DisableIPv6: &opts.DisableIPv6,
|
||||||
BlockInbound: &opts.BlockInbound,
|
BlockInbound: &opts.BlockInbound,
|
||||||
WireguardPort: opts.WireguardPort,
|
WireguardPort: opts.WireguardPort,
|
||||||
|
MTU: opts.MTU,
|
||||||
|
DNSLabels: parsedLabels,
|
||||||
}
|
}
|
||||||
if opts.ConfigPath != "" {
|
if opts.ConfigPath != "" {
|
||||||
config, err = profilemanager.UpdateOrCreateConfig(input)
|
config, err = profilemanager.UpdateOrCreateConfig(input)
|
||||||
@@ -202,7 +228,7 @@ 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, false)
|
client := internal.NewConnectClient(ctx, c.config, c.recorder)
|
||||||
client.SetSyncResponsePersistence(true)
|
client.SetSyncResponsePersistence(true)
|
||||||
|
|
||||||
// either startup error (permanent backoff err) or nil err (successful engine up)
|
// either startup error (permanent backoff err) or nil err (successful engine up)
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ type Manager struct {
|
|||||||
|
|
||||||
wgIface iFaceMapper
|
wgIface iFaceMapper
|
||||||
|
|
||||||
ipv4Client *iptables.IPTables
|
ipv4Client *iptables.IPTables
|
||||||
aclMgr *aclManager
|
aclMgr *aclManager
|
||||||
router *router
|
router *router
|
||||||
|
rawSupported bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// iFaceMapper defines subset methods of interface required for manager
|
// iFaceMapper defines subset methods of interface required for manager
|
||||||
@@ -84,7 +85,7 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initNoTrackChain(); err != nil {
|
if err := m.initNoTrackChain(); err != nil {
|
||||||
return fmt.Errorf("init notrack chain: %w", err)
|
log.Warnf("raw table not available, notrack rules will be disabled: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// persist early to ensure cleanup of chains
|
// persist early to ensure cleanup of chains
|
||||||
@@ -318,6 +319,10 @@ func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error {
|
|||||||
m.mutex.Lock()
|
m.mutex.Lock()
|
||||||
defer m.mutex.Unlock()
|
defer m.mutex.Unlock()
|
||||||
|
|
||||||
|
if !m.rawSupported {
|
||||||
|
return fmt.Errorf("raw table not available")
|
||||||
|
}
|
||||||
|
|
||||||
wgPortStr := fmt.Sprintf("%d", wgPort)
|
wgPortStr := fmt.Sprintf("%d", wgPort)
|
||||||
proxyPortStr := fmt.Sprintf("%d", proxyPort)
|
proxyPortStr := fmt.Sprintf("%d", proxyPort)
|
||||||
|
|
||||||
@@ -375,12 +380,16 @@ func (m *Manager) initNoTrackChain() error {
|
|||||||
return fmt.Errorf("add prerouting jump rule: %w", err)
|
return fmt.Errorf("add prerouting jump rule: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.rawSupported = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) cleanupNoTrackChain() error {
|
func (m *Manager) cleanupNoTrackChain() error {
|
||||||
exists, err := m.ipv4Client.ChainExists(tableRaw, chainNameRaw)
|
exists, err := m.ipv4Client.ChainExists(tableRaw, chainNameRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if !m.rawSupported {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("check chain exists: %w", err)
|
return fmt.Errorf("check chain exists: %w", err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -401,6 +410,7 @@ func (m *Manager) cleanupNoTrackChain() error {
|
|||||||
return fmt.Errorf("clear and delete chain: %w", err)
|
return fmt.Errorf("clear and delete chain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.rawSupported = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initNoTrackChains(workTable); err != nil {
|
if err := m.initNoTrackChains(workTable); err != nil {
|
||||||
return fmt.Errorf("init notrack chains: %w", err)
|
log.Warnf("raw priority chains not available, notrack rules will be disabled: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stateManager.RegisterState(&ShutdownState{})
|
stateManager.RegisterState(&ShutdownState{})
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func Backoff(ctx context.Context) backoff.BackOff {
|
|||||||
|
|
||||||
// CreateConnection creates a gRPC client connection with the appropriate transport options.
|
// CreateConnection creates a gRPC client connection with the appropriate transport options.
|
||||||
// The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal").
|
// The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal").
|
||||||
func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, component string) (*grpc.ClientConn, error) {
|
func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, component string, extraOpts ...grpc.DialOption) (*grpc.ClientConn, error) {
|
||||||
transportOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
transportOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||||
// for js, the outer websocket layer takes care of tls
|
// for js, the outer websocket layer takes care of tls
|
||||||
if tlsEnabled && runtime.GOOS != "js" {
|
if tlsEnabled && runtime.GOOS != "js" {
|
||||||
@@ -46,9 +46,7 @@ func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, compone
|
|||||||
connCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
connCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
conn, err := grpc.DialContext(
|
opts := []grpc.DialOption{
|
||||||
connCtx,
|
|
||||||
addr,
|
|
||||||
transportOption,
|
transportOption,
|
||||||
WithCustomDialer(tlsEnabled, component),
|
WithCustomDialer(tlsEnabled, component),
|
||||||
grpc.WithBlock(),
|
grpc.WithBlock(),
|
||||||
@@ -56,7 +54,10 @@ func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, compone
|
|||||||
Time: 30 * time.Second,
|
Time: 30 * time.Second,
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
}),
|
}),
|
||||||
)
|
}
|
||||||
|
opts = append(opts, extraOpts...)
|
||||||
|
|
||||||
|
conn, err := grpc.DialContext(connCtx, addr, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("dial context: %w", err)
|
return nil, fmt.Errorf("dial context: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,18 @@ package configurer
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"golang.zx2c4.com/wireguard/ipc"
|
"golang.zx2c4.com/wireguard/ipc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func openUAPI(deviceName string) (net.Listener, error) {
|
func openUAPI(deviceName string) (net.Listener, error) {
|
||||||
uapiSock, err := ipc.UAPIOpen(deviceName)
|
uapiSock, err := ipc.UAPIOpen(deviceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed to open uapi socket: %v", err)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
listener, err := ipc.UAPIListen(deviceName, uapiSock)
|
listener, err := ipc.UAPIListen(deviceName, uapiSock)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed to listen on uapi socket: %v", err)
|
_ = uapiSock.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder
|
|||||||
return wgCfg
|
return wgCfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer {
|
||||||
|
return &WGUSPConfigurer{
|
||||||
|
device: device,
|
||||||
|
deviceName: deviceName,
|
||||||
|
activityRecorder: activityRecorder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error {
|
func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error {
|
||||||
log.Debugf("adding Wireguard private key")
|
log.Debugf("adding Wireguard private key")
|
||||||
key, err := wgtypes.ParseKey(privateKey)
|
key, err := wgtypes.ParseKey(privateKey)
|
||||||
|
|||||||
@@ -131,23 +131,32 @@ func (t *TunDevice) Device() *device.Device {
|
|||||||
|
|
||||||
// assignAddr Adds IP address to the tunnel interface and network route based on the range provided
|
// assignAddr Adds IP address to the tunnel interface and network route based on the range provided
|
||||||
func (t *TunDevice) assignAddr() error {
|
func (t *TunDevice) assignAddr() error {
|
||||||
cmd := exec.Command("ifconfig", t.name, "inet", t.address.IP.String(), t.address.IP.String())
|
if out, err := exec.Command("ifconfig", t.name, "inet", t.address.IP.String(), t.address.IP.String()).CombinedOutput(); err != nil {
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
return fmt.Errorf("add v4 address: %s: %w", string(out), err)
|
||||||
log.Errorf("adding address command '%v' failed with output: %s", cmd.String(), out)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dummy ipv6 so routing works
|
// Assign a dummy link-local so macOS enables IPv6 on the tun device.
|
||||||
cmd = exec.Command("ifconfig", t.name, "inet6", "fe80::/64")
|
// When a real overlay v6 is present, use that instead.
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
v6Addr := "fe80::/64"
|
||||||
log.Debugf("adding address command '%v' failed with output: %s", cmd.String(), out)
|
if t.address.HasIPv6() {
|
||||||
|
v6Addr = t.address.IPv6String()
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("ifconfig", t.name, "inet6", v6Addr).CombinedOutput(); err != nil {
|
||||||
|
log.Warnf("failed to assign IPv6 address %s, continuing v4-only: %s: %v", v6Addr, string(out), err)
|
||||||
|
t.address.ClearIPv6()
|
||||||
}
|
}
|
||||||
|
|
||||||
routeCmd := exec.Command("route", "add", "-net", t.address.Network.String(), "-interface", t.name)
|
if out, err := exec.Command("route", "add", "-net", t.address.Network.String(), "-interface", t.name).CombinedOutput(); err != nil {
|
||||||
if out, err := routeCmd.CombinedOutput(); err != nil {
|
return fmt.Errorf("add route %s via %s: %s: %w", t.address.Network, t.name, string(out), err)
|
||||||
log.Errorf("adding route command '%v' failed with output: %s", routeCmd.String(), out)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.address.HasIPv6() {
|
||||||
|
if out, err := exec.Command("route", "add", "-inet6", "-net", t.address.IPv6Net.String(), "-interface", t.name).CombinedOutput(); err != nil {
|
||||||
|
log.Warnf("failed to add route %s via %s, continuing v4-only: %s: %v", t.address.IPv6Net, t.name, string(out), err)
|
||||||
|
t.address.ClearIPv6()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,8 +151,11 @@ func (t *TunDevice) MTU() uint16 {
|
|||||||
return t.mtu
|
return t.mtu
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TunDevice) UpdateAddr(_ wgaddr.Address) error {
|
// UpdateAddr updates the device address. On iOS the tunnel is managed by the
|
||||||
// todo implement
|
// NetworkExtension, so we only store the new value. The extension picks up the
|
||||||
|
// change on the next tunnel reconfiguration.
|
||||||
|
func (t *TunDevice) UpdateAddr(addr wgaddr.Address) error {
|
||||||
|
t.address = addr
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ func (t *TunKernelDevice) FilteredDevice() *FilteredDevice {
|
|||||||
|
|
||||||
// assignAddr Adds IP address to the tunnel interface
|
// assignAddr Adds IP address to the tunnel interface
|
||||||
func (t *TunKernelDevice) assignAddr() error {
|
func (t *TunKernelDevice) assignAddr() error {
|
||||||
return t.link.assignAddr(t.address)
|
return t.link.assignAddr(&t.address)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TunKernelDevice) GetNet() *netstack.Net {
|
func (t *TunKernelDevice) GetNet() *netstack.Net {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package device
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.zx2c4.com/wireguard/conn"
|
"golang.zx2c4.com/wireguard/conn"
|
||||||
@@ -63,8 +64,12 @@ func (t *TunNetstackDevice) create() (WGConfigurer, error) {
|
|||||||
return nil, fmt.Errorf("last ip: %w", err)
|
return nil, fmt.Errorf("last ip: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("netstack using address: %s", t.address.IP)
|
addresses := []netip.Addr{t.address.IP}
|
||||||
t.nsTun = nbnetstack.NewNetStackTun(t.listenAddress, t.address.IP, dnsAddr, int(t.mtu))
|
if t.address.HasIPv6() {
|
||||||
|
addresses = append(addresses, t.address.IPv6)
|
||||||
|
}
|
||||||
|
log.Debugf("netstack using addresses: %v", addresses)
|
||||||
|
t.nsTun = nbnetstack.NewNetStackTun(t.listenAddress, addresses, dnsAddr, int(t.mtu))
|
||||||
log.Debugf("netstack using dns address: %s", dnsAddr)
|
log.Debugf("netstack using dns address: %s", dnsAddr)
|
||||||
tunIface, net, err := t.nsTun.Create()
|
tunIface, net, err := t.nsTun.Create()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,7 +84,7 @@ func (t *TunNetstackDevice) create() (WGConfigurer, error) {
|
|||||||
device.NewLogger(wgLogLevel(), "[netbird] "),
|
device.NewLogger(wgLogLevel(), "[netbird] "),
|
||||||
)
|
)
|
||||||
|
|
||||||
t.configurer = configurer.NewUSPConfigurer(t.device, t.name, t.bind.ActivityRecorder())
|
t.configurer = configurer.NewUSPConfigurerNoUAPI(t.device, t.name, t.bind.ActivityRecorder())
|
||||||
err = t.configurer.ConfigureInterface(t.key, t.port)
|
err = t.configurer.ConfigureInterface(t.key, t.port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if cErr := tunIface.Close(); cErr != nil {
|
if cErr := tunIface.Close(); cErr != nil {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
)
|
)
|
||||||
|
|
||||||
type USPDevice struct {
|
type TunDevice struct {
|
||||||
name string
|
name string
|
||||||
address wgaddr.Address
|
address wgaddr.Address
|
||||||
port int
|
port int
|
||||||
@@ -30,10 +30,10 @@ type USPDevice struct {
|
|||||||
configurer WGConfigurer
|
configurer WGConfigurer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUSPDevice(name string, address wgaddr.Address, port int, key string, mtu uint16, iceBind *bind.ICEBind) *USPDevice {
|
func NewTunDevice(name string, address wgaddr.Address, port int, key string, mtu uint16, iceBind *bind.ICEBind) *TunDevice {
|
||||||
log.Infof("using userspace bind mode")
|
log.Infof("using userspace bind mode")
|
||||||
|
|
||||||
return &USPDevice{
|
return &TunDevice{
|
||||||
name: name,
|
name: name,
|
||||||
address: address,
|
address: address,
|
||||||
port: port,
|
port: port,
|
||||||
@@ -43,7 +43,7 @@ func NewUSPDevice(name string, address wgaddr.Address, port int, key string, mtu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *USPDevice) Create() (WGConfigurer, error) {
|
func (t *TunDevice) Create() (WGConfigurer, error) {
|
||||||
log.Info("create tun interface")
|
log.Info("create tun interface")
|
||||||
tunIface, err := tun.CreateTUN(t.name, int(t.mtu))
|
tunIface, err := tun.CreateTUN(t.name, int(t.mtu))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,7 +75,7 @@ func (t *USPDevice) Create() (WGConfigurer, error) {
|
|||||||
return t.configurer, nil
|
return t.configurer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *USPDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
func (t *TunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
||||||
if t.device == nil {
|
if t.device == nil {
|
||||||
return nil, fmt.Errorf("device is not ready yet")
|
return nil, fmt.Errorf("device is not ready yet")
|
||||||
}
|
}
|
||||||
@@ -95,12 +95,12 @@ func (t *USPDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
|||||||
return udpMux, nil
|
return udpMux, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *USPDevice) UpdateAddr(address wgaddr.Address) error {
|
func (t *TunDevice) UpdateAddr(address wgaddr.Address) error {
|
||||||
t.address = address
|
t.address = address
|
||||||
return t.assignAddr()
|
return t.assignAddr()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *USPDevice) Close() error {
|
func (t *TunDevice) Close() error {
|
||||||
if t.configurer != nil {
|
if t.configurer != nil {
|
||||||
t.configurer.Close()
|
t.configurer.Close()
|
||||||
}
|
}
|
||||||
@@ -115,39 +115,39 @@ func (t *USPDevice) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *USPDevice) WgAddress() wgaddr.Address {
|
func (t *TunDevice) WgAddress() wgaddr.Address {
|
||||||
return t.address
|
return t.address
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *USPDevice) MTU() uint16 {
|
func (t *TunDevice) MTU() uint16 {
|
||||||
return t.mtu
|
return t.mtu
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *USPDevice) DeviceName() string {
|
func (t *TunDevice) DeviceName() string {
|
||||||
return t.name
|
return t.name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *USPDevice) FilteredDevice() *FilteredDevice {
|
func (t *TunDevice) FilteredDevice() *FilteredDevice {
|
||||||
return t.filteredDevice
|
return t.filteredDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
// Device returns the wireguard device
|
// Device returns the wireguard device
|
||||||
func (t *USPDevice) Device() *device.Device {
|
func (t *TunDevice) Device() *device.Device {
|
||||||
return t.device
|
return t.device
|
||||||
}
|
}
|
||||||
|
|
||||||
// assignAddr Adds IP address to the tunnel interface
|
// assignAddr Adds IP address to the tunnel interface
|
||||||
func (t *USPDevice) assignAddr() error {
|
func (t *TunDevice) assignAddr() error {
|
||||||
link := newWGLink(t.name)
|
link := newWGLink(t.name)
|
||||||
|
|
||||||
return link.assignAddr(t.address)
|
return link.assignAddr(&t.address)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *USPDevice) GetNet() *netstack.Net {
|
func (t *TunDevice) GetNet() *netstack.Net {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetICEBind returns the ICEBind instance
|
// GetICEBind returns the ICEBind instance
|
||||||
func (t *USPDevice) GetICEBind() EndpointManager {
|
func (t *TunDevice) GetICEBind() EndpointManager {
|
||||||
return t.iceBind
|
return t.iceBind
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,19 @@ func (t *TunDevice) Create() (WGConfigurer, error) {
|
|||||||
err = nbiface.Set()
|
err = nbiface.Set()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.device.Close()
|
t.device.Close()
|
||||||
return nil, fmt.Errorf("got error when getting setting the interface mtu: %s", err)
|
return nil, fmt.Errorf("set IPv4 interface MTU: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.address.HasIPv6() {
|
||||||
|
nbiface6, err := luid.IPInterface(windows.AF_INET6)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to get IPv6 interface for MTU: %v", err)
|
||||||
|
} else {
|
||||||
|
nbiface6.NLMTU = uint32(t.mtu)
|
||||||
|
if err := nbiface6.Set(); err != nil {
|
||||||
|
log.Warnf("failed to set IPv6 interface MTU: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
err = t.assignAddr()
|
err = t.assignAddr()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,8 +190,21 @@ func (t *TunDevice) GetInterfaceGUIDString() (string, error) {
|
|||||||
// assignAddr Adds IP address to the tunnel interface and network route based on the range provided
|
// assignAddr Adds IP address to the tunnel interface and network route based on the range provided
|
||||||
func (t *TunDevice) assignAddr() error {
|
func (t *TunDevice) assignAddr() error {
|
||||||
luid := winipcfg.LUID(t.nativeTunDevice.LUID())
|
luid := winipcfg.LUID(t.nativeTunDevice.LUID())
|
||||||
log.Debugf("adding address %s to interface: %s", t.address.IP, t.name)
|
|
||||||
return luid.SetIPAddresses([]netip.Prefix{netip.MustParsePrefix(t.address.String())})
|
v4Prefix := t.address.Prefix()
|
||||||
|
if t.address.HasIPv6() {
|
||||||
|
v6Prefix := t.address.IPv6Prefix()
|
||||||
|
log.Debugf("adding addresses %s, %s to interface: %s", v4Prefix, v6Prefix, t.name)
|
||||||
|
if err := luid.SetIPAddresses([]netip.Prefix{v4Prefix, v6Prefix}); err != nil {
|
||||||
|
log.Warnf("failed to assign dual-stack addresses, retrying v4-only: %v", err)
|
||||||
|
t.address.ClearIPv6()
|
||||||
|
return luid.SetIPAddresses([]netip.Prefix{v4Prefix})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("adding address %s to interface: %s", v4Prefix, t.name)
|
||||||
|
return luid.SetIPAddresses([]netip.Prefix{v4Prefix})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TunDevice) GetNet() *netstack.Net {
|
func (t *TunDevice) GetNet() *netstack.Net {
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
//go:build (!linux && !freebsd) || android
|
|
||||||
|
|
||||||
package device
|
|
||||||
|
|
||||||
// WireGuardModuleIsLoaded check if we can load WireGuard mod (linux only)
|
|
||||||
func WireGuardModuleIsLoaded() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package device
|
|
||||||
|
|
||||||
// WireGuardModuleIsLoaded check if kernel support wireguard
|
|
||||||
func WireGuardModuleIsLoaded() bool {
|
|
||||||
// Despite the fact FreeBSD natively support Wireguard (https://github.com/WireGuard/wireguard-freebsd)
|
|
||||||
// we are currently do not use it, since it is required to add wireguard kernel support to
|
|
||||||
// - https://github.com/netbirdio/netbird/tree/main/sharedsock
|
|
||||||
// - https://github.com/mdlayher/socket
|
|
||||||
// TODO: implement kernel space
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModuleTunIsLoaded check if tun module exist, if is not attempt to load it
|
|
||||||
func ModuleTunIsLoaded() bool {
|
|
||||||
// Assume tun supported by freebsd kernel by default
|
|
||||||
// TODO: implement check for module loaded in kernel or build-it
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
13
client/iface/device/kernel_module_nonlinux.go
Normal file
13
client/iface/device/kernel_module_nonlinux.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !linux || android
|
||||||
|
|
||||||
|
package device
|
||||||
|
|
||||||
|
// WireGuardModuleIsLoaded reports whether the kernel WireGuard module is available.
|
||||||
|
func WireGuardModuleIsLoaded() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleTunIsLoaded reports whether the tun device is available.
|
||||||
|
func ModuleTunIsLoaded() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package device
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
@@ -57,32 +58,32 @@ func (l *wgLink) up() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *wgLink) assignAddr(address wgaddr.Address) error {
|
func (l *wgLink) assignAddr(address *wgaddr.Address) error {
|
||||||
link, err := freebsd.LinkByName(l.name)
|
link, err := freebsd.LinkByName(l.name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("link by name: %w", err)
|
return fmt.Errorf("link by name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := address.IP.String()
|
|
||||||
|
|
||||||
// Convert prefix length to hex netmask
|
|
||||||
prefixLen := address.Network.Bits()
|
prefixLen := address.Network.Bits()
|
||||||
if !address.IP.Is4() {
|
|
||||||
return fmt.Errorf("IPv6 not supported for interface assignment")
|
|
||||||
}
|
|
||||||
|
|
||||||
maskBits := uint32(0xffffffff) << (32 - prefixLen)
|
maskBits := uint32(0xffffffff) << (32 - prefixLen)
|
||||||
mask := fmt.Sprintf("0x%08x", maskBits)
|
mask := fmt.Sprintf("0x%08x", maskBits)
|
||||||
|
|
||||||
log.Infof("assign addr %s mask %s to %s interface", ip, mask, l.name)
|
log.Infof("assign addr %s mask %s to %s interface", address.IP, mask, l.name)
|
||||||
|
|
||||||
err = link.AssignAddr(ip, mask)
|
if err := link.AssignAddr(address.IP.String(), mask); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("assign addr: %w", err)
|
return fmt.Errorf("assign addr: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = link.Up()
|
if address.HasIPv6() {
|
||||||
if err != nil {
|
log.Infof("assign IPv6 addr %s to %s interface", address.IPv6String(), l.name)
|
||||||
|
cmd := exec.Command("ifconfig", l.name, "inet6", address.IPv6String())
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
log.Warnf("failed to assign IPv6 address %s to %s, continuing v4-only: %s: %v", address.IPv6String(), l.name, string(out), err)
|
||||||
|
address.ClearIPv6()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := link.Up(); err != nil {
|
||||||
return fmt.Errorf("up: %w", err)
|
return fmt.Errorf("up: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ package device
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -92,7 +94,7 @@ func (l *wgLink) up() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *wgLink) assignAddr(address wgaddr.Address) error {
|
func (l *wgLink) assignAddr(address *wgaddr.Address) error {
|
||||||
//delete existing addresses
|
//delete existing addresses
|
||||||
list, err := netlink.AddrList(l, 0)
|
list, err := netlink.AddrList(l, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,20 +112,16 @@ func (l *wgLink) assignAddr(address wgaddr.Address) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
name := l.attrs.Name
|
name := l.attrs.Name
|
||||||
addrStr := address.String()
|
|
||||||
|
|
||||||
log.Debugf("adding address %s to interface: %s", addrStr, name)
|
if err := l.addAddr(name, address.Prefix()); err != nil {
|
||||||
|
return err
|
||||||
addr, err := netlink.ParseAddr(addrStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse addr: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = netlink.AddrAdd(l, addr)
|
if address.HasIPv6() {
|
||||||
if os.IsExist(err) {
|
if err := l.addAddr(name, address.IPv6Prefix()); err != nil {
|
||||||
log.Infof("interface %s already has the address: %s", name, addrStr)
|
log.Warnf("failed to assign IPv6 address %s to %s, continuing v4-only: %v", address.IPv6Prefix(), name, err)
|
||||||
} else if err != nil {
|
address.ClearIPv6()
|
||||||
return fmt.Errorf("add addr: %w", err)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// On linux, the link must be brought up
|
// On linux, the link must be brought up
|
||||||
@@ -133,3 +131,22 @@ func (l *wgLink) assignAddr(address wgaddr.Address) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *wgLink) addAddr(ifaceName string, prefix netip.Prefix) error {
|
||||||
|
log.Debugf("adding address %s to interface: %s", prefix, ifaceName)
|
||||||
|
|
||||||
|
addr := &netlink.Addr{
|
||||||
|
IPNet: &net.IPNet{
|
||||||
|
IP: prefix.Addr().AsSlice(),
|
||||||
|
Mask: net.CIDRMask(prefix.Bits(), prefix.Addr().BitLen()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := netlink.AddrAdd(l, addr); os.IsExist(err) {
|
||||||
|
log.Infof("interface %s already has the address: %s", ifaceName, prefix)
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("add addr %s: %w", prefix, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ type wgProxyFactory interface {
|
|||||||
|
|
||||||
type WGIFaceOpts struct {
|
type WGIFaceOpts struct {
|
||||||
IFaceName string
|
IFaceName string
|
||||||
Address string
|
Address wgaddr.Address
|
||||||
WGPort int
|
WGPort int
|
||||||
WGPrivKey string
|
WGPrivKey string
|
||||||
MTU uint16
|
MTU uint16
|
||||||
@@ -141,16 +141,11 @@ func (w *WGIface) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAddr updates address of the interface
|
// UpdateAddr updates address of the interface
|
||||||
func (w *WGIface) UpdateAddr(newAddr string) error {
|
func (w *WGIface) UpdateAddr(newAddr wgaddr.Address) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
addr, err := wgaddr.ParseWGAddress(newAddr)
|
return w.tun.UpdateAddr(newAddr)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return w.tun.UpdateAddr(addr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePeer updates existing Wireguard Peer or creates a new one if doesn't exist
|
// UpdatePeer updates existing Wireguard Peer or creates a new one if doesn't exist
|
||||||
|
|||||||
@@ -1,33 +1,28 @@
|
|||||||
|
//go:build !linux && !ios && !android && !js
|
||||||
|
|
||||||
package iface
|
package iface
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/netbirdio/netbird/client/iface/bind"
|
"github.com/netbirdio/netbird/client/iface/bind"
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
wgaddr "github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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) {
|
||||||
wgAddress, err := wgaddr.ParseWGAddress(opts.Address)
|
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU)
|
|
||||||
|
|
||||||
var tun WGTunDevice
|
var tun WGTunDevice
|
||||||
if netstack.IsEnabled() {
|
if netstack.IsEnabled() {
|
||||||
tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, 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())
|
||||||
} else {
|
} else {
|
||||||
tun = device.NewTunDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind)
|
tun = device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind)
|
||||||
}
|
}
|
||||||
|
|
||||||
wgIFace := &WGIface{
|
return &WGIface{
|
||||||
userspaceBind: true,
|
userspaceBind: true,
|
||||||
tun: tun,
|
tun: tun,
|
||||||
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
||||||
}
|
}, nil
|
||||||
return wgIFace, nil
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -4,23 +4,17 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/iface/bind"
|
"github.com/netbirdio/netbird/client/iface/bind"
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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) {
|
||||||
wgAddress, err := wgaddr.ParseWGAddress(opts.Address)
|
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU)
|
|
||||||
|
|
||||||
if netstack.IsEnabled() {
|
if netstack.IsEnabled() {
|
||||||
wgIFace := &WGIface{
|
wgIFace := &WGIface{
|
||||||
userspaceBind: true,
|
userspaceBind: true,
|
||||||
tun: device.NewNetstackDevice(opts.IFaceName, wgAddress, 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()),
|
||||||
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
||||||
}
|
}
|
||||||
return wgIFace, nil
|
return wgIFace, nil
|
||||||
@@ -28,7 +22,7 @@ func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
|||||||
|
|
||||||
wgIFace := &WGIface{
|
wgIFace := &WGIface{
|
||||||
userspaceBind: true,
|
userspaceBind: true,
|
||||||
tun: device.NewTunDevice(wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunAdapter, opts.DisableDNS),
|
tun: device.NewTunDevice(opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunAdapter, opts.DisableDNS),
|
||||||
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
||||||
}
|
}
|
||||||
return wgIFace, nil
|
return wgIFace, nil
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
//go:build !ios
|
|
||||||
|
|
||||||
package iface
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/netbirdio/netbird/client/iface/bind"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewWGIFace Creates a new WireGuard interface instance
|
|
||||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
|
||||||
wgAddress, err := wgaddr.ParseWGAddress(opts.Address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU)
|
|
||||||
|
|
||||||
var tun WGTunDevice
|
|
||||||
if netstack.IsEnabled() {
|
|
||||||
tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr())
|
|
||||||
} else {
|
|
||||||
tun = device.NewTunDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind)
|
|
||||||
}
|
|
||||||
|
|
||||||
wgIFace := &WGIface{
|
|
||||||
userspaceBind: true,
|
|
||||||
tun: tun,
|
|
||||||
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
|
||||||
}
|
|
||||||
return wgIFace, nil
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
//go:build freebsd
|
|
||||||
|
|
||||||
package iface
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/bind"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewWGIFace Creates a new WireGuard interface instance
|
|
||||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
|
||||||
wgAddress, err := wgaddr.ParseWGAddress(opts.Address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
wgIFace := &WGIface{}
|
|
||||||
|
|
||||||
if netstack.IsEnabled() {
|
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU)
|
|
||||||
wgIFace.tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr())
|
|
||||||
wgIFace.userspaceBind = true
|
|
||||||
wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU)
|
|
||||||
return wgIFace, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if device.ModuleTunIsLoaded() {
|
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU)
|
|
||||||
wgIFace.tun = device.NewUSPDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind)
|
|
||||||
wgIFace.userspaceBind = true
|
|
||||||
wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU)
|
|
||||||
return wgIFace, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("couldn't check or load tun module")
|
|
||||||
}
|
|
||||||
@@ -5,21 +5,15 @@ package iface
|
|||||||
import (
|
import (
|
||||||
"github.com/netbirdio/netbird/client/iface/bind"
|
"github.com/netbirdio/netbird/client/iface/bind"
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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) {
|
||||||
wgAddress, err := wgaddr.ParseWGAddress(opts.Address)
|
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU)
|
|
||||||
|
|
||||||
wgIFace := &WGIface{
|
wgIFace := &WGIface{
|
||||||
tun: device.NewTunDevice(opts.IFaceName, wgAddress, 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),
|
||||||
userspaceBind: true,
|
userspaceBind: true,
|
||||||
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,15 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/iface/bind"
|
"github.com/netbirdio/netbird/client/iface/bind"
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewWGIFace creates a new WireGuard interface for WASM (always uses netstack mode)
|
// NewWGIFace creates a new WireGuard interface for WASM (always uses netstack mode)
|
||||||
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
|
||||||
wgAddress, err := wgaddr.ParseWGAddress(opts.Address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
relayBind := bind.NewRelayBindJS()
|
relayBind := bind.NewRelayBindJS()
|
||||||
|
|
||||||
wgIface := &WGIface{
|
wgIface := &WGIface{
|
||||||
tun: device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, relayBind, netstack.ListenAddr()),
|
tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, relayBind, netstack.ListenAddr()),
|
||||||
userspaceBind: true,
|
userspaceBind: true,
|
||||||
wgProxyFactory: wgproxy.NewUSPFactory(relayBind, opts.MTU),
|
wgProxyFactory: wgproxy.NewUSPFactory(relayBind, opts.MTU),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,44 +3,40 @@
|
|||||||
package iface
|
package iface
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/bind"
|
"github.com/netbirdio/netbird/client/iface/bind"
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
||||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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) {
|
||||||
wgAddress, err := wgaddr.ParseWGAddress(opts.Address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
wgIFace := &WGIface{}
|
|
||||||
|
|
||||||
if netstack.IsEnabled() {
|
if netstack.IsEnabled() {
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU)
|
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
||||||
wgIFace.tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr())
|
return &WGIface{
|
||||||
wgIFace.userspaceBind = true
|
tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()),
|
||||||
wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU)
|
userspaceBind: true,
|
||||||
return wgIFace, nil
|
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if device.WireGuardModuleIsLoaded() {
|
if device.WireGuardModuleIsLoaded() {
|
||||||
wgIFace.tun = device.NewKernelDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, opts.TransportNet)
|
return &WGIface{
|
||||||
wgIFace.wgProxyFactory = wgproxy.NewKernelFactory(opts.WGPort, opts.MTU)
|
tun: device.NewKernelDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, opts.TransportNet),
|
||||||
return wgIFace, nil
|
wgProxyFactory: wgproxy.NewKernelFactory(opts.WGPort, opts.MTU),
|
||||||
}
|
}, nil
|
||||||
if device.ModuleTunIsLoaded() {
|
|
||||||
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU)
|
|
||||||
wgIFace.tun = device.NewUSPDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind)
|
|
||||||
wgIFace.userspaceBind = true
|
|
||||||
wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU)
|
|
||||||
return wgIFace, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("couldn't check or load tun module")
|
if device.ModuleTunIsLoaded() {
|
||||||
|
iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
|
||||||
|
return &WGIface{
|
||||||
|
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind),
|
||||||
|
userspaceBind: true,
|
||||||
|
wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("tun module not available")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ func TestWGIface_UpdateAddr(t *testing.T) {
|
|||||||
|
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
Address: addr,
|
Address: wgaddr.MustParseWGAddress(addr),
|
||||||
WGPort: wgPort,
|
WGPort: wgPort,
|
||||||
WGPrivKey: key,
|
WGPrivKey: key,
|
||||||
MTU: DefaultMTU,
|
MTU: DefaultMTU,
|
||||||
@@ -84,7 +85,7 @@ func TestWGIface_UpdateAddr(t *testing.T) {
|
|||||||
|
|
||||||
//update WireGuard address
|
//update WireGuard address
|
||||||
addr = "100.64.0.2/8"
|
addr = "100.64.0.2/8"
|
||||||
err = iface.UpdateAddr(addr)
|
err = iface.UpdateAddr(wgaddr.MustParseWGAddress(addr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -130,7 +131,7 @@ func Test_CreateInterface(t *testing.T) {
|
|||||||
}
|
}
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
Address: wgIP,
|
Address: wgaddr.MustParseWGAddress(wgIP),
|
||||||
WGPort: 33100,
|
WGPort: 33100,
|
||||||
WGPrivKey: key,
|
WGPrivKey: key,
|
||||||
MTU: DefaultMTU,
|
MTU: DefaultMTU,
|
||||||
@@ -174,7 +175,7 @@ func Test_Close(t *testing.T) {
|
|||||||
|
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
Address: wgIP,
|
Address: wgaddr.MustParseWGAddress(wgIP),
|
||||||
WGPort: wgPort,
|
WGPort: wgPort,
|
||||||
WGPrivKey: key,
|
WGPrivKey: key,
|
||||||
MTU: DefaultMTU,
|
MTU: DefaultMTU,
|
||||||
@@ -219,7 +220,7 @@ func TestRecreation(t *testing.T) {
|
|||||||
|
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
Address: wgIP,
|
Address: wgaddr.MustParseWGAddress(wgIP),
|
||||||
WGPort: wgPort,
|
WGPort: wgPort,
|
||||||
WGPrivKey: key,
|
WGPrivKey: key,
|
||||||
MTU: DefaultMTU,
|
MTU: DefaultMTU,
|
||||||
@@ -291,7 +292,7 @@ func Test_ConfigureInterface(t *testing.T) {
|
|||||||
}
|
}
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
Address: wgIP,
|
Address: wgaddr.MustParseWGAddress(wgIP),
|
||||||
WGPort: wgPort,
|
WGPort: wgPort,
|
||||||
WGPrivKey: key,
|
WGPrivKey: key,
|
||||||
MTU: DefaultMTU,
|
MTU: DefaultMTU,
|
||||||
@@ -347,7 +348,7 @@ func Test_UpdatePeer(t *testing.T) {
|
|||||||
|
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
Address: wgIP,
|
Address: wgaddr.MustParseWGAddress(wgIP),
|
||||||
WGPort: 33100,
|
WGPort: 33100,
|
||||||
WGPrivKey: key,
|
WGPrivKey: key,
|
||||||
MTU: DefaultMTU,
|
MTU: DefaultMTU,
|
||||||
@@ -417,7 +418,7 @@ func Test_RemovePeer(t *testing.T) {
|
|||||||
|
|
||||||
opts := WGIFaceOpts{
|
opts := WGIFaceOpts{
|
||||||
IFaceName: ifaceName,
|
IFaceName: ifaceName,
|
||||||
Address: wgIP,
|
Address: wgaddr.MustParseWGAddress(wgIP),
|
||||||
WGPort: 33100,
|
WGPort: 33100,
|
||||||
WGPrivKey: key,
|
WGPrivKey: key,
|
||||||
MTU: DefaultMTU,
|
MTU: DefaultMTU,
|
||||||
@@ -482,7 +483,7 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
|
|
||||||
optsPeer1 := WGIFaceOpts{
|
optsPeer1 := WGIFaceOpts{
|
||||||
IFaceName: peer1ifaceName,
|
IFaceName: peer1ifaceName,
|
||||||
Address: peer1wgIP.String(),
|
Address: wgaddr.MustParseWGAddress(peer1wgIP.String()),
|
||||||
WGPort: peer1wgPort,
|
WGPort: peer1wgPort,
|
||||||
WGPrivKey: peer1Key.String(),
|
WGPrivKey: peer1Key.String(),
|
||||||
MTU: DefaultMTU,
|
MTU: DefaultMTU,
|
||||||
@@ -522,7 +523,7 @@ func Test_ConnectPeers(t *testing.T) {
|
|||||||
|
|
||||||
optsPeer2 := WGIFaceOpts{
|
optsPeer2 := WGIFaceOpts{
|
||||||
IFaceName: peer2ifaceName,
|
IFaceName: peer2ifaceName,
|
||||||
Address: peer2wgIP.String(),
|
Address: wgaddr.MustParseWGAddress(peer2wgIP.String()),
|
||||||
WGPort: peer2wgPort,
|
WGPort: peer2wgPort,
|
||||||
WGPrivKey: peer2Key.String(),
|
WGPrivKey: peer2Key.String(),
|
||||||
MTU: DefaultMTU,
|
MTU: DefaultMTU,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
const EnvSkipProxy = "NB_NETSTACK_SKIP_PROXY"
|
const EnvSkipProxy = "NB_NETSTACK_SKIP_PROXY"
|
||||||
|
|
||||||
type NetStackTun struct { //nolint:revive
|
type NetStackTun struct { //nolint:revive
|
||||||
address netip.Addr
|
addresses []netip.Addr
|
||||||
dnsAddress netip.Addr
|
dnsAddress netip.Addr
|
||||||
mtu int
|
mtu int
|
||||||
listenAddress string
|
listenAddress string
|
||||||
@@ -22,9 +22,9 @@ type NetStackTun struct { //nolint:revive
|
|||||||
tundev tun.Device
|
tundev tun.Device
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNetStackTun(listenAddress string, address netip.Addr, dnsAddress netip.Addr, mtu int) *NetStackTun {
|
func NewNetStackTun(listenAddress string, addresses []netip.Addr, dnsAddress netip.Addr, mtu int) *NetStackTun {
|
||||||
return &NetStackTun{
|
return &NetStackTun{
|
||||||
address: address,
|
addresses: addresses,
|
||||||
dnsAddress: dnsAddress,
|
dnsAddress: dnsAddress,
|
||||||
mtu: mtu,
|
mtu: mtu,
|
||||||
listenAddress: listenAddress,
|
listenAddress: listenAddress,
|
||||||
@@ -33,7 +33,7 @@ func NewNetStackTun(listenAddress string, address netip.Addr, dnsAddress netip.A
|
|||||||
|
|
||||||
func (t *NetStackTun) Create() (tun.Device, *netstack.Net, error) {
|
func (t *NetStackTun) Create() (tun.Device, *netstack.Net, error) {
|
||||||
nsTunDev, tunNet, err := netstack.CreateNetTUN(
|
nsTunDev, tunNet, err := netstack.CreateNetTUN(
|
||||||
[]netip.Addr{t.address},
|
t.addresses,
|
||||||
[]netip.Addr{t.dnsAddress},
|
[]netip.Addr{t.dnsAddress},
|
||||||
t.mtu)
|
t.mtu)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ package wgaddr
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/netiputil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Address WireGuard parsed address
|
// Address WireGuard parsed address
|
||||||
type Address struct {
|
type Address struct {
|
||||||
IP netip.Addr
|
IP netip.Addr
|
||||||
Network netip.Prefix
|
Network netip.Prefix
|
||||||
|
|
||||||
|
// IPv6 overlay address, if assigned.
|
||||||
|
IPv6 netip.Addr
|
||||||
|
IPv6Net netip.Prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseWGAddress parse a string ("1.2.3.4/24") address to WG Address
|
// ParseWGAddress parse a string ("1.2.3.4/24") address to WG Address
|
||||||
@@ -23,6 +29,60 @@ func ParseWGAddress(address string) (Address, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (addr Address) String() string {
|
// HasIPv6 reports whether a v6 overlay address is assigned.
|
||||||
return fmt.Sprintf("%s/%d", addr.IP.String(), addr.Network.Bits())
|
func (addr Address) HasIPv6() bool {
|
||||||
|
return addr.IPv6.IsValid()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (addr Address) String() string {
|
||||||
|
return addr.Prefix().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6String returns the v6 address in CIDR notation, or empty string if none.
|
||||||
|
func (addr Address) IPv6String() string {
|
||||||
|
if !addr.HasIPv6() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return addr.IPv6Prefix().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix returns the v4 host address with its network prefix length (e.g. 100.64.0.1/16).
|
||||||
|
func (addr Address) Prefix() netip.Prefix {
|
||||||
|
return netip.PrefixFrom(addr.IP, addr.Network.Bits())
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6Prefix returns the v6 host address with its network prefix length, or a zero prefix if none.
|
||||||
|
func (addr Address) IPv6Prefix() netip.Prefix {
|
||||||
|
if !addr.HasIPv6() {
|
||||||
|
return netip.Prefix{}
|
||||||
|
}
|
||||||
|
return netip.PrefixFrom(addr.IPv6, addr.IPv6Net.Bits())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIPv6FromCompact decodes a compact prefix (5 or 17 bytes) and sets the IPv6 fields.
|
||||||
|
// Returns an error if the bytes are invalid. A nil or empty input is a no-op.
|
||||||
|
//
|
||||||
|
//nolint:recvcheck
|
||||||
|
func (addr *Address) SetIPv6FromCompact(raw []byte) error {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prefix, err := netiputil.DecodePrefix(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode v6 overlay address: %w", err)
|
||||||
|
}
|
||||||
|
if !prefix.Addr().Is6() {
|
||||||
|
return fmt.Errorf("expected IPv6 address, got %s", prefix.Addr())
|
||||||
|
}
|
||||||
|
addr.IPv6 = prefix.Addr()
|
||||||
|
addr.IPv6Net = prefix.Masked()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearIPv6 removes the IPv6 overlay address, leaving only v4.
|
||||||
|
//
|
||||||
|
//nolint:recvcheck
|
||||||
|
func (addr *Address) ClearIPv6() {
|
||||||
|
addr.IPv6 = netip.Addr{}
|
||||||
|
addr.IPv6Net = netip.Prefix{}
|
||||||
}
|
}
|
||||||
|
|||||||
10
client/iface/wgaddr/address_test_helpers.go
Normal file
10
client/iface/wgaddr/address_test_helpers.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package wgaddr
|
||||||
|
|
||||||
|
// MustParseWGAddress parses and returns a WG Address, panicking on error.
|
||||||
|
func MustParseWGAddress(address string) Address {
|
||||||
|
a, err := ParseWGAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
@@ -216,6 +216,7 @@ func (d *DefaultManager) protoRuleToFirewallRule(
|
|||||||
r *mgmProto.FirewallRule,
|
r *mgmProto.FirewallRule,
|
||||||
ipsetName string,
|
ipsetName string,
|
||||||
) (id.RuleID, []firewall.Rule, error) {
|
) (id.RuleID, []firewall.Rule, error) {
|
||||||
|
//nolint:staticcheck // PeerIP used for backward compatibility with old management
|
||||||
ip := net.ParseIP(r.PeerIP)
|
ip := net.ParseIP(r.PeerIP)
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
return "", nil, fmt.Errorf("invalid IP address, skipping firewall rule")
|
return "", nil, fmt.Errorf("invalid IP address, skipping firewall rule")
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
|||||||
a.config.DisableFirewall,
|
a.config.DisableFirewall,
|
||||||
a.config.BlockLANAccess,
|
a.config.BlockLANAccess,
|
||||||
a.config.BlockInbound,
|
a.config.BlockInbound,
|
||||||
|
a.config.DisableIPv6,
|
||||||
a.config.LazyConnectionEnabled,
|
a.config.LazyConnectionEnabled,
|
||||||
a.config.EnableSSHRoot,
|
a.config.EnableSSHRoot,
|
||||||
a.config.EnableSSHSFTP,
|
a.config.EnableSSHSFTP,
|
||||||
|
|||||||
@@ -14,21 +14,25 @@ import (
|
|||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
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"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
"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/metrics"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
"github.com/netbirdio/netbird/client/internal/updatemanager"
|
"github.com/netbirdio/netbird/client/internal/updater"
|
||||||
"github.com/netbirdio/netbird/client/internal/updatemanager/installer"
|
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||||
nbnet "github.com/netbirdio/netbird/client/net"
|
nbnet "github.com/netbirdio/netbird/client/net"
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
"github.com/netbirdio/netbird/client/ssh"
|
"github.com/netbirdio/netbird/client/ssh"
|
||||||
@@ -44,13 +48,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ConnectClient struct {
|
type ConnectClient struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
doInitialAutoUpdate bool
|
|
||||||
|
|
||||||
engine *Engine
|
engine *Engine
|
||||||
engineMutex sync.Mutex
|
engineMutex sync.Mutex
|
||||||
|
clientMetrics *metrics.ClientMetrics
|
||||||
|
updateManager *updater.Manager
|
||||||
|
|
||||||
persistSyncResponse bool
|
persistSyncResponse bool
|
||||||
}
|
}
|
||||||
@@ -59,17 +64,19 @@ func NewConnectClient(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
config *profilemanager.Config,
|
config *profilemanager.Config,
|
||||||
statusRecorder *peer.Status,
|
statusRecorder *peer.Status,
|
||||||
doInitalAutoUpdate bool,
|
|
||||||
) *ConnectClient {
|
) *ConnectClient {
|
||||||
return &ConnectClient{
|
return &ConnectClient{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
config: config,
|
config: config,
|
||||||
statusRecorder: statusRecorder,
|
statusRecorder: statusRecorder,
|
||||||
doInitialAutoUpdate: doInitalAutoUpdate,
|
engineMutex: sync.Mutex{},
|
||||||
engineMutex: sync.Mutex{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
||||||
|
c.updateManager = um
|
||||||
|
}
|
||||||
|
|
||||||
// Run with main logic.
|
// Run with main logic.
|
||||||
func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error {
|
func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error {
|
||||||
return c.run(MobileDependency{}, runningChan, logPath)
|
return c.run(MobileDependency{}, runningChan, logPath)
|
||||||
@@ -131,10 +138,34 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Stop metrics push on exit
|
||||||
|
defer func() {
|
||||||
|
if c.clientMetrics != nil {
|
||||||
|
c.clientMetrics.StopPush()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
log.Infof("starting NetBird client version %s on %s/%s", version.NetbirdVersion(), runtime.GOOS, runtime.GOARCH)
|
log.Infof("starting NetBird client version %s on %s/%s", version.NetbirdVersion(), runtime.GOOS, runtime.GOARCH)
|
||||||
|
|
||||||
nbnet.Init()
|
nbnet.Init()
|
||||||
|
|
||||||
|
// Initialize metrics once at startup (always active for debug bundles)
|
||||||
|
if c.clientMetrics == nil {
|
||||||
|
agentInfo := metrics.AgentInfo{
|
||||||
|
DeploymentType: metrics.DeploymentTypeUnknown,
|
||||||
|
Version: version.NetbirdVersion(),
|
||||||
|
OS: runtime.GOOS,
|
||||||
|
Arch: runtime.GOARCH,
|
||||||
|
}
|
||||||
|
c.clientMetrics = metrics.NewClientMetrics(agentInfo)
|
||||||
|
log.Debugf("initialized client metrics")
|
||||||
|
|
||||||
|
// Start metrics push if enabled (uses daemon context, persists across engine restarts)
|
||||||
|
if metrics.IsMetricsPushEnabled() {
|
||||||
|
c.clientMetrics.StartPush(c.ctx, metrics.PushConfigFromEnv())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
backOff := &backoff.ExponentialBackOff{
|
backOff := &backoff.ExponentialBackOff{
|
||||||
InitialInterval: time.Second,
|
InitialInterval: time.Second,
|
||||||
RandomizationFactor: 1,
|
RandomizationFactor: 1,
|
||||||
@@ -187,14 +218,13 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
stateManager := statemanager.New(path)
|
stateManager := statemanager.New(path)
|
||||||
stateManager.RegisterState(&sshconfig.ShutdownState{})
|
stateManager.RegisterState(&sshconfig.ShutdownState{})
|
||||||
|
|
||||||
updateManager, err := updatemanager.NewManager(c.statusRecorder, stateManager)
|
if c.updateManager != nil {
|
||||||
if err == nil {
|
c.updateManager.CheckUpdateSuccess(c.ctx)
|
||||||
updateManager.CheckUpdateSuccess(c.ctx)
|
}
|
||||||
|
|
||||||
inst := installer.New()
|
inst := installer.New()
|
||||||
if err := inst.CleanUpInstallerFiles(); err != nil {
|
if err := inst.CleanUpInstallerFiles(); err != nil {
|
||||||
log.Errorf("failed to clean up temporary installer file: %v", err)
|
log.Errorf("failed to clean up temporary installer file: %v", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defer c.statusRecorder.ClientStop()
|
defer c.statusRecorder.ClientStop()
|
||||||
@@ -222,6 +252,16 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
|
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
|
||||||
mgmClient.SetConnStateListener(mgmNotifier)
|
mgmClient.SetConnStateListener(mgmNotifier)
|
||||||
|
|
||||||
|
// Update metrics with actual deployment type after connection
|
||||||
|
deploymentType := metrics.DetermineDeploymentType(mgmClient.GetServerURL())
|
||||||
|
agentInfo := metrics.AgentInfo{
|
||||||
|
DeploymentType: deploymentType,
|
||||||
|
Version: version.NetbirdVersion(),
|
||||||
|
OS: runtime.GOOS,
|
||||||
|
Arch: runtime.GOARCH,
|
||||||
|
}
|
||||||
|
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", c.config.ManagementURL.Host)
|
||||||
defer func() {
|
defer func() {
|
||||||
if err = mgmClient.Close(); err != nil {
|
if err = mgmClient.Close(); err != nil {
|
||||||
@@ -230,8 +270,10 @@ 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()
|
||||||
loginResp, err := loginToManagement(engineCtx, mgmClient, publicSSHKey, c.config)
|
loginResp, err := loginToManagement(engineCtx, mgmClient, publicSSHKey, c.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
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)
|
||||||
@@ -240,6 +282,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}
|
}
|
||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
|
c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), true)
|
||||||
c.statusRecorder.MarkManagementConnected()
|
c.statusRecorder.MarkManagementConnected()
|
||||||
|
|
||||||
localPeerState := peer.LocalPeerState{
|
localPeerState := peer.LocalPeerState{
|
||||||
@@ -308,7 +351,16 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
checks := loginResp.GetChecks()
|
checks := loginResp.GetChecks()
|
||||||
|
|
||||||
c.engineMutex.Lock()
|
c.engineMutex.Lock()
|
||||||
engine := NewEngine(engineCtx, cancel, signalClient, mgmClient, relayManager, engineConfig, mobileDependency, c.statusRecorder, checks, stateManager)
|
engine := NewEngine(engineCtx, cancel, engineConfig, EngineServices{
|
||||||
|
SignalClient: signalClient,
|
||||||
|
MgmClient: mgmClient,
|
||||||
|
RelayManager: relayManager,
|
||||||
|
StatusRecorder: c.statusRecorder,
|
||||||
|
Checks: checks,
|
||||||
|
StateManager: stateManager,
|
||||||
|
UpdateManager: c.updateManager,
|
||||||
|
ClientMetrics: c.clientMetrics,
|
||||||
|
}, mobileDependency)
|
||||||
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
||||||
c.engine = engine
|
c.engine = engine
|
||||||
c.engineMutex.Unlock()
|
c.engineMutex.Unlock()
|
||||||
@@ -318,21 +370,15 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if loginResp.PeerConfig != nil && loginResp.PeerConfig.AutoUpdate != nil {
|
|
||||||
// AutoUpdate will be true when the user click on "Connect" menu on the UI
|
|
||||||
if c.doInitialAutoUpdate {
|
|
||||||
log.Infof("start engine by ui, run auto-update check")
|
|
||||||
c.engine.InitialUpdateHandling(loginResp.PeerConfig.AutoUpdate)
|
|
||||||
c.doInitialAutoUpdate = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if runningChan != nil {
|
||||||
close(runningChan)
|
select {
|
||||||
runningChan = nil
|
case <-runningChan:
|
||||||
|
default:
|
||||||
|
close(runningChan)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<-engineCtx.Done()
|
<-engineCtx.Done()
|
||||||
@@ -477,9 +523,20 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
|||||||
if config.NetworkMonitor != nil {
|
if config.NetworkMonitor != nil {
|
||||||
nm = *config.NetworkMonitor
|
nm = *config.NetworkMonitor
|
||||||
}
|
}
|
||||||
|
wgAddr, err := wgaddr.ParseWGAddress(peerConfig.Address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse overlay address %q: %w", peerConfig.Address, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.DisableIPv6 {
|
||||||
|
if err := wgAddr.SetIPv6FromCompact(peerConfig.GetAddressV6()); err != nil {
|
||||||
|
log.Warn(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
engineConf := &EngineConfig{
|
engineConf := &EngineConfig{
|
||||||
WgIfaceName: config.WgIface,
|
WgIfaceName: config.WgIface,
|
||||||
WgAddr: peerConfig.Address,
|
WgAddr: wgAddr,
|
||||||
IFaceBlackList: config.IFaceBlackList,
|
IFaceBlackList: config.IFaceBlackList,
|
||||||
DisableIPv6Discovery: config.DisableIPv6Discovery,
|
DisableIPv6Discovery: config.DisableIPv6Discovery,
|
||||||
WgPrivateKey: key,
|
WgPrivateKey: key,
|
||||||
@@ -504,6 +561,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
|||||||
DisableFirewall: config.DisableFirewall,
|
DisableFirewall: config.DisableFirewall,
|
||||||
BlockLANAccess: config.BlockLANAccess,
|
BlockLANAccess: config.BlockLANAccess,
|
||||||
BlockInbound: config.BlockInbound,
|
BlockInbound: config.BlockInbound,
|
||||||
|
DisableIPv6: config.DisableIPv6,
|
||||||
|
|
||||||
LazyConnectionEnabled: config.LazyConnectionEnabled,
|
LazyConnectionEnabled: config.LazyConnectionEnabled,
|
||||||
|
|
||||||
@@ -584,6 +642,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
|||||||
config.DisableFirewall,
|
config.DisableFirewall,
|
||||||
config.BlockLANAccess,
|
config.BlockLANAccess,
|
||||||
config.BlockInbound,
|
config.BlockInbound,
|
||||||
|
config.DisableIPv6,
|
||||||
config.LazyConnectionEnabled,
|
config.LazyConnectionEnabled,
|
||||||
config.EnableSSHRoot,
|
config.EnableSSHRoot,
|
||||||
config.EnableSSHSFTP,
|
config.EnableSSHSFTP,
|
||||||
|
|||||||
60
client/internal/daemonaddr/resolve.go
Normal file
60
client/internal/daemonaddr/resolve.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
//go:build !windows && !ios && !android
|
||||||
|
|
||||||
|
package daemonaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var scanDir = "/var/run/netbird"
|
||||||
|
|
||||||
|
// setScanDir overrides the scan directory (used by tests).
|
||||||
|
func setScanDir(dir string) {
|
||||||
|
scanDir = dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveUnixDaemonAddr checks whether the default Unix socket exists and, if not,
|
||||||
|
// scans /var/run/netbird/ for a single .sock file to use instead. This handles the
|
||||||
|
// mismatch between the netbird@.service template (which places the socket under
|
||||||
|
// /var/run/netbird/<instance>.sock) and the CLI default (/var/run/netbird.sock).
|
||||||
|
func ResolveUnixDaemonAddr(addr string) string {
|
||||||
|
if !strings.HasPrefix(addr, "unix://") {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
sockPath := strings.TrimPrefix(addr, "unix://")
|
||||||
|
if _, err := os.Stat(sockPath); err == nil {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(scanDir)
|
||||||
|
if err != nil {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
var found []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(e.Name(), ".sock") {
|
||||||
|
found = append(found, filepath.Join(scanDir, e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(found) {
|
||||||
|
case 1:
|
||||||
|
resolved := "unix://" + found[0]
|
||||||
|
log.Debugf("Default daemon socket not found, using discovered socket: %s", resolved)
|
||||||
|
return resolved
|
||||||
|
case 0:
|
||||||
|
return addr
|
||||||
|
default:
|
||||||
|
log.Warnf("Default daemon socket not found and multiple sockets discovered in %s; pass --daemon-addr explicitly", scanDir)
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
}
|
||||||
8
client/internal/daemonaddr/resolve_stub.go
Normal file
8
client/internal/daemonaddr/resolve_stub.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build windows || ios || android
|
||||||
|
|
||||||
|
package daemonaddr
|
||||||
|
|
||||||
|
// ResolveUnixDaemonAddr is a no-op on platforms that don't use Unix sockets.
|
||||||
|
func ResolveUnixDaemonAddr(addr string) string {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
121
client/internal/daemonaddr/resolve_test.go
Normal file
121
client/internal/daemonaddr/resolve_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
//go:build !windows && !ios && !android
|
||||||
|
|
||||||
|
package daemonaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createSockFile creates a regular file with a .sock extension.
|
||||||
|
// ResolveUnixDaemonAddr uses os.Stat (not net.Dial), so a regular file is
|
||||||
|
// sufficient and avoids Unix socket path-length limits on macOS.
|
||||||
|
func createSockFile(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to create test sock file at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUnixDaemonAddr_DefaultExists(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
sock := filepath.Join(tmp, "netbird.sock")
|
||||||
|
createSockFile(t, sock)
|
||||||
|
|
||||||
|
addr := "unix://" + sock
|
||||||
|
got := ResolveUnixDaemonAddr(addr)
|
||||||
|
if got != addr {
|
||||||
|
t.Errorf("expected %s, got %s", addr, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUnixDaemonAddr_SingleDiscovered(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
// Default socket does not exist
|
||||||
|
defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock")
|
||||||
|
|
||||||
|
// Create a scan dir with one socket
|
||||||
|
sd := filepath.Join(tmp, "netbird")
|
||||||
|
if err := os.MkdirAll(sd, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
instanceSock := filepath.Join(sd, "main.sock")
|
||||||
|
createSockFile(t, instanceSock)
|
||||||
|
|
||||||
|
origScanDir := scanDir
|
||||||
|
setScanDir(sd)
|
||||||
|
t.Cleanup(func() { setScanDir(origScanDir) })
|
||||||
|
|
||||||
|
got := ResolveUnixDaemonAddr(defaultAddr)
|
||||||
|
expected := "unix://" + instanceSock
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUnixDaemonAddr_MultipleDiscovered(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock")
|
||||||
|
|
||||||
|
sd := filepath.Join(tmp, "netbird")
|
||||||
|
if err := os.MkdirAll(sd, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
createSockFile(t, filepath.Join(sd, "main.sock"))
|
||||||
|
createSockFile(t, filepath.Join(sd, "other.sock"))
|
||||||
|
|
||||||
|
origScanDir := scanDir
|
||||||
|
setScanDir(sd)
|
||||||
|
t.Cleanup(func() { setScanDir(origScanDir) })
|
||||||
|
|
||||||
|
got := ResolveUnixDaemonAddr(defaultAddr)
|
||||||
|
if got != defaultAddr {
|
||||||
|
t.Errorf("expected original %s, got %s", defaultAddr, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUnixDaemonAddr_NoSocketsFound(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock")
|
||||||
|
|
||||||
|
sd := filepath.Join(tmp, "netbird")
|
||||||
|
if err := os.MkdirAll(sd, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origScanDir := scanDir
|
||||||
|
setScanDir(sd)
|
||||||
|
t.Cleanup(func() { setScanDir(origScanDir) })
|
||||||
|
|
||||||
|
got := ResolveUnixDaemonAddr(defaultAddr)
|
||||||
|
if got != defaultAddr {
|
||||||
|
t.Errorf("expected original %s, got %s", defaultAddr, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUnixDaemonAddr_NonUnixAddr(t *testing.T) {
|
||||||
|
addr := "tcp://127.0.0.1:41731"
|
||||||
|
got := ResolveUnixDaemonAddr(addr)
|
||||||
|
if got != addr {
|
||||||
|
t.Errorf("expected %s, got %s", addr, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUnixDaemonAddr_ScanDirMissing(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock")
|
||||||
|
|
||||||
|
origScanDir := scanDir
|
||||||
|
setScanDir(filepath.Join(tmp, "nonexistent"))
|
||||||
|
t.Cleanup(func() { setScanDir(origScanDir) })
|
||||||
|
|
||||||
|
got := ResolveUnixDaemonAddr(defaultAddr)
|
||||||
|
if got != defaultAddr {
|
||||||
|
t.Errorf("expected original %s, got %s", defaultAddr, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,11 +27,10 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/anonymize"
|
"github.com/netbirdio/netbird/client/anonymize"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/updatemanager/installer"
|
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||||
nbstatus "github.com/netbirdio/netbird/client/status"
|
nbstatus "github.com/netbirdio/netbird/client/status"
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
"github.com/netbirdio/netbird/version"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const readmeContent = `Netbird debug bundle
|
const readmeContent = `Netbird debug bundle
|
||||||
@@ -53,6 +52,7 @@ resolved_domains.txt: Anonymized resolved domain IP addresses from the status re
|
|||||||
config.txt: Anonymized configuration information of the NetBird client.
|
config.txt: Anonymized configuration information of the NetBird client.
|
||||||
network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules.
|
network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules.
|
||||||
state.json: Anonymized client state dump containing netbird states for the active profile.
|
state.json: Anonymized client state dump containing netbird states for the active profile.
|
||||||
|
metrics.txt: Buffered client metrics in InfluxDB line protocol format. Only present when metrics collection is enabled. Peer identifiers are anonymized.
|
||||||
mutex.prof: Mutex profiling information.
|
mutex.prof: Mutex profiling information.
|
||||||
goroutine.prof: Goroutine profiling information.
|
goroutine.prof: Goroutine profiling information.
|
||||||
block.prof: Block profiling information.
|
block.prof: Block profiling information.
|
||||||
@@ -219,6 +219,11 @@ const (
|
|||||||
darwinStdoutLogPath = "/var/log/netbird.err.log"
|
darwinStdoutLogPath = "/var/log/netbird.err.log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MetricsExporter is an interface for exporting metrics
|
||||||
|
type MetricsExporter interface {
|
||||||
|
Export(w io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
type BundleGenerator struct {
|
type BundleGenerator struct {
|
||||||
anonymizer *anonymize.Anonymizer
|
anonymizer *anonymize.Anonymizer
|
||||||
|
|
||||||
@@ -229,6 +234,7 @@ type BundleGenerator struct {
|
|||||||
logPath string
|
logPath string
|
||||||
cpuProfile []byte
|
cpuProfile []byte
|
||||||
refreshStatus func() // Optional callback to refresh status before bundle generation
|
refreshStatus func() // Optional callback to refresh status before bundle generation
|
||||||
|
clientMetrics MetricsExporter
|
||||||
|
|
||||||
anonymize bool
|
anonymize bool
|
||||||
includeSystemInfo bool
|
includeSystemInfo bool
|
||||||
@@ -250,6 +256,7 @@ type GeneratorDependencies struct {
|
|||||||
LogPath string
|
LogPath string
|
||||||
CPUProfile []byte
|
CPUProfile []byte
|
||||||
RefreshStatus func() // Optional callback to refresh status before bundle generation
|
RefreshStatus func() // Optional callback to refresh status before bundle generation
|
||||||
|
ClientMetrics MetricsExporter
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
|
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
|
||||||
@@ -268,6 +275,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
|||||||
logPath: deps.LogPath,
|
logPath: deps.LogPath,
|
||||||
cpuProfile: deps.CPUProfile,
|
cpuProfile: deps.CPUProfile,
|
||||||
refreshStatus: deps.RefreshStatus,
|
refreshStatus: deps.RefreshStatus,
|
||||||
|
clientMetrics: deps.ClientMetrics,
|
||||||
|
|
||||||
anonymize: cfg.Anonymize,
|
anonymize: cfg.Anonymize,
|
||||||
includeSystemInfo: cfg.IncludeSystemInfo,
|
includeSystemInfo: cfg.IncludeSystemInfo,
|
||||||
@@ -351,6 +359,10 @@ func (g *BundleGenerator) createArchive() error {
|
|||||||
log.Errorf("failed to add corrupted state files to debug bundle: %v", err)
|
log.Errorf("failed to add corrupted state files to debug bundle: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := g.addMetrics(); err != nil {
|
||||||
|
log.Errorf("failed to add metrics to debug bundle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := g.addWgShow(); err != nil {
|
if err := g.addWgShow(); err != nil {
|
||||||
log.Errorf("failed to add wg show output: %v", err)
|
log.Errorf("failed to add wg show output: %v", err)
|
||||||
}
|
}
|
||||||
@@ -418,7 +430,10 @@ func (g *BundleGenerator) addStatus() error {
|
|||||||
fullStatus := g.statusRecorder.GetFullStatus()
|
fullStatus := g.statusRecorder.GetFullStatus()
|
||||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||||
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
|
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
|
||||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, g.anonymize, version.NetbirdVersion(), "", nil, nil, nil, "", profName)
|
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||||
|
Anonymize: g.anonymize,
|
||||||
|
ProfileName: profName,
|
||||||
|
})
|
||||||
statusOutput := overview.FullDetailSummary()
|
statusOutput := overview.FullDetailSummary()
|
||||||
|
|
||||||
statusReader := strings.NewReader(statusOutput)
|
statusReader := strings.NewReader(statusOutput)
|
||||||
@@ -507,6 +522,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
|||||||
configContent.WriteString(fmt.Sprintf("DisableFirewall: %v\n", g.internalConfig.DisableFirewall))
|
configContent.WriteString(fmt.Sprintf("DisableFirewall: %v\n", g.internalConfig.DisableFirewall))
|
||||||
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
|
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
|
||||||
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
|
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
|
||||||
|
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
|
||||||
|
|
||||||
if g.internalConfig.DisableNotifications != nil {
|
if g.internalConfig.DisableNotifications != nil {
|
||||||
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))
|
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))
|
||||||
@@ -744,6 +760,30 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *BundleGenerator) addMetrics() error {
|
||||||
|
if g.clientMetrics == nil {
|
||||||
|
log.Debugf("skipping metrics in debug bundle: no metrics collector")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := g.clientMetrics.Export(&buf); err != nil {
|
||||||
|
return fmt.Errorf("export metrics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
log.Debugf("skipping metrics.txt in debug bundle: no metrics data")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.addFileToZip(&buf, "metrics.txt"); err != nil {
|
||||||
|
return fmt.Errorf("add metrics file to zip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("added metrics to debug bundle")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (g *BundleGenerator) addLogfile() error {
|
func (g *BundleGenerator) addLogfile() error {
|
||||||
if g.logPath == "" {
|
if g.logPath == "" {
|
||||||
log.Debugf("skipping empty log file in debug bundle")
|
log.Debugf("skipping empty log file in debug bundle")
|
||||||
@@ -1231,8 +1271,9 @@ func anonymizeFirewallRule(rule *mgmProto.FirewallRule, anonymizer *anonymize.An
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:staticcheck // PeerIP used for backward compatibility
|
||||||
if addr, err := netip.ParseAddr(rule.PeerIP); err == nil {
|
if addr, err := netip.ParseAddr(rule.PeerIP); err == nil {
|
||||||
rule.PeerIP = anonymizer.AnonymizeIP(addr).String()
|
rule.PeerIP = anonymizer.AnonymizeIP(addr).String() //nolint:staticcheck
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("added %d NRPT rules for %d domains. Domain list: %v", ruleIndex, len(domains), domains)
|
log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains))
|
||||||
return ruleIndex, nil
|
return ruleIndex, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func (d *Resolver) ID() types.HandlerID {
|
|||||||
return "local-resolver"
|
return "local-resolver"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Resolver) ProbeAvailability() {}
|
func (d *Resolver) ProbeAvailability(context.Context) {}
|
||||||
|
|
||||||
// ServeDNS handles a DNS request
|
// ServeDNS handles a DNS request
|
||||||
func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||||
|
|||||||
@@ -376,9 +376,9 @@ func (m *Resolver) extractDomainsFromServerDomains(serverDomains dnsconfig.Serve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if serverDomains.Flow != "" {
|
// Flow receiver domain is intentionally excluded from caching.
|
||||||
domains = append(domains, serverDomains.Flow)
|
// Cloud providers may rotate the IP behind this domain; a stale cached record
|
||||||
}
|
// causes TLS certificate verification failures on reconnect.
|
||||||
|
|
||||||
for _, stun := range serverDomains.Stuns {
|
for _, stun := range serverDomains.Stuns {
|
||||||
if stun != "" {
|
if stun != "" {
|
||||||
|
|||||||
@@ -391,7 +391,8 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.Len(t, resolver.GetCachedDomains(), 3)
|
assert.Len(t, resolver.GetCachedDomains(), 3)
|
||||||
|
|
||||||
// Update with partial ServerDomains (only flow domain - new type, should preserve all existing)
|
// Update with partial ServerDomains (only flow domain - flow is intentionally excluded from
|
||||||
|
// caching to prevent TLS failures from stale records, so all existing domains are preserved)
|
||||||
partialDomains := dnsconfig.ServerDomains{
|
partialDomains := dnsconfig.ServerDomains{
|
||||||
Flow: "github.com",
|
Flow: "github.com",
|
||||||
}
|
}
|
||||||
@@ -400,10 +401,10 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) {
|
|||||||
t.Skipf("Skipping test due to DNS resolution failure: %v", err)
|
t.Skipf("Skipping test due to DNS resolution failure: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Len(t, removedDomains, 0, "Should not remove any domains when adding new type")
|
assert.Len(t, removedDomains, 0, "Should not remove any domains when only flow domain is provided")
|
||||||
|
|
||||||
finalDomains := resolver.GetCachedDomains()
|
finalDomains := resolver.GetCachedDomains()
|
||||||
assert.Len(t, finalDomains, 4, "Should have all original domains plus new flow domain")
|
assert.Len(t, finalDomains, 3, "Flow domain is not cached; all original domains should be preserved")
|
||||||
|
|
||||||
domainStrings := make([]string, len(finalDomains))
|
domainStrings := make([]string, len(finalDomains))
|
||||||
for i, d := range finalDomains {
|
for i, d := range finalDomains {
|
||||||
@@ -412,5 +413,5 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) {
|
|||||||
assert.Contains(t, domainStrings, "example.org")
|
assert.Contains(t, domainStrings, "example.org")
|
||||||
assert.Contains(t, domainStrings, "google.com")
|
assert.Contains(t, domainStrings, "google.com")
|
||||||
assert.Contains(t, domainStrings, "cloudflare.com")
|
assert.Contains(t, domainStrings, "cloudflare.com")
|
||||||
assert.Contains(t, domainStrings, "github.com")
|
assert.NotContains(t, domainStrings, "github.com")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ func (m *MockServer) PopulateManagementDomain(mgmtURL *url.URL) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRouteChecker mock implementation of SetRouteChecker from Server interface
|
||||||
|
func (m *MockServer) SetRouteChecker(func(netip.Addr) bool) {
|
||||||
|
// Mock implementation - no-op
|
||||||
|
}
|
||||||
|
|
||||||
// BeginBatch mock implementation of BeginBatch from Server interface
|
// BeginBatch mock implementation of BeginBatch from Server interface
|
||||||
func (m *MockServer) BeginBatch() {
|
func (m *MockServer) BeginBatch() {
|
||||||
// Mock implementation - no-op
|
// Mock implementation - no-op
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type Server interface {
|
|||||||
ProbeAvailability()
|
ProbeAvailability()
|
||||||
UpdateServerConfig(domains dnsconfig.ServerDomains) error
|
UpdateServerConfig(domains dnsconfig.ServerDomains) error
|
||||||
PopulateManagementDomain(mgmtURL *url.URL) error
|
PopulateManagementDomain(mgmtURL *url.URL) error
|
||||||
|
SetRouteChecker(func(netip.Addr) bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
type nsGroupsByDomain struct {
|
type nsGroupsByDomain struct {
|
||||||
@@ -104,12 +105,17 @@ type DefaultServer struct {
|
|||||||
|
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
stateManager *statemanager.Manager
|
stateManager *statemanager.Manager
|
||||||
|
routeMatch func(netip.Addr) bool
|
||||||
|
|
||||||
|
probeMu sync.Mutex
|
||||||
|
probeCancel context.CancelFunc
|
||||||
|
probeWg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
type handlerWithStop interface {
|
type handlerWithStop interface {
|
||||||
dns.Handler
|
dns.Handler
|
||||||
Stop()
|
Stop()
|
||||||
ProbeAvailability()
|
ProbeAvailability(context.Context)
|
||||||
ID() types.HandlerID
|
ID() types.HandlerID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +231,14 @@ func newDefaultServer(
|
|||||||
return defaultServer
|
return defaultServer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRouteChecker sets the function used by upstream resolvers to determine
|
||||||
|
// whether an IP is routed through the tunnel.
|
||||||
|
func (s *DefaultServer) SetRouteChecker(f func(netip.Addr) bool) {
|
||||||
|
s.mux.Lock()
|
||||||
|
defer s.mux.Unlock()
|
||||||
|
s.routeMatch = f
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterHandler registers a handler for the given domains with the given priority.
|
// RegisterHandler registers a handler for the given domains with the given priority.
|
||||||
// Any previously registered handler for the same domain and priority will be replaced.
|
// Any previously registered handler for the same domain and priority will be replaced.
|
||||||
func (s *DefaultServer) RegisterHandler(domains domain.List, handler dns.Handler, priority int) {
|
func (s *DefaultServer) RegisterHandler(domains domain.List, handler dns.Handler, priority int) {
|
||||||
@@ -362,7 +376,13 @@ func (s *DefaultServer) DnsIP() netip.Addr {
|
|||||||
|
|
||||||
// Stop stops the server
|
// Stop stops the server
|
||||||
func (s *DefaultServer) Stop() {
|
func (s *DefaultServer) Stop() {
|
||||||
|
s.probeMu.Lock()
|
||||||
|
if s.probeCancel != nil {
|
||||||
|
s.probeCancel()
|
||||||
|
}
|
||||||
s.ctxCancel()
|
s.ctxCancel()
|
||||||
|
s.probeMu.Unlock()
|
||||||
|
s.probeWg.Wait()
|
||||||
s.shutdownWg.Wait()
|
s.shutdownWg.Wait()
|
||||||
|
|
||||||
s.mux.Lock()
|
s.mux.Lock()
|
||||||
@@ -479,7 +499,8 @@ func (s *DefaultServer) SearchDomains() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProbeAvailability tests each upstream group's servers for availability
|
// ProbeAvailability tests each upstream group's servers for availability
|
||||||
// and deactivates the group if no server responds
|
// and deactivates the group if no server responds.
|
||||||
|
// If a previous probe is still running, it will be cancelled before starting a new one.
|
||||||
func (s *DefaultServer) ProbeAvailability() {
|
func (s *DefaultServer) ProbeAvailability() {
|
||||||
if val := os.Getenv(envSkipDNSProbe); val != "" {
|
if val := os.Getenv(envSkipDNSProbe); val != "" {
|
||||||
skipProbe, err := strconv.ParseBool(val)
|
skipProbe, err := strconv.ParseBool(val)
|
||||||
@@ -492,15 +513,52 @@ func (s *DefaultServer) ProbeAvailability() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
s.probeMu.Lock()
|
||||||
for _, mux := range s.dnsMuxMap {
|
|
||||||
wg.Add(1)
|
// don't start probes on a stopped server
|
||||||
go func(mux handlerWithStop) {
|
if s.ctx.Err() != nil {
|
||||||
defer wg.Done()
|
s.probeMu.Unlock()
|
||||||
mux.ProbeAvailability()
|
return
|
||||||
}(mux.handler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cancel any running probe
|
||||||
|
if s.probeCancel != nil {
|
||||||
|
s.probeCancel()
|
||||||
|
s.probeCancel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait for the previous probe goroutines to finish while holding
|
||||||
|
// the mutex so no other caller can start a new probe concurrently
|
||||||
|
s.probeWg.Wait()
|
||||||
|
|
||||||
|
// start a new probe
|
||||||
|
probeCtx, probeCancel := context.WithCancel(s.ctx)
|
||||||
|
s.probeCancel = probeCancel
|
||||||
|
|
||||||
|
s.probeWg.Add(1)
|
||||||
|
defer s.probeWg.Done()
|
||||||
|
|
||||||
|
// Snapshot handlers under s.mux to avoid racing with updateMux/dnsMuxMap writers.
|
||||||
|
s.mux.Lock()
|
||||||
|
handlers := make([]handlerWithStop, 0, len(s.dnsMuxMap))
|
||||||
|
for _, mux := range s.dnsMuxMap {
|
||||||
|
handlers = append(handlers, mux.handler)
|
||||||
|
}
|
||||||
|
s.mux.Unlock()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, handler := range handlers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(h handlerWithStop) {
|
||||||
|
defer wg.Done()
|
||||||
|
h.ProbeAvailability(probeCtx)
|
||||||
|
}(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.probeMu.Unlock()
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
probeCancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DefaultServer) UpdateServerConfig(domains dnsconfig.ServerDomains) error {
|
func (s *DefaultServer) UpdateServerConfig(domains dnsconfig.ServerDomains) error {
|
||||||
@@ -695,6 +753,7 @@ func (s *DefaultServer) registerFallback(config HostDNSConfig) {
|
|||||||
log.Errorf("failed to create upstream resolver for original nameservers: %v", err)
|
log.Errorf("failed to create upstream resolver for original nameservers: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
handler.routeMatch = s.routeMatch
|
||||||
|
|
||||||
for _, ns := range originalNameservers {
|
for _, ns := range originalNameservers {
|
||||||
if ns == config.ServerIP {
|
if ns == config.ServerIP {
|
||||||
@@ -804,6 +863,7 @@ func (s *DefaultServer) createHandlersForDomainGroup(domainGroup nsGroupsByDomai
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create upstream resolver: %v", err)
|
return nil, fmt.Errorf("create upstream resolver: %v", err)
|
||||||
}
|
}
|
||||||
|
handler.routeMatch = s.routeMatch
|
||||||
|
|
||||||
for _, ns := range nsGroup.NameServers {
|
for _, ns := range nsGroup.NameServers {
|
||||||
if ns.NSType != nbdns.UDPNameServerType {
|
if ns.NSType != nbdns.UDPNameServerType {
|
||||||
@@ -988,6 +1048,7 @@ func (s *DefaultServer) addHostRootZone() {
|
|||||||
log.Errorf("unable to create a new upstream resolver, error: %v", err)
|
log.Errorf("unable to create a new upstream resolver, error: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
handler.routeMatch = s.routeMatch
|
||||||
|
|
||||||
handler.upstreamServers = maps.Keys(hostDNSServers)
|
handler.upstreamServers = maps.Keys(hostDNSServers)
|
||||||
handler.deactivate = func(error) {}
|
handler.deactivate = func(error) {}
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ func TestUpdateDNSServer(t *testing.T) {
|
|||||||
|
|
||||||
opts := iface.WGIFaceOpts{
|
opts := iface.WGIFaceOpts{
|
||||||
IFaceName: fmt.Sprintf("utun230%d", n),
|
IFaceName: fmt.Sprintf("utun230%d", n),
|
||||||
Address: fmt.Sprintf("100.66.100.%d/32", n+1),
|
Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)),
|
||||||
WGPort: 33100,
|
WGPort: 33100,
|
||||||
WGPrivKey: privKey.String(),
|
WGPrivKey: privKey.String(),
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
@@ -448,7 +448,7 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) {
|
|||||||
privKey, _ := wgtypes.GeneratePrivateKey()
|
privKey, _ := wgtypes.GeneratePrivateKey()
|
||||||
opts := iface.WGIFaceOpts{
|
opts := iface.WGIFaceOpts{
|
||||||
IFaceName: "utun2301",
|
IFaceName: "utun2301",
|
||||||
Address: "100.66.100.1/32",
|
Address: wgaddr.MustParseWGAddress("100.66.100.1/32"),
|
||||||
WGPort: 33100,
|
WGPort: 33100,
|
||||||
WGPrivKey: privKey.String(),
|
WGPrivKey: privKey.String(),
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
@@ -929,7 +929,7 @@ func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) {
|
|||||||
|
|
||||||
opts := iface.WGIFaceOpts{
|
opts := iface.WGIFaceOpts{
|
||||||
IFaceName: "utun2301",
|
IFaceName: "utun2301",
|
||||||
Address: "100.66.100.2/24",
|
Address: wgaddr.MustParseWGAddress("100.66.100.2/24"),
|
||||||
WGPort: 33100,
|
WGPort: 33100,
|
||||||
WGPrivKey: privKey.String(),
|
WGPrivKey: privKey.String(),
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
@@ -1065,7 +1065,7 @@ type mockHandler struct {
|
|||||||
|
|
||||||
func (m *mockHandler) ServeDNS(dns.ResponseWriter, *dns.Msg) {}
|
func (m *mockHandler) ServeDNS(dns.ResponseWriter, *dns.Msg) {}
|
||||||
func (m *mockHandler) Stop() {}
|
func (m *mockHandler) Stop() {}
|
||||||
func (m *mockHandler) ProbeAvailability() {}
|
func (m *mockHandler) ProbeAvailability(context.Context) {}
|
||||||
func (m *mockHandler) ID() types.HandlerID { return types.HandlerID(m.Id) }
|
func (m *mockHandler) ID() types.HandlerID { return types.HandlerID(m.Id) }
|
||||||
|
|
||||||
type mockService struct{}
|
type mockService struct{}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ func (s *serviceViaListener) Listen() error {
|
|||||||
return fmt.Errorf("eval listen address: %w", err)
|
return fmt.Errorf("eval listen address: %w", err)
|
||||||
}
|
}
|
||||||
s.listenIP = s.listenIP.Unmap()
|
s.listenIP = s.listenIP.Unmap()
|
||||||
s.server.Addr = fmt.Sprintf("%s:%d", s.listenIP, s.listenPort)
|
s.server.Addr = net.JoinHostPort(s.listenIP.String(), strconv.Itoa(int(s.listenPort)))
|
||||||
log.Debugf("starting dns on %s", s.server.Addr)
|
log.Debugf("starting dns on %s", s.server.Addr)
|
||||||
go func() {
|
go func() {
|
||||||
s.setListenerStatus(true)
|
s.setListenerStatus(true)
|
||||||
@@ -186,7 +187,7 @@ func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool {
|
func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool {
|
||||||
addrString := fmt.Sprintf("%s:%d", ip, port)
|
addrString := net.JoinHostPort(ip.String(), strconv.Itoa(port))
|
||||||
udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort(addrString))
|
udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort(addrString))
|
||||||
probeListener, err := net.ListenUDP("udp", udpAddr)
|
probeListener, err := net.ListenUDP("udp", udpAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -65,10 +65,12 @@ type upstreamResolverBase struct {
|
|||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
reactivatePeriod time.Duration
|
reactivatePeriod time.Duration
|
||||||
upstreamTimeout time.Duration
|
upstreamTimeout time.Duration
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
deactivate func(error)
|
deactivate func(error)
|
||||||
reactivate func()
|
reactivate func()
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
|
routeMatch func(netip.Addr) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type upstreamFailure struct {
|
type upstreamFailure struct {
|
||||||
@@ -115,6 +117,11 @@ func (u *upstreamResolverBase) MatchSubdomains() bool {
|
|||||||
func (u *upstreamResolverBase) Stop() {
|
func (u *upstreamResolverBase) Stop() {
|
||||||
log.Debugf("stopping serving DNS for upstreams %s", u.upstreamServers)
|
log.Debugf("stopping serving DNS for upstreams %s", u.upstreamServers)
|
||||||
u.cancel()
|
u.cancel()
|
||||||
|
|
||||||
|
u.mutex.Lock()
|
||||||
|
u.wg.Wait()
|
||||||
|
u.mutex.Unlock()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeDNS handles a DNS request
|
// ServeDNS handles a DNS request
|
||||||
@@ -260,16 +267,10 @@ func formatFailures(failures []upstreamFailure) string {
|
|||||||
|
|
||||||
// ProbeAvailability tests all upstream servers simultaneously and
|
// ProbeAvailability tests all upstream servers simultaneously and
|
||||||
// disables the resolver if none work
|
// disables the resolver if none work
|
||||||
func (u *upstreamResolverBase) ProbeAvailability() {
|
func (u *upstreamResolverBase) ProbeAvailability(ctx context.Context) {
|
||||||
u.mutex.Lock()
|
u.mutex.Lock()
|
||||||
defer u.mutex.Unlock()
|
defer u.mutex.Unlock()
|
||||||
|
|
||||||
select {
|
|
||||||
case <-u.ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// avoid probe if upstreams could resolve at least one query
|
// avoid probe if upstreams could resolve at least one query
|
||||||
if u.successCount.Load() > 0 {
|
if u.successCount.Load() > 0 {
|
||||||
return
|
return
|
||||||
@@ -279,31 +280,39 @@ func (u *upstreamResolverBase) ProbeAvailability() {
|
|||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
var errors *multierror.Error
|
var errs *multierror.Error
|
||||||
for _, upstream := range u.upstreamServers {
|
for _, upstream := range u.upstreamServers {
|
||||||
upstream := upstream
|
|
||||||
|
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func(upstream netip.AddrPort) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
err := u.testNameserver(upstream, 500*time.Millisecond)
|
err := u.testNameserver(u.ctx, ctx, upstream, 500*time.Millisecond)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors = multierror.Append(errors, err)
|
mu.Lock()
|
||||||
|
errs = multierror.Append(errs, err)
|
||||||
|
mu.Unlock()
|
||||||
log.Warnf("probing upstream nameserver %s: %s", upstream, err)
|
log.Warnf("probing upstream nameserver %s: %s", upstream, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
|
||||||
success = true
|
success = true
|
||||||
}()
|
mu.Unlock()
|
||||||
|
}(upstream)
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-u.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
// didn't find a working upstream server, let's disable and try later
|
// didn't find a working upstream server, let's disable and try later
|
||||||
if !success {
|
if !success {
|
||||||
u.disable(errors.ErrorOrNil())
|
u.disable(errs.ErrorOrNil())
|
||||||
|
|
||||||
if u.statusRecorder == nil {
|
if u.statusRecorder == nil {
|
||||||
return
|
return
|
||||||
@@ -339,7 +348,7 @@ func (u *upstreamResolverBase) waitUntilResponse() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, upstream := range u.upstreamServers {
|
for _, upstream := range u.upstreamServers {
|
||||||
if err := u.testNameserver(upstream, probeTimeout); err != nil {
|
if err := u.testNameserver(u.ctx, nil, upstream, probeTimeout); err != nil {
|
||||||
log.Tracef("upstream check for %s: %s", upstream, err)
|
log.Tracef("upstream check for %s: %s", upstream, err)
|
||||||
} else {
|
} else {
|
||||||
// at least one upstream server is available, stop probing
|
// at least one upstream server is available, stop probing
|
||||||
@@ -351,16 +360,22 @@ func (u *upstreamResolverBase) waitUntilResponse() {
|
|||||||
return fmt.Errorf("upstream check call error")
|
return fmt.Errorf("upstream check call error")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := backoff.Retry(operation, exponentialBackOff)
|
err := backoff.Retry(operation, backoff.WithContext(exponentialBackOff, u.ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(err)
|
if errors.Is(err, context.Canceled) {
|
||||||
|
log.Debugf("upstream retry loop exited for upstreams %s", u.upstreamServersString())
|
||||||
|
} else {
|
||||||
|
log.Warnf("upstream retry loop exited for upstreams %s: %v", u.upstreamServersString(), err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("upstreams %s are responsive again. Adding them back to system", u.upstreamServersString())
|
log.Infof("upstreams %s are responsive again. Adding them back to system", u.upstreamServersString())
|
||||||
u.successCount.Add(1)
|
u.successCount.Add(1)
|
||||||
u.reactivate()
|
u.reactivate()
|
||||||
|
u.mutex.Lock()
|
||||||
u.disabled = false
|
u.disabled = false
|
||||||
|
u.mutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTimeout returns true if the given error is a network timeout error.
|
// isTimeout returns true if the given error is a network timeout error.
|
||||||
@@ -383,7 +398,11 @@ func (u *upstreamResolverBase) disable(err error) {
|
|||||||
u.successCount.Store(0)
|
u.successCount.Store(0)
|
||||||
u.deactivate(err)
|
u.deactivate(err)
|
||||||
u.disabled = true
|
u.disabled = true
|
||||||
go u.waitUntilResponse()
|
u.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer u.wg.Done()
|
||||||
|
u.waitUntilResponse()
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *upstreamResolverBase) upstreamServersString() string {
|
func (u *upstreamResolverBase) upstreamServersString() string {
|
||||||
@@ -394,13 +413,18 @@ func (u *upstreamResolverBase) upstreamServersString() string {
|
|||||||
return strings.Join(servers, ", ")
|
return strings.Join(servers, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *upstreamResolverBase) testNameserver(server netip.AddrPort, timeout time.Duration) error {
|
func (u *upstreamResolverBase) testNameserver(baseCtx context.Context, externalCtx context.Context, server netip.AddrPort, timeout time.Duration) error {
|
||||||
ctx, cancel := context.WithTimeout(u.ctx, timeout)
|
mergedCtx, cancel := context.WithTimeout(baseCtx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
if externalCtx != nil {
|
||||||
|
stop2 := context.AfterFunc(externalCtx, cancel)
|
||||||
|
defer stop2()
|
||||||
|
}
|
||||||
|
|
||||||
r := new(dns.Msg).SetQuestion(testRecord, dns.TypeSOA)
|
r := new(dns.Msg).SetQuestion(testRecord, dns.TypeSOA)
|
||||||
|
|
||||||
_, _, err := u.upstreamClient.exchange(ctx, server.String(), r)
|
_, _, err := u.upstreamClient.exchange(mergedCtx, server.String(), r)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,11 +65,13 @@ func (u *upstreamResolverIOS) exchange(ctx context.Context, upstream string, r *
|
|||||||
} else {
|
} else {
|
||||||
upstreamIP = upstreamIP.Unmap()
|
upstreamIP = upstreamIP.Unmap()
|
||||||
}
|
}
|
||||||
if u.lNet.Contains(upstreamIP) || upstreamIP.IsPrivate() {
|
needsPrivate := u.lNet.Contains(upstreamIP) ||
|
||||||
log.Debugf("using private client to query upstream: %s", upstream)
|
(u.routeMatch != nil && u.routeMatch(upstreamIP))
|
||||||
|
if needsPrivate {
|
||||||
|
log.Debugf("using private client to query %s via upstream %s", r.Question[0].Name, upstream)
|
||||||
client, err = GetClientPrivate(u.lIP, u.interfaceName, timeout)
|
client, err = GetClientPrivate(u.lIP, u.interfaceName, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("error while creating private client: %s", err)
|
return nil, 0, fmt.Errorf("create private client: %s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ func TestUpstreamResolver_DeactivationReactivation(t *testing.T) {
|
|||||||
reactivated = true
|
reactivated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
resolver.ProbeAvailability()
|
resolver.ProbeAvailability(context.TODO())
|
||||||
|
|
||||||
if !failed {
|
if !failed {
|
||||||
t.Errorf("expected that resolving was deactivated")
|
t.Errorf("expected that resolving was deactivated")
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/firewall"
|
"github.com/netbirdio/netbird/client/firewall"
|
||||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
|
"github.com/netbirdio/netbird/shared/netiputil"
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
nbnetstack "github.com/netbirdio/netbird/client/iface/netstack"
|
nbnetstack "github.com/netbirdio/netbird/client/iface/netstack"
|
||||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||||
@@ -36,7 +38,9 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/dns"
|
"github.com/netbirdio/netbird/client/internal/dns"
|
||||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||||
"github.com/netbirdio/netbird/client/internal/dnsfwd"
|
"github.com/netbirdio/netbird/client/internal/dnsfwd"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/expose"
|
||||||
"github.com/netbirdio/netbird/client/internal/ingressgw"
|
"github.com/netbirdio/netbird/client/internal/ingressgw"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||||
"github.com/netbirdio/netbird/client/internal/netflow"
|
"github.com/netbirdio/netbird/client/internal/netflow"
|
||||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
"github.com/netbirdio/netbird/client/internal/networkmonitor"
|
"github.com/netbirdio/netbird/client/internal/networkmonitor"
|
||||||
@@ -50,16 +54,14 @@ import (
|
|||||||
"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/routemanager/systemops"
|
||||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/updatemanager"
|
"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"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
|
||||||
semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
nbdns "github.com/netbirdio/netbird/dns"
|
nbdns "github.com/netbirdio/netbird/dns"
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||||
@@ -75,19 +77,18 @@ import (
|
|||||||
const (
|
const (
|
||||||
PeerConnectionTimeoutMax = 45000 // ms
|
PeerConnectionTimeoutMax = 45000 // ms
|
||||||
PeerConnectionTimeoutMin = 30000 // ms
|
PeerConnectionTimeoutMin = 30000 // ms
|
||||||
connInitLimit = 200
|
|
||||||
disableAutoUpdate = "disabled"
|
disableAutoUpdate = "disabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrResetConnection = fmt.Errorf("reset connection")
|
var ErrResetConnection = fmt.Errorf("reset connection")
|
||||||
|
|
||||||
// EngineConfig is a config for the Engine
|
|
||||||
type EngineConfig struct {
|
type EngineConfig struct {
|
||||||
WgPort int
|
WgPort int
|
||||||
WgIfaceName string
|
WgIfaceName string
|
||||||
|
|
||||||
// WgAddr is a Wireguard local address (Netbird Network IP)
|
// WgAddr is the Wireguard local address (Netbird Network IP).
|
||||||
WgAddr string
|
// Contains both v4 and optional v6 overlay addresses.
|
||||||
|
WgAddr wgaddr.Address
|
||||||
|
|
||||||
// WgPrivateKey is a Wireguard private key of our peer (it MUST never leave the machine)
|
// WgPrivateKey is a Wireguard private key of our peer (it MUST never leave the machine)
|
||||||
WgPrivateKey wgtypes.Key
|
WgPrivateKey wgtypes.Key
|
||||||
@@ -132,6 +133,7 @@ type EngineConfig struct {
|
|||||||
DisableFirewall bool
|
DisableFirewall bool
|
||||||
BlockLANAccess bool
|
BlockLANAccess bool
|
||||||
BlockInbound bool
|
BlockInbound bool
|
||||||
|
DisableIPv6 bool
|
||||||
|
|
||||||
LazyConnectionEnabled bool
|
LazyConnectionEnabled bool
|
||||||
|
|
||||||
@@ -143,6 +145,18 @@ type EngineConfig struct {
|
|||||||
LogPath string
|
LogPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EngineServices holds the external service dependencies required by the Engine.
|
||||||
|
type EngineServices struct {
|
||||||
|
SignalClient signal.Client
|
||||||
|
MgmClient mgm.Client
|
||||||
|
RelayManager *relayClient.Manager
|
||||||
|
StatusRecorder *peer.Status
|
||||||
|
Checks []*mgmProto.Checks
|
||||||
|
StateManager *statemanager.Manager
|
||||||
|
UpdateManager *updater.Manager
|
||||||
|
ClientMetrics *metrics.ClientMetrics
|
||||||
|
}
|
||||||
|
|
||||||
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
// signal is a Signal Service client
|
// signal is a Signal Service client
|
||||||
@@ -208,11 +222,10 @@ type Engine struct {
|
|||||||
syncRespMux sync.RWMutex
|
syncRespMux sync.RWMutex
|
||||||
persistSyncResponse bool
|
persistSyncResponse bool
|
||||||
latestSyncResponse *mgmProto.SyncResponse
|
latestSyncResponse *mgmProto.SyncResponse
|
||||||
connSemaphore *semaphoregroup.SemaphoreGroup
|
|
||||||
flowManager nftypes.FlowManager
|
flowManager nftypes.FlowManager
|
||||||
|
|
||||||
// auto-update
|
// auto-update
|
||||||
updateManager *updatemanager.Manager
|
updateManager *updater.Manager
|
||||||
|
|
||||||
// WireGuard interface monitor
|
// WireGuard interface monitor
|
||||||
wgIfaceMonitor *WGIfaceMonitor
|
wgIfaceMonitor *WGIfaceMonitor
|
||||||
@@ -222,8 +235,13 @@ type Engine struct {
|
|||||||
|
|
||||||
probeStunTurn *relay.StunTurnProbe
|
probeStunTurn *relay.StunTurnProbe
|
||||||
|
|
||||||
|
// clientMetrics collects and pushes metrics
|
||||||
|
clientMetrics *metrics.ClientMetrics
|
||||||
|
|
||||||
jobExecutor *jobexec.Executor
|
jobExecutor *jobexec.Executor
|
||||||
jobExecutorWG sync.WaitGroup
|
jobExecutorWG sync.WaitGroup
|
||||||
|
|
||||||
|
exposeManager *expose.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer is an instance of the Connection Peer
|
// Peer is an instance of the Connection Peer
|
||||||
@@ -240,22 +258,17 @@ type localIpUpdater interface {
|
|||||||
func NewEngine(
|
func NewEngine(
|
||||||
clientCtx context.Context,
|
clientCtx context.Context,
|
||||||
clientCancel context.CancelFunc,
|
clientCancel context.CancelFunc,
|
||||||
signalClient signal.Client,
|
|
||||||
mgmClient mgm.Client,
|
|
||||||
relayManager *relayClient.Manager,
|
|
||||||
config *EngineConfig,
|
config *EngineConfig,
|
||||||
|
services EngineServices,
|
||||||
mobileDep MobileDependency,
|
mobileDep MobileDependency,
|
||||||
statusRecorder *peer.Status,
|
|
||||||
checks []*mgmProto.Checks,
|
|
||||||
stateManager *statemanager.Manager,
|
|
||||||
) *Engine {
|
) *Engine {
|
||||||
engine := &Engine{
|
engine := &Engine{
|
||||||
clientCtx: clientCtx,
|
clientCtx: clientCtx,
|
||||||
clientCancel: clientCancel,
|
clientCancel: clientCancel,
|
||||||
signal: signalClient,
|
signal: services.SignalClient,
|
||||||
signaler: peer.NewSignaler(signalClient, config.WgPrivateKey),
|
signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey),
|
||||||
mgmClient: mgmClient,
|
mgmClient: services.MgmClient,
|
||||||
relayManager: relayManager,
|
relayManager: services.RelayManager,
|
||||||
peerStore: peerstore.NewConnStore(),
|
peerStore: peerstore.NewConnStore(),
|
||||||
syncMsgMux: &sync.Mutex{},
|
syncMsgMux: &sync.Mutex{},
|
||||||
config: config,
|
config: config,
|
||||||
@@ -263,12 +276,13 @@ func NewEngine(
|
|||||||
STUNs: []*stun.URI{},
|
STUNs: []*stun.URI{},
|
||||||
TURNs: []*stun.URI{},
|
TURNs: []*stun.URI{},
|
||||||
networkSerial: 0,
|
networkSerial: 0,
|
||||||
statusRecorder: statusRecorder,
|
statusRecorder: services.StatusRecorder,
|
||||||
stateManager: stateManager,
|
stateManager: services.StateManager,
|
||||||
checks: checks,
|
checks: services.Checks,
|
||||||
connSemaphore: semaphoregroup.NewSemaphoreGroup(connInitLimit),
|
|
||||||
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
|
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
|
||||||
jobExecutor: jobexec.NewExecutor(),
|
jobExecutor: jobexec.NewExecutor(),
|
||||||
|
clientMetrics: services.ClientMetrics,
|
||||||
|
updateManager: services.UpdateManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
||||||
@@ -311,7 +325,7 @@ func (e *Engine) Stop() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if e.updateManager != nil {
|
if e.updateManager != nil {
|
||||||
e.updateManager.Stop()
|
e.updateManager.SetDownloadOnly()
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("cleaning up status recorder states")
|
log.Info("cleaning up status recorder states")
|
||||||
@@ -419,6 +433,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
e.cancel()
|
e.cancel()
|
||||||
}
|
}
|
||||||
e.ctx, e.cancel = context.WithCancel(e.clientCtx)
|
e.ctx, e.cancel = context.WithCancel(e.clientCtx)
|
||||||
|
e.exposeManager = expose.NewManager(e.ctx, e.mgmClient)
|
||||||
|
|
||||||
wgIface, err := e.newWgIface()
|
wgIface, err := e.newWgIface()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -488,6 +503,17 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
|
|
||||||
e.routeManager.SetRouteChangeListener(e.mobileDep.NetworkChangeListener)
|
e.routeManager.SetRouteChangeListener(e.mobileDep.NetworkChangeListener)
|
||||||
|
|
||||||
|
e.dnsServer.SetRouteChecker(func(ip netip.Addr) bool {
|
||||||
|
for _, routes := range e.routeManager.GetClientRoutes() {
|
||||||
|
for _, r := range routes {
|
||||||
|
if r.Network.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
if err = e.wgInterfaceCreate(); err != nil {
|
if err = e.wgInterfaceCreate(); err != nil {
|
||||||
log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error())
|
log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error())
|
||||||
e.close()
|
e.close()
|
||||||
@@ -560,13 +586,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) InitialUpdateHandling(autoUpdateSettings *mgmProto.AutoUpdateSettings) {
|
|
||||||
e.syncMsgMux.Lock()
|
|
||||||
defer e.syncMsgMux.Unlock()
|
|
||||||
|
|
||||||
e.handleAutoUpdateVersion(autoUpdateSettings, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) createFirewall() error {
|
func (e *Engine) createFirewall() error {
|
||||||
if e.config.DisableFirewall {
|
if e.config.DisableFirewall {
|
||||||
log.Infof("firewall is disabled")
|
log.Infof("firewall is disabled")
|
||||||
@@ -699,7 +718,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !compareNetIPLists(allowedIPs, p.GetAllowedIps()) {
|
if !compareNetIPLists(allowedIPs, e.filterAllowedIPs(p.GetAllowedIps())) {
|
||||||
modified = append(modified, p)
|
modified = append(modified, p)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -794,45 +813,30 @@ func (e *Engine) PopulateNetbirdConfig(netbirdConfig *mgmProto.NetbirdConfig, mg
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdateSettings, initialCheck bool) {
|
func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdateSettings) {
|
||||||
|
if e.updateManager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if autoUpdateSettings == nil {
|
if autoUpdateSettings == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
disabled := autoUpdateSettings.Version == disableAutoUpdate
|
if autoUpdateSettings.Version == disableAutoUpdate {
|
||||||
|
log.Infof("auto-update is disabled")
|
||||||
// Stop and cleanup if disabled
|
e.updateManager.SetDownloadOnly()
|
||||||
if e.updateManager != nil && disabled {
|
|
||||||
log.Infof("auto-update is disabled, stopping update manager")
|
|
||||||
e.updateManager.Stop()
|
|
||||||
e.updateManager = nil
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip check unless AlwaysUpdate is enabled or this is the initial check at startup
|
e.updateManager.SetVersion(autoUpdateSettings.Version, autoUpdateSettings.AlwaysUpdate)
|
||||||
if !autoUpdateSettings.AlwaysUpdate && !initialCheck {
|
|
||||||
log.Debugf("skipping auto-update check, AlwaysUpdate is false and this is not the initial check")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start manager if needed
|
|
||||||
if e.updateManager == nil {
|
|
||||||
log.Infof("starting auto-update manager")
|
|
||||||
updateManager, err := updatemanager.NewManager(e.statusRecorder, e.stateManager)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.updateManager = updateManager
|
|
||||||
e.updateManager.Start(e.ctx)
|
|
||||||
}
|
|
||||||
log.Infof("handling auto-update version: %s", autoUpdateSettings.Version)
|
|
||||||
e.updateManager.SetVersion(autoUpdateSettings.Version)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
log.Infof("sync finished in %s", time.Since(started))
|
duration := time.Since(started)
|
||||||
|
log.Infof("sync finished in %s", duration)
|
||||||
|
e.clientMetrics.RecordSyncDuration(e.ctx, duration)
|
||||||
}()
|
}()
|
||||||
e.syncMsgMux.Lock()
|
e.syncMsgMux.Lock()
|
||||||
defer e.syncMsgMux.Unlock()
|
defer e.syncMsgMux.Unlock()
|
||||||
@@ -843,7 +847,7 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
||||||
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate, false)
|
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
if update.GetNetbirdConfig() != nil {
|
if update.GetNetbirdConfig() != nil {
|
||||||
@@ -988,6 +992,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
|||||||
e.config.DisableFirewall,
|
e.config.DisableFirewall,
|
||||||
e.config.BlockLANAccess,
|
e.config.BlockLANAccess,
|
||||||
e.config.BlockInbound,
|
e.config.BlockInbound,
|
||||||
|
e.config.DisableIPv6,
|
||||||
e.config.LazyConnectionEnabled,
|
e.config.LazyConnectionEnabled,
|
||||||
e.config.EnableSSHRoot,
|
e.config.EnableSSHRoot,
|
||||||
e.config.EnableSSHSFTP,
|
e.config.EnableSSHSFTP,
|
||||||
@@ -1008,10 +1013,18 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
|||||||
return errors.New("wireguard interface is not initialized")
|
return errors.New("wireguard interface is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cannot update the IP address without restarting the engine because
|
|
||||||
// the firewall, route manager, and other components cache the old address
|
|
||||||
if e.wgInterface.Address().String() != conf.Address {
|
if e.wgInterface.Address().String() != conf.Address {
|
||||||
log.Infof("peer IP address has changed from %s to %s", e.wgInterface.Address().String(), conf.Address)
|
log.Infof("peer IP address changed from %s to %s, restarting client", e.wgInterface.Address().String(), conf.Address)
|
||||||
|
_ = CtxGetState(e.ctx).Wrap(ErrResetConnection)
|
||||||
|
e.clientCancel()
|
||||||
|
return ErrResetConnection
|
||||||
|
}
|
||||||
|
|
||||||
|
if !e.config.DisableIPv6 && e.hasIPv6Changed(conf) {
|
||||||
|
log.Infof("peer IPv6 address changed, restarting client")
|
||||||
|
_ = CtxGetState(e.ctx).Wrap(ErrResetConnection)
|
||||||
|
e.clientCancel()
|
||||||
|
return ErrResetConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
if conf.GetSshConfig() != nil {
|
if conf.GetSshConfig() != nil {
|
||||||
@@ -1022,6 +1035,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
|||||||
|
|
||||||
state := e.statusRecorder.GetLocalPeerState()
|
state := e.statusRecorder.GetLocalPeerState()
|
||||||
state.IP = e.wgInterface.Address().String()
|
state.IP = e.wgInterface.Address().String()
|
||||||
|
state.IPv6 = e.wgInterface.Address().IPv6String()
|
||||||
state.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()
|
||||||
@@ -1030,6 +1044,28 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasIPv6Changed reports whether the IPv6 overlay address in the peer config
|
||||||
|
// differs from the configured address (added, removed, or changed).
|
||||||
|
// Compares against e.config.WgAddr (not the interface address, which may have
|
||||||
|
// been cleared by ClearIPv6 if OS assignment failed).
|
||||||
|
func (e *Engine) hasIPv6Changed(conf *mgmProto.PeerConfig) bool {
|
||||||
|
current := e.config.WgAddr
|
||||||
|
raw := conf.GetAddressV6()
|
||||||
|
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return current.HasIPv6()
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, err := netiputil.DecodePrefix(raw)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("decode v6 overlay address: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !current.HasIPv6() || current.IPv6 != prefix.Addr() || current.IPv6Net != prefix.Masked()
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) receiveJobEvents() {
|
func (e *Engine) receiveJobEvents() {
|
||||||
e.jobExecutorWG.Add(1)
|
e.jobExecutorWG.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -1079,6 +1115,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
|||||||
StatusRecorder: e.statusRecorder,
|
StatusRecorder: e.statusRecorder,
|
||||||
SyncResponse: syncResponse,
|
SyncResponse: syncResponse,
|
||||||
LogPath: e.config.LogPath,
|
LogPath: e.config.LogPath,
|
||||||
|
ClientMetrics: e.clientMetrics,
|
||||||
RefreshStatus: func() {
|
RefreshStatus: func() {
|
||||||
e.RunHealthProbes(true)
|
e.RunHealthProbes(true)
|
||||||
},
|
},
|
||||||
@@ -1126,6 +1163,7 @@ func (e *Engine) receiveManagementEvents() {
|
|||||||
e.config.DisableFirewall,
|
e.config.DisableFirewall,
|
||||||
e.config.BlockLANAccess,
|
e.config.BlockLANAccess,
|
||||||
e.config.BlockInbound,
|
e.config.BlockInbound,
|
||||||
|
e.config.DisableIPv6,
|
||||||
e.config.LazyConnectionEnabled,
|
e.config.LazyConnectionEnabled,
|
||||||
e.config.EnableSSHRoot,
|
e.config.EnableSSHRoot,
|
||||||
e.config.EnableSSHSFTP,
|
e.config.EnableSSHSFTP,
|
||||||
@@ -1316,8 +1354,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
|||||||
|
|
||||||
// Test received (upstream) servers for availability right away instead of upon usage.
|
// Test received (upstream) servers for availability right away instead of upon usage.
|
||||||
// If no server of a server group responds this will disable the respective handler and retry later.
|
// If no server of a server group responds this will disable the respective handler and retry later.
|
||||||
e.dnsServer.ProbeAvailability()
|
go e.dnsServer.ProbeAvailability()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1447,8 +1484,10 @@ func (e *Engine) updateOfflinePeers(offlinePeers []*mgmProto.RemotePeerConfig) {
|
|||||||
replacement := make([]peer.State, len(offlinePeers))
|
replacement := make([]peer.State, len(offlinePeers))
|
||||||
for i, offlinePeer := range offlinePeers {
|
for i, offlinePeer := range offlinePeers {
|
||||||
log.Debugf("added offline peer %s", offlinePeer.Fqdn)
|
log.Debugf("added offline peer %s", offlinePeer.Fqdn)
|
||||||
|
v4, v6 := splitAllowedIPs(offlinePeer.GetAllowedIps(), e.wgInterface.Address().IPv6Net)
|
||||||
replacement[i] = peer.State{
|
replacement[i] = peer.State{
|
||||||
IP: strings.Join(offlinePeer.GetAllowedIps(), ","),
|
IP: v4,
|
||||||
|
IPv6: v6,
|
||||||
PubKey: offlinePeer.GetWgPubKey(),
|
PubKey: offlinePeer.GetWgPubKey(),
|
||||||
FQDN: offlinePeer.GetFqdn(),
|
FQDN: offlinePeer.GetFqdn(),
|
||||||
ConnStatus: peer.StatusIdle,
|
ConnStatus: peer.StatusIdle,
|
||||||
@@ -1459,6 +1498,30 @@ func (e *Engine) updateOfflinePeers(offlinePeers []*mgmProto.RemotePeerConfig) {
|
|||||||
e.statusRecorder.ReplaceOfflinePeers(replacement)
|
e.statusRecorder.ReplaceOfflinePeers(replacement)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitAllowedIPs separates the peer's overlay v4 (/32) and v6 (/128) addresses
|
||||||
|
// from a list of AllowedIPs CIDRs. The v6 address is only matched if it falls
|
||||||
|
// within ourV6Net (the local overlay v6 subnet), to avoid confusing routed /128
|
||||||
|
// prefixes with the peer's overlay address.
|
||||||
|
func splitAllowedIPs(allowedIPs []string, ourV6Net netip.Prefix) (v4, v6 string) {
|
||||||
|
for _, cidr := range allowedIPs {
|
||||||
|
prefix, err := netip.ParsePrefix(cidr)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to parse AllowedIP %q: %v", cidr, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case prefix.Addr().Is4() && prefix.Bits() == 32 && v4 == "":
|
||||||
|
v4 = prefix.Addr().String()
|
||||||
|
case prefix.Addr().Is6() && prefix.Bits() == 128 && ourV6Net.Contains(prefix.Addr()) && v6 == "":
|
||||||
|
v6 = prefix.Addr().String()
|
||||||
|
}
|
||||||
|
if v4 != "" && v6 != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// addNewPeers adds peers that were not know before but arrived from the Management service with the update
|
// addNewPeers adds peers that were not know before but arrived from the Management service with the update
|
||||||
func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
||||||
for _, p := range peersUpdate {
|
for _, p := range peersUpdate {
|
||||||
@@ -1484,15 +1547,23 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error {
|
|||||||
log.Errorf("failed to parse allowedIPS: %v", err)
|
log.Errorf("failed to parse allowedIPS: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if allowedNetIP.Addr().Is6() && !e.wgInterface.Address().HasIPv6() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
peerIPs = append(peerIPs, allowedNetIP)
|
peerIPs = append(peerIPs, allowedNetIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(peerIPs) == 0 {
|
||||||
|
return fmt.Errorf("peer %s has no usable AllowedIPs", peerKey)
|
||||||
|
}
|
||||||
|
|
||||||
conn, err := e.createPeerConn(peerKey, peerIPs, peerConfig.AgentVersion)
|
conn, err := e.createPeerConn(peerKey, peerIPs, peerConfig.AgentVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create peer connection: %w", err)
|
return fmt.Errorf("create peer connection: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = e.statusRecorder.AddPeer(peerKey, peerConfig.Fqdn, peerIPs[0].Addr().String())
|
peerV4, peerV6 := splitAllowedIPs(peerConfig.GetAllowedIps(), e.wgInterface.Address().IPv6Net)
|
||||||
|
err = e.statusRecorder.AddPeer(peerKey, peerConfig.Fqdn, peerV4, peerV6)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err)
|
log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err)
|
||||||
}
|
}
|
||||||
@@ -1534,12 +1605,12 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
|||||||
}
|
}
|
||||||
|
|
||||||
serviceDependencies := peer.ServiceDependencies{
|
serviceDependencies := peer.ServiceDependencies{
|
||||||
StatusRecorder: e.statusRecorder,
|
StatusRecorder: e.statusRecorder,
|
||||||
Signaler: e.signaler,
|
Signaler: e.signaler,
|
||||||
IFaceDiscover: e.mobileDep.IFaceDiscover,
|
IFaceDiscover: e.mobileDep.IFaceDiscover,
|
||||||
RelayManager: e.relayManager,
|
RelayManager: e.relayManager,
|
||||||
SrWatcher: e.srWatcher,
|
SrWatcher: e.srWatcher,
|
||||||
Semaphore: e.connSemaphore,
|
MetricsRecorder: e.clientMetrics,
|
||||||
}
|
}
|
||||||
peerConn, err := peer.NewConn(config, serviceDependencies)
|
peerConn, err := peer.NewConn(config, serviceDependencies)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1715,6 +1786,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
|||||||
e.config.DisableFirewall,
|
e.config.DisableFirewall,
|
||||||
e.config.BlockLANAccess,
|
e.config.BlockLANAccess,
|
||||||
e.config.BlockInbound,
|
e.config.BlockInbound,
|
||||||
|
e.config.DisableIPv6,
|
||||||
e.config.LazyConnectionEnabled,
|
e.config.LazyConnectionEnabled,
|
||||||
e.config.EnableSSHRoot,
|
e.config.EnableSSHRoot,
|
||||||
e.config.EnableSSHSFTP,
|
e.config.EnableSSHSFTP,
|
||||||
@@ -1770,7 +1842,8 @@ func (e *Engine) wgInterfaceCreate() (err error) {
|
|||||||
case "android":
|
case "android":
|
||||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||||
case "ios":
|
case "ios":
|
||||||
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr)
|
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
||||||
|
e.mobileDep.NetworkChangeListener.SetInterfaceIPv6(e.config.WgAddr.IPv6String())
|
||||||
err = e.wgInterface.Create()
|
err = e.wgInterface.Create()
|
||||||
default:
|
default:
|
||||||
err = e.wgInterface.Create()
|
err = e.wgInterface.Create()
|
||||||
@@ -1824,11 +1897,23 @@ func (e *Engine) GetRouteManager() routemanager.Manager {
|
|||||||
return e.routeManager
|
return e.routeManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFirewallManager returns the firewall manager
|
// GetFirewallManager returns the firewall manager.
|
||||||
func (e *Engine) GetFirewallManager() firewallManager.Manager {
|
func (e *Engine) GetFirewallManager() firewallManager.Manager {
|
||||||
return e.firewall
|
return e.firewall
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetExposeManager returns the expose session manager.
|
||||||
|
func (e *Engine) GetExposeManager() *expose.Manager {
|
||||||
|
e.syncMsgMux.Lock()
|
||||||
|
defer e.syncMsgMux.Unlock()
|
||||||
|
return e.exposeManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientMetrics returns the client metrics
|
||||||
|
func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
|
||||||
|
return e.clientMetrics
|
||||||
|
}
|
||||||
|
|
||||||
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
||||||
iface, err := net.InterfaceByName(ifaceName)
|
iface, err := net.InterfaceByName(ifaceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2267,6 +2352,24 @@ func getInterfacePrefixes() ([]netip.Prefix, error) {
|
|||||||
return prefixes, nberrors.FormatErrorOrNil(merr)
|
return prefixes, nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterAllowedIPs strips IPv6 entries when the local interface has no v6 address.
|
||||||
|
// This covers both the explicit --disable-ipv6 flag (v6 never assigned) and the
|
||||||
|
// case where OS v6 assignment failed (ClearIPv6). Without this, WireGuard would
|
||||||
|
// accept v6 traffic that the native firewall cannot filter.
|
||||||
|
func (e *Engine) filterAllowedIPs(ips []string) []string {
|
||||||
|
if e.wgInterface.Address().HasIPv6() {
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
filtered := make([]string, 0, len(ips))
|
||||||
|
for _, s := range ips {
|
||||||
|
p, err := netip.ParsePrefix(s)
|
||||||
|
if err != nil || !p.Addr().Is6() {
|
||||||
|
filtered = append(filtered, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
// compareNetIPLists compares a list of netip.Prefix with a list of strings.
|
// compareNetIPLists compares a list of netip.Prefix with a list of strings.
|
||||||
// return true if both lists are equal, false otherwise.
|
// return true if both lists are equal, false otherwise.
|
||||||
func compareNetIPLists(list1 []netip.Prefix, list2 []string) bool {
|
func compareNetIPLists(list1 []netip.Prefix, list2 []string) bool {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import (
|
|||||||
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
||||||
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||||
|
"github.com/netbirdio/netbird/shared/netiputil"
|
||||||
signal "github.com/netbirdio/netbird/shared/signal/client"
|
signal "github.com/netbirdio/netbird/shared/signal/client"
|
||||||
"github.com/netbirdio/netbird/shared/signal/proto"
|
"github.com/netbirdio/netbird/shared/signal/proto"
|
||||||
signalServer "github.com/netbirdio/netbird/signal/server"
|
signalServer "github.com/netbirdio/netbird/signal/server"
|
||||||
@@ -94,7 +95,7 @@ type MockWGIface struct {
|
|||||||
AddressFunc func() wgaddr.Address
|
AddressFunc func() wgaddr.Address
|
||||||
ToInterfaceFunc func() *net.Interface
|
ToInterfaceFunc func() *net.Interface
|
||||||
UpFunc func() (*udpmux.UniversalUDPMuxDefault, error)
|
UpFunc func() (*udpmux.UniversalUDPMuxDefault, error)
|
||||||
UpdateAddrFunc func(newAddr string) error
|
UpdateAddrFunc func(newAddr wgaddr.Address) error
|
||||||
UpdatePeerFunc func(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
|
UpdatePeerFunc func(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
|
||||||
RemovePeerFunc func(peerKey string) error
|
RemovePeerFunc func(peerKey string) error
|
||||||
AddAllowedIPFunc func(peerKey string, allowedIP netip.Prefix) error
|
AddAllowedIPFunc func(peerKey string, allowedIP netip.Prefix) error
|
||||||
@@ -156,7 +157,7 @@ func (m *MockWGIface) Up() (*udpmux.UniversalUDPMuxDefault, error) {
|
|||||||
return m.UpFunc()
|
return m.UpFunc()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockWGIface) UpdateAddr(newAddr string) error {
|
func (m *MockWGIface) UpdateAddr(newAddr wgaddr.Address) error {
|
||||||
return m.UpdateAddrFunc(newAddr)
|
return m.UpdateAddrFunc(newAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,22 +252,22 @@ func TestEngine_SSH(t *testing.T) {
|
|||||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||||
engine := NewEngine(
|
engine := NewEngine(
|
||||||
ctx, cancel,
|
ctx, cancel,
|
||||||
&signal.MockClient{},
|
|
||||||
&mgmt.MockClient{},
|
|
||||||
relayMgr,
|
|
||||||
&EngineConfig{
|
&EngineConfig{
|
||||||
WgIfaceName: "utun101",
|
WgIfaceName: "utun101",
|
||||||
WgAddr: "100.64.0.1/24",
|
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||||
WgPrivateKey: key,
|
WgPrivateKey: key,
|
||||||
WgPort: 33100,
|
WgPort: 33100,
|
||||||
ServerSSHAllowed: true,
|
ServerSSHAllowed: true,
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
SSHKey: sshKey,
|
SSHKey: sshKey,
|
||||||
},
|
},
|
||||||
|
EngineServices{
|
||||||
|
SignalClient: &signal.MockClient{},
|
||||||
|
MgmClient: &mgmt.MockClient{},
|
||||||
|
RelayManager: relayMgr,
|
||||||
|
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||||
|
},
|
||||||
MobileDependency{},
|
MobileDependency{},
|
||||||
peer.NewRecorder("https://mgm"),
|
|
||||||
nil,
|
|
||||||
nil,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
engine.dnsServer = &dns.MockServer{
|
engine.dnsServer = &dns.MockServer{
|
||||||
@@ -428,13 +429,18 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||||
engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{}, relayMgr, &EngineConfig{
|
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||||
WgIfaceName: "utun102",
|
WgIfaceName: "utun102",
|
||||||
WgAddr: "100.64.0.1/24",
|
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||||
WgPrivateKey: key,
|
WgPrivateKey: key,
|
||||||
WgPort: 33100,
|
WgPort: 33100,
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
}, MobileDependency{}, peer.NewRecorder("https://mgm"), nil, nil)
|
}, EngineServices{
|
||||||
|
SignalClient: &signal.MockClient{},
|
||||||
|
MgmClient: &mgmt.MockClient{},
|
||||||
|
RelayManager: relayMgr,
|
||||||
|
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||||
|
}, MobileDependency{})
|
||||||
|
|
||||||
wgIface := &MockWGIface{
|
wgIface := &MockWGIface{
|
||||||
NameFunc: func() string { return "utun102" },
|
NameFunc: func() string { return "utun102" },
|
||||||
@@ -647,13 +653,18 @@ func TestEngine_Sync(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||||
engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{SyncFunc: syncFunc}, relayMgr, &EngineConfig{
|
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||||
WgIfaceName: "utun103",
|
WgIfaceName: "utun103",
|
||||||
WgAddr: "100.64.0.1/24",
|
WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
|
||||||
WgPrivateKey: key,
|
WgPrivateKey: key,
|
||||||
WgPort: 33100,
|
WgPort: 33100,
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
}, MobileDependency{}, peer.NewRecorder("https://mgm"), nil, nil)
|
}, EngineServices{
|
||||||
|
SignalClient: &signal.MockClient{},
|
||||||
|
MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
|
||||||
|
RelayManager: relayMgr,
|
||||||
|
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||||
|
}, MobileDependency{})
|
||||||
engine.ctx = ctx
|
engine.ctx = ctx
|
||||||
|
|
||||||
engine.dnsServer = &dns.MockServer{
|
engine.dnsServer = &dns.MockServer{
|
||||||
@@ -812,13 +823,18 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
|
|||||||
wgAddr := fmt.Sprintf("100.66.%d.1/24", n)
|
wgAddr := fmt.Sprintf("100.66.%d.1/24", n)
|
||||||
|
|
||||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||||
engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{}, relayMgr, &EngineConfig{
|
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||||
WgIfaceName: wgIfaceName,
|
WgIfaceName: wgIfaceName,
|
||||||
WgAddr: wgAddr,
|
WgAddr: wgaddr.MustParseWGAddress(wgAddr),
|
||||||
WgPrivateKey: key,
|
WgPrivateKey: key,
|
||||||
WgPort: 33100,
|
WgPort: 33100,
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
}, MobileDependency{}, peer.NewRecorder("https://mgm"), nil, nil)
|
}, EngineServices{
|
||||||
|
SignalClient: &signal.MockClient{},
|
||||||
|
MgmClient: &mgmt.MockClient{},
|
||||||
|
RelayManager: relayMgr,
|
||||||
|
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||||
|
}, MobileDependency{})
|
||||||
engine.ctx = ctx
|
engine.ctx = ctx
|
||||||
newNet, err := stdnet.NewNet(context.Background(), nil)
|
newNet, err := stdnet.NewNet(context.Background(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -827,7 +843,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
|
|||||||
|
|
||||||
opts := iface.WGIFaceOpts{
|
opts := iface.WGIFaceOpts{
|
||||||
IFaceName: wgIfaceName,
|
IFaceName: wgIfaceName,
|
||||||
Address: wgAddr,
|
Address: wgaddr.MustParseWGAddress(wgAddr),
|
||||||
WGPort: engine.config.WgPort,
|
WGPort: engine.config.WgPort,
|
||||||
WGPrivKey: key.String(),
|
WGPrivKey: key.String(),
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
@@ -1014,13 +1030,18 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
|
|||||||
wgAddr := fmt.Sprintf("100.66.%d.1/24", n)
|
wgAddr := fmt.Sprintf("100.66.%d.1/24", n)
|
||||||
|
|
||||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||||
engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{}, relayMgr, &EngineConfig{
|
engine := NewEngine(ctx, cancel, &EngineConfig{
|
||||||
WgIfaceName: wgIfaceName,
|
WgIfaceName: wgIfaceName,
|
||||||
WgAddr: wgAddr,
|
WgAddr: wgaddr.MustParseWGAddress(wgAddr),
|
||||||
WgPrivateKey: key,
|
WgPrivateKey: key,
|
||||||
WgPort: 33100,
|
WgPort: 33100,
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
}, MobileDependency{}, peer.NewRecorder("https://mgm"), nil, nil)
|
}, EngineServices{
|
||||||
|
SignalClient: &signal.MockClient{},
|
||||||
|
MgmClient: &mgmt.MockClient{},
|
||||||
|
RelayManager: relayMgr,
|
||||||
|
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||||
|
}, MobileDependency{})
|
||||||
engine.ctx = ctx
|
engine.ctx = ctx
|
||||||
|
|
||||||
newNet, err := stdnet.NewNet(context.Background(), nil)
|
newNet, err := stdnet.NewNet(context.Background(), nil)
|
||||||
@@ -1029,7 +1050,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
opts := iface.WGIFaceOpts{
|
opts := iface.WGIFaceOpts{
|
||||||
IFaceName: wgIfaceName,
|
IFaceName: wgIfaceName,
|
||||||
Address: wgAddr,
|
Address: wgaddr.MustParseWGAddress(wgAddr),
|
||||||
WGPort: 33100,
|
WGPort: 33100,
|
||||||
WGPrivKey: key.String(),
|
WGPrivKey: key.String(),
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
@@ -1539,14 +1560,19 @@ func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey strin
|
|||||||
wgPort := 33100 + i
|
wgPort := 33100 + i
|
||||||
conf := &EngineConfig{
|
conf := &EngineConfig{
|
||||||
WgIfaceName: ifaceName,
|
WgIfaceName: ifaceName,
|
||||||
WgAddr: resp.PeerConfig.Address,
|
WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
|
||||||
WgPrivateKey: key,
|
WgPrivateKey: key,
|
||||||
WgPort: wgPort,
|
WgPort: wgPort,
|
||||||
MTU: iface.DefaultMTU,
|
MTU: iface.DefaultMTU,
|
||||||
}
|
}
|
||||||
|
|
||||||
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
|
||||||
e, err := NewEngine(ctx, cancel, signalClient, mgmtClient, relayMgr, conf, MobileDependency{}, peer.NewRecorder("https://mgm"), nil, nil), nil
|
e, err := NewEngine(ctx, cancel, conf, EngineServices{
|
||||||
|
SignalClient: signalClient,
|
||||||
|
MgmClient: mgmtClient,
|
||||||
|
RelayManager: relayMgr,
|
||||||
|
StatusRecorder: peer.NewRecorder("https://mgm"),
|
||||||
|
}, MobileDependency{}), nil
|
||||||
e.ctx = ctx
|
e.ctx = ctx
|
||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
@@ -1679,3 +1705,209 @@ func getPeers(e *Engine) int {
|
|||||||
|
|
||||||
return len(e.peerStore.PeersPubKey())
|
return len(e.peerStore.PeersPubKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEngine_hasIPv6Changed(t *testing.T) {
|
||||||
|
v4Only := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||||
|
|
||||||
|
v4v6 := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||||
|
v4v6.IPv6 = netip.MustParseAddr("fd00::1")
|
||||||
|
v4v6.IPv6Net = netip.MustParsePrefix("fd00::1/64").Masked()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
current wgaddr.Address
|
||||||
|
confV6 []byte
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no v6 before, no v6 now",
|
||||||
|
current: v4Only,
|
||||||
|
confV6: nil,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no v6 before, v6 added",
|
||||||
|
current: v4Only,
|
||||||
|
confV6: netiputil.EncodePrefix(netip.MustParsePrefix("fd00::1/64")),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "had v6, now removed",
|
||||||
|
current: v4v6,
|
||||||
|
confV6: nil,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "had v6, same v6",
|
||||||
|
current: v4v6,
|
||||||
|
confV6: netiputil.EncodePrefix(netip.MustParsePrefix("fd00::1/64")),
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "had v6, different v6",
|
||||||
|
current: v4v6,
|
||||||
|
confV6: netiputil.EncodePrefix(netip.MustParsePrefix("fd00::2/64")),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same v6 addr, different prefix length",
|
||||||
|
current: v4v6,
|
||||||
|
confV6: netiputil.EncodePrefix(netip.MustParsePrefix("fd00::1/80")),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "decode error keeps status quo",
|
||||||
|
current: v4Only,
|
||||||
|
confV6: []byte{1, 2, 3},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
engine := &Engine{
|
||||||
|
config: &EngineConfig{WgAddr: tt.current},
|
||||||
|
}
|
||||||
|
conf := &mgmtProto.PeerConfig{
|
||||||
|
AddressV6: tt.confV6,
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.expected, engine.hasIPv6Changed(conf))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterAllowedIPs(t *testing.T) {
|
||||||
|
v4v6Addr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||||
|
v4v6Addr.IPv6 = netip.MustParseAddr("fd00::1")
|
||||||
|
v4v6Addr.IPv6Net = netip.MustParsePrefix("fd00::1/64").Masked()
|
||||||
|
|
||||||
|
v4OnlyAddr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
addr wgaddr.Address
|
||||||
|
input []string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "interface has v6, keep all",
|
||||||
|
addr: v4v6Addr,
|
||||||
|
input: []string{"100.64.0.1/32", "fd00::1/128"},
|
||||||
|
expected: []string{"100.64.0.1/32", "fd00::1/128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no v6, strip v6",
|
||||||
|
addr: v4OnlyAddr,
|
||||||
|
input: []string{"100.64.0.1/32", "fd00::1/128"},
|
||||||
|
expected: []string{"100.64.0.1/32"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no v6, only v4",
|
||||||
|
addr: v4OnlyAddr,
|
||||||
|
input: []string{"100.64.0.1/32", "10.0.0.0/8"},
|
||||||
|
expected: []string{"100.64.0.1/32", "10.0.0.0/8"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no v6, only v6 input",
|
||||||
|
addr: v4OnlyAddr,
|
||||||
|
input: []string{"fd00::1/128", "::/0"},
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no v6, invalid prefix preserved",
|
||||||
|
addr: v4OnlyAddr,
|
||||||
|
input: []string{"100.64.0.1/32", "garbage"},
|
||||||
|
expected: []string{"100.64.0.1/32", "garbage"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no v6, empty input",
|
||||||
|
addr: v4OnlyAddr,
|
||||||
|
input: []string{},
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
addr := tt.addr
|
||||||
|
engine := &Engine{
|
||||||
|
config: &EngineConfig{},
|
||||||
|
wgInterface: &MockWGIface{
|
||||||
|
AddressFunc: func() wgaddr.Address { return addr },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result := engine.filterAllowedIPs(tt.input)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitAllowedIPs(t *testing.T) {
|
||||||
|
ourV6Net := netip.MustParsePrefix("fd00:1234:5678:abcd::/64")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
allowedIPs []string
|
||||||
|
ourV6Net netip.Prefix
|
||||||
|
wantV4 string
|
||||||
|
wantV6 string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "v4 only",
|
||||||
|
allowedIPs: []string{"100.64.0.1/32"},
|
||||||
|
ourV6Net: ourV6Net,
|
||||||
|
wantV4: "100.64.0.1",
|
||||||
|
wantV6: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "v4 and v6 overlay",
|
||||||
|
allowedIPs: []string{"100.64.0.1/32", "fd00:1234:5678:abcd::1/128"},
|
||||||
|
ourV6Net: ourV6Net,
|
||||||
|
wantV4: "100.64.0.1",
|
||||||
|
wantV6: "fd00:1234:5678:abcd::1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "v4, routed v6, overlay v6",
|
||||||
|
allowedIPs: []string{"100.64.0.1/32", "2001:db8::1/128", "fd00:1234:5678:abcd::1/128"},
|
||||||
|
ourV6Net: ourV6Net,
|
||||||
|
wantV4: "100.64.0.1",
|
||||||
|
wantV6: "fd00:1234:5678:abcd::1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "routed v6 /128 outside our subnet is ignored",
|
||||||
|
allowedIPs: []string{"100.64.0.1/32", "2001:db8::1/128"},
|
||||||
|
ourV6Net: ourV6Net,
|
||||||
|
wantV4: "100.64.0.1",
|
||||||
|
wantV6: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "routed v6 prefix is ignored",
|
||||||
|
allowedIPs: []string{"100.64.0.1/32", "fd00:1234:5678:abcd::/64"},
|
||||||
|
ourV6Net: ourV6Net,
|
||||||
|
wantV4: "100.64.0.1",
|
||||||
|
wantV6: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no v6 subnet configured",
|
||||||
|
allowedIPs: []string{"100.64.0.1/32", "fd00:1234:5678:abcd::1/128"},
|
||||||
|
ourV6Net: netip.Prefix{},
|
||||||
|
wantV4: "100.64.0.1",
|
||||||
|
wantV6: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "v4 /24 route is ignored",
|
||||||
|
allowedIPs: []string{"100.64.0.0/24", "100.64.0.1/32"},
|
||||||
|
ourV6Net: ourV6Net,
|
||||||
|
wantV4: "100.64.0.1",
|
||||||
|
wantV6: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
v4, v6 := splitAllowedIPs(tt.allowedIPs, tt.ourV6Net)
|
||||||
|
assert.Equal(t, tt.wantV4, v4, "v4")
|
||||||
|
assert.Equal(t, tt.wantV6, v6, "v6")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
97
client/internal/expose/manager.go
Normal file
97
client/internal/expose/manager.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package expose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const renewTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Response holds the response from exposing a service.
|
||||||
|
type Response struct {
|
||||||
|
ServiceName string
|
||||||
|
ServiceURL string
|
||||||
|
Domain string
|
||||||
|
PortAutoAssigned bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
NamePrefix string
|
||||||
|
Domain string
|
||||||
|
Port uint16
|
||||||
|
Protocol int
|
||||||
|
Pin string
|
||||||
|
Password string
|
||||||
|
UserGroups []string
|
||||||
|
ListenPort uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagementClient interface {
|
||||||
|
CreateExpose(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error)
|
||||||
|
RenewExpose(ctx context.Context, domain string) error
|
||||||
|
StopExpose(ctx context.Context, domain string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager handles expose session lifecycle via the management client.
|
||||||
|
type Manager struct {
|
||||||
|
mgmClient ManagementClient
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new expose Manager using the given management client.
|
||||||
|
func NewManager(ctx context.Context, mgmClient ManagementClient) *Manager {
|
||||||
|
return &Manager{mgmClient: mgmClient, ctx: ctx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose creates a new expose session via the management server.
|
||||||
|
func (m *Manager) Expose(ctx context.Context, req Request) (*Response, error) {
|
||||||
|
log.Infof("exposing service on port %d", req.Port)
|
||||||
|
resp, err := m.mgmClient.CreateExpose(ctx, toClientExposeRequest(req))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("expose session created for %s", resp.Domain)
|
||||||
|
|
||||||
|
return fromClientExposeResponse(resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) KeepAlive(ctx context.Context, domain string) error {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
defer m.stop(domain)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Infof("context canceled, stopping keep alive for %s", domain)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := m.renew(ctx, domain); err != nil {
|
||||||
|
log.Errorf("renewing expose session for %s: %v", domain, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renew extends the TTL of an active expose session.
|
||||||
|
func (m *Manager) renew(ctx context.Context, domain string) error {
|
||||||
|
renewCtx, cancel := context.WithTimeout(ctx, renewTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return m.mgmClient.RenewExpose(renewCtx, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop terminates an active expose session.
|
||||||
|
func (m *Manager) stop(domain string) {
|
||||||
|
stopCtx, cancel := context.WithTimeout(m.ctx, renewTimeout)
|
||||||
|
defer cancel()
|
||||||
|
err := m.mgmClient.StopExpose(stopCtx, domain)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("Failed stopping expose session for %s: %v", domain, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
client/internal/expose/manager_test.go
Normal file
95
client/internal/expose/manager_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package expose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
daemonProto "github.com/netbirdio/netbird/client/proto"
|
||||||
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManager_Expose_Success(t *testing.T) {
|
||||||
|
mock := &mgm.MockClient{
|
||||||
|
CreateExposeFunc: func(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error) {
|
||||||
|
return &mgm.ExposeResponse{
|
||||||
|
ServiceName: "my-service",
|
||||||
|
ServiceURL: "https://my-service.example.com",
|
||||||
|
Domain: "my-service.example.com",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewManager(context.Background(), mock)
|
||||||
|
result, err := m.Expose(context.Background(), Request{Port: 8080})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "my-service", result.ServiceName, "service name should match")
|
||||||
|
assert.Equal(t, "https://my-service.example.com", result.ServiceURL, "service URL should match")
|
||||||
|
assert.Equal(t, "my-service.example.com", result.Domain, "domain should match")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Expose_Error(t *testing.T) {
|
||||||
|
mock := &mgm.MockClient{
|
||||||
|
CreateExposeFunc: func(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error) {
|
||||||
|
return nil, errors.New("permission denied")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewManager(context.Background(), mock)
|
||||||
|
_, err := m.Expose(context.Background(), Request{Port: 8080})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "permission denied", "error should propagate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Renew_Success(t *testing.T) {
|
||||||
|
mock := &mgm.MockClient{
|
||||||
|
RenewExposeFunc: func(ctx context.Context, domain string) error {
|
||||||
|
assert.Equal(t, "my-service.example.com", domain, "domain should be passed through")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewManager(context.Background(), mock)
|
||||||
|
err := m.renew(context.Background(), "my-service.example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Renew_Timeout(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
mock := &mgm.MockClient{
|
||||||
|
RenewExposeFunc: func(ctx context.Context, domain string) error {
|
||||||
|
return ctx.Err()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewManager(ctx, mock)
|
||||||
|
err := m.renew(ctx, "my-service.example.com")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRequest(t *testing.T) {
|
||||||
|
req := &daemonProto.ExposeServiceRequest{
|
||||||
|
Port: 8080,
|
||||||
|
Protocol: daemonProto.ExposeProtocol_EXPOSE_HTTPS,
|
||||||
|
Pin: "123456",
|
||||||
|
Password: "secret",
|
||||||
|
UserGroups: []string{"group1", "group2"},
|
||||||
|
Domain: "custom.example.com",
|
||||||
|
NamePrefix: "my-prefix",
|
||||||
|
}
|
||||||
|
|
||||||
|
exposeReq := NewRequest(req)
|
||||||
|
|
||||||
|
assert.Equal(t, uint16(8080), exposeReq.Port, "port should match")
|
||||||
|
assert.Equal(t, int(daemonProto.ExposeProtocol_EXPOSE_HTTPS), exposeReq.Protocol, "protocol should match")
|
||||||
|
assert.Equal(t, "123456", exposeReq.Pin, "pin should match")
|
||||||
|
assert.Equal(t, "secret", exposeReq.Password, "password should match")
|
||||||
|
assert.Equal(t, []string{"group1", "group2"}, exposeReq.UserGroups, "user groups should match")
|
||||||
|
assert.Equal(t, "custom.example.com", exposeReq.Domain, "domain should match")
|
||||||
|
assert.Equal(t, "my-prefix", exposeReq.NamePrefix, "name prefix should match")
|
||||||
|
}
|
||||||
42
client/internal/expose/request.go
Normal file
42
client/internal/expose/request.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package expose
|
||||||
|
|
||||||
|
import (
|
||||||
|
daemonProto "github.com/netbirdio/netbird/client/proto"
|
||||||
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRequest converts a daemon ExposeServiceRequest to a management ExposeServiceRequest.
|
||||||
|
func NewRequest(req *daemonProto.ExposeServiceRequest) *Request {
|
||||||
|
return &Request{
|
||||||
|
Port: uint16(req.Port),
|
||||||
|
Protocol: int(req.Protocol),
|
||||||
|
Pin: req.Pin,
|
||||||
|
Password: req.Password,
|
||||||
|
UserGroups: req.UserGroups,
|
||||||
|
Domain: req.Domain,
|
||||||
|
NamePrefix: req.NamePrefix,
|
||||||
|
ListenPort: uint16(req.ListenPort),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toClientExposeRequest(req Request) mgm.ExposeRequest {
|
||||||
|
return mgm.ExposeRequest{
|
||||||
|
NamePrefix: req.NamePrefix,
|
||||||
|
Domain: req.Domain,
|
||||||
|
Port: req.Port,
|
||||||
|
Protocol: req.Protocol,
|
||||||
|
Pin: req.Pin,
|
||||||
|
Password: req.Password,
|
||||||
|
UserGroups: req.UserGroups,
|
||||||
|
ListenPort: req.ListenPort,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromClientExposeResponse(response *mgm.ExposeResponse) *Response {
|
||||||
|
return &Response{
|
||||||
|
ServiceName: response.ServiceName,
|
||||||
|
Domain: response.Domain,
|
||||||
|
ServiceURL: response.ServiceURL,
|
||||||
|
PortAutoAssigned: response.PortAutoAssigned,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ type wgIfaceBase interface {
|
|||||||
Address() wgaddr.Address
|
Address() wgaddr.Address
|
||||||
ToInterface() *net.Interface
|
ToInterface() *net.Interface
|
||||||
Up() (*udpmux.UniversalUDPMuxDefault, error)
|
Up() (*udpmux.UniversalUDPMuxDefault, error)
|
||||||
UpdateAddr(newAddr string) error
|
UpdateAddr(newAddr wgaddr.Address) error
|
||||||
GetProxy() wgproxy.Proxy
|
GetProxy() wgproxy.Proxy
|
||||||
GetProxyPort() uint16
|
GetProxyPort() uint16
|
||||||
UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
|
UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ type NetworkChangeListener interface {
|
|||||||
// OnNetworkChanged invoke when network settings has been changed
|
// OnNetworkChanged invoke when network settings has been changed
|
||||||
OnNetworkChanged(string)
|
OnNetworkChanged(string)
|
||||||
SetInterfaceIP(string)
|
SetInterfaceIP(string)
|
||||||
|
SetInterfaceIPv6(string)
|
||||||
}
|
}
|
||||||
|
|||||||
17
client/internal/metrics/connection_type.go
Normal file
17
client/internal/metrics/connection_type.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
// ConnectionType represents the type of peer connection
|
||||||
|
type ConnectionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ConnectionTypeICE represents a direct peer-to-peer connection using ICE
|
||||||
|
ConnectionTypeICE ConnectionType = "ice"
|
||||||
|
|
||||||
|
// ConnectionTypeRelay represents a relayed connection
|
||||||
|
ConnectionTypeRelay ConnectionType = "relay"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the connection type
|
||||||
|
func (c ConnectionType) String() string {
|
||||||
|
return string(c)
|
||||||
|
}
|
||||||
51
client/internal/metrics/deployment_type.go
Normal file
51
client/internal/metrics/deployment_type.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeploymentType represents the type of NetBird deployment
|
||||||
|
type DeploymentType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DeploymentTypeUnknown represents an unknown or uninitialized deployment type
|
||||||
|
DeploymentTypeUnknown DeploymentType = iota
|
||||||
|
|
||||||
|
// DeploymentTypeCloud represents a cloud-hosted NetBird deployment
|
||||||
|
DeploymentTypeCloud
|
||||||
|
|
||||||
|
// DeploymentTypeSelfHosted represents a self-hosted NetBird deployment
|
||||||
|
DeploymentTypeSelfHosted
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the deployment type
|
||||||
|
func (d DeploymentType) String() string {
|
||||||
|
switch d {
|
||||||
|
case DeploymentTypeCloud:
|
||||||
|
return "cloud"
|
||||||
|
case DeploymentTypeSelfHosted:
|
||||||
|
return "selfhosted"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetermineDeploymentType determines if the deployment is cloud or self-hosted
|
||||||
|
// based on the management URL string
|
||||||
|
func DetermineDeploymentType(managementURL string) DeploymentType {
|
||||||
|
if managementURL == "" {
|
||||||
|
return DeploymentTypeUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(managementURL)
|
||||||
|
if err != nil {
|
||||||
|
return DeploymentTypeSelfHosted
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(u.Hostname()) == "api.netbird.io" {
|
||||||
|
return DeploymentTypeCloud
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeploymentTypeSelfHosted
|
||||||
|
}
|
||||||
93
client/internal/metrics/env.go
Normal file
93
client/internal/metrics/env.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// EnvMetricsPushEnabled controls whether collected metrics are pushed to the backend.
|
||||||
|
// Metrics collection itself is always active (for debug bundles).
|
||||||
|
// Disabled by default. Set NB_METRICS_PUSH_ENABLED=true to enable push.
|
||||||
|
EnvMetricsPushEnabled = "NB_METRICS_PUSH_ENABLED"
|
||||||
|
|
||||||
|
// EnvMetricsForceSending if set to true, skips remote configuration fetch and forces metric sending
|
||||||
|
EnvMetricsForceSending = "NB_METRICS_FORCE_SENDING"
|
||||||
|
|
||||||
|
// EnvMetricsConfigURL is the environment variable to override the metrics push config ServerAddress
|
||||||
|
EnvMetricsConfigURL = "NB_METRICS_CONFIG_URL"
|
||||||
|
|
||||||
|
// EnvMetricsServerURL is the environment variable to override the metrics server address.
|
||||||
|
// When set, this takes precedence over the server_url from remote push config.
|
||||||
|
EnvMetricsServerURL = "NB_METRICS_SERVER_URL"
|
||||||
|
|
||||||
|
// EnvMetricsInterval overrides the push interval from the remote config.
|
||||||
|
// Only affects how often metrics are pushed; remote config availability
|
||||||
|
// and version range checks are still respected.
|
||||||
|
// Format: duration string like "1h", "30m", "4h"
|
||||||
|
EnvMetricsInterval = "NB_METRICS_INTERVAL"
|
||||||
|
|
||||||
|
defaultMetricsConfigURL = "https://ingest.netbird.io/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsMetricsPushEnabled returns true if metrics push is enabled via NB_METRICS_PUSH_ENABLED env var.
|
||||||
|
// Disabled by default. Metrics collection is always active for debug bundles.
|
||||||
|
func IsMetricsPushEnabled() bool {
|
||||||
|
enabled, _ := strconv.ParseBool(os.Getenv(EnvMetricsPushEnabled))
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMetricsInterval returns the metrics push interval from NB_METRICS_INTERVAL env var.
|
||||||
|
// Returns 0 if not set or invalid.
|
||||||
|
func getMetricsInterval() time.Duration {
|
||||||
|
intervalStr := os.Getenv(EnvMetricsInterval)
|
||||||
|
if intervalStr == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
interval, err := time.ParseDuration(intervalStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("invalid metrics interval from env %q: %v", intervalStr, err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if interval <= 0 {
|
||||||
|
log.Warnf("invalid metrics interval from env %q: must be positive", intervalStr)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
|
||||||
|
func isForceSending() bool {
|
||||||
|
force, _ := strconv.ParseBool(os.Getenv(EnvMetricsForceSending))
|
||||||
|
return force
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMetricsConfigURL returns the URL to fetch push configuration from
|
||||||
|
func getMetricsConfigURL() string {
|
||||||
|
if envURL := os.Getenv(EnvMetricsConfigURL); envURL != "" {
|
||||||
|
return envURL
|
||||||
|
}
|
||||||
|
return defaultMetricsConfigURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMetricsServerURL returns the metrics server URL from NB_METRICS_SERVER_URL env var.
|
||||||
|
// Returns nil if not set or invalid.
|
||||||
|
func getMetricsServerURL() *url.URL {
|
||||||
|
envURL := os.Getenv(EnvMetricsServerURL)
|
||||||
|
if envURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := url.ParseRequestURI(envURL)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
log.Warnf("invalid metrics server URL %q: must be an absolute HTTP(S) URL", envURL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
log.Warnf("invalid metrics server URL %q: unsupported scheme %q", envURL, parsed.Scheme)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
219
client/internal/metrics/influxdb.go
Normal file
219
client/internal/metrics/influxdb.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxSampleAge = 5 * 24 * time.Hour // drop samples older than 5 days
|
||||||
|
maxBufferSize = 5 * 1024 * 1024 // drop oldest samples when estimated size exceeds 5 MB
|
||||||
|
// estimatedSampleSize is a rough per-sample memory estimate (measurement + tags + fields + timestamp)
|
||||||
|
estimatedSampleSize = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
// influxSample is a single InfluxDB line protocol entry.
|
||||||
|
type influxSample struct {
|
||||||
|
measurement string
|
||||||
|
tags string
|
||||||
|
fields map[string]float64
|
||||||
|
timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// influxDBMetrics collects metric events as timestamped samples.
|
||||||
|
// Each event is recorded with its exact timestamp, pushed once, then cleared.
|
||||||
|
type influxDBMetrics struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
samples []influxSample
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInfluxDBMetrics() metricsImplementation {
|
||||||
|
return &influxDBMetrics{}
|
||||||
|
}
|
||||||
|
func (m *influxDBMetrics) RecordConnectionStages(
|
||||||
|
_ context.Context,
|
||||||
|
agentInfo AgentInfo,
|
||||||
|
connectionPairID string,
|
||||||
|
connectionType ConnectionType,
|
||||||
|
isReconnection bool,
|
||||||
|
timestamps ConnectionStageTimestamps,
|
||||||
|
) {
|
||||||
|
var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64
|
||||||
|
|
||||||
|
if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() {
|
||||||
|
signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
|
||||||
|
connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
|
||||||
|
totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptType := "initial"
|
||||||
|
if isReconnection {
|
||||||
|
attemptType = "reconnection"
|
||||||
|
}
|
||||||
|
|
||||||
|
connTypeStr := connectionType.String()
|
||||||
|
tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s",
|
||||||
|
agentInfo.DeploymentType.String(),
|
||||||
|
connTypeStr,
|
||||||
|
attemptType,
|
||||||
|
agentInfo.Version,
|
||||||
|
agentInfo.OS,
|
||||||
|
agentInfo.Arch,
|
||||||
|
agentInfo.peerID,
|
||||||
|
connectionPairID,
|
||||||
|
)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.samples = append(m.samples, influxSample{
|
||||||
|
measurement: "netbird_peer_connection",
|
||||||
|
tags: tags,
|
||||||
|
fields: map[string]float64{
|
||||||
|
"signaling_to_connection_seconds": signalingReceivedToConnection,
|
||||||
|
"connection_to_wg_handshake_seconds": connectionToWgHandshake,
|
||||||
|
"total_seconds": totalDuration,
|
||||||
|
},
|
||||||
|
timestamp: now,
|
||||||
|
})
|
||||||
|
m.trimLocked()
|
||||||
|
|
||||||
|
log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs",
|
||||||
|
agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) {
|
||||||
|
tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s",
|
||||||
|
agentInfo.DeploymentType.String(),
|
||||||
|
agentInfo.Version,
|
||||||
|
agentInfo.OS,
|
||||||
|
agentInfo.Arch,
|
||||||
|
agentInfo.peerID,
|
||||||
|
)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.samples = append(m.samples, influxSample{
|
||||||
|
measurement: "netbird_sync",
|
||||||
|
tags: tags,
|
||||||
|
fields: map[string]float64{
|
||||||
|
"duration_seconds": duration.Seconds(),
|
||||||
|
},
|
||||||
|
timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
m.trimLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
|
||||||
|
result := "success"
|
||||||
|
if !success {
|
||||||
|
result = "failure"
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := fmt.Sprintf("deployment_type=%s,result=%s,version=%s,os=%s,arch=%s,peer_id=%s",
|
||||||
|
agentInfo.DeploymentType.String(),
|
||||||
|
result,
|
||||||
|
agentInfo.Version,
|
||||||
|
agentInfo.OS,
|
||||||
|
agentInfo.Arch,
|
||||||
|
agentInfo.peerID,
|
||||||
|
)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.samples = append(m.samples, influxSample{
|
||||||
|
measurement: "netbird_login",
|
||||||
|
tags: tags,
|
||||||
|
fields: map[string]float64{
|
||||||
|
"duration_seconds": duration.Seconds(),
|
||||||
|
},
|
||||||
|
timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
m.trimLocked()
|
||||||
|
|
||||||
|
log.Tracef("login metrics [%s, %s]: duration=%.3fs", agentInfo.DeploymentType.String(), result, duration.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export writes pending samples in InfluxDB line protocol format.
|
||||||
|
// Format: measurement,tag=val,tag=val field=val,field=val timestamp_ns
|
||||||
|
func (m *influxDBMetrics) Export(w io.Writer) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
samples := make([]influxSample, len(m.samples))
|
||||||
|
copy(samples, m.samples)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
for _, s := range samples {
|
||||||
|
if _, err := fmt.Fprintf(w, "%s,%s ", s.measurement, s.tags); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sortedKeys := slices.Sorted(maps.Keys(s.fields))
|
||||||
|
first := true
|
||||||
|
for _, k := range sortedKeys {
|
||||||
|
if !first {
|
||||||
|
if _, err := fmt.Fprint(w, ","); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "%s=%g", k, s.fields[k]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintf(w, " %d\n", s.timestamp.UnixNano()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears pending samples after a successful push
|
||||||
|
func (m *influxDBMetrics) Reset() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.samples = m.samples[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimLocked removes samples that exceed age or size limits.
|
||||||
|
// Must be called with m.mu held.
|
||||||
|
func (m *influxDBMetrics) trimLocked() {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// drop samples older than maxSampleAge
|
||||||
|
cutoff := 0
|
||||||
|
for cutoff < len(m.samples) && now.Sub(m.samples[cutoff].timestamp) > maxSampleAge {
|
||||||
|
cutoff++
|
||||||
|
}
|
||||||
|
if cutoff > 0 {
|
||||||
|
copy(m.samples, m.samples[cutoff:])
|
||||||
|
m.samples = m.samples[:len(m.samples)-cutoff]
|
||||||
|
log.Debugf("influxdb metrics: dropped %d samples older than %s", cutoff, maxSampleAge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drop oldest samples if estimated size exceeds maxBufferSize
|
||||||
|
maxSamples := maxBufferSize / estimatedSampleSize
|
||||||
|
if len(m.samples) > maxSamples {
|
||||||
|
drop := len(m.samples) - maxSamples
|
||||||
|
copy(m.samples, m.samples[drop:])
|
||||||
|
m.samples = m.samples[:maxSamples]
|
||||||
|
log.Debugf("influxdb metrics: dropped %d oldest samples to stay under %d MB size limit", drop, maxBufferSize/(1024*1024))
|
||||||
|
}
|
||||||
|
}
|
||||||
229
client/internal/metrics/influxdb_test.go
Normal file
229
client/internal/metrics/influxdb_test.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInfluxDBMetrics_RecordAndExport(t *testing.T) {
|
||||||
|
m := newInfluxDBMetrics().(*influxDBMetrics)
|
||||||
|
|
||||||
|
agentInfo := AgentInfo{
|
||||||
|
DeploymentType: DeploymentTypeCloud,
|
||||||
|
Version: "1.0.0",
|
||||||
|
OS: "linux",
|
||||||
|
Arch: "amd64",
|
||||||
|
peerID: "abc123",
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := ConnectionStageTimestamps{
|
||||||
|
SignalingReceived: time.Now().Add(-3 * time.Second),
|
||||||
|
ConnectionReady: time.Now().Add(-2 * time.Second),
|
||||||
|
WgHandshakeSuccess: time.Now().Add(-1 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := m.Export(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
assert.Contains(t, output, "netbird_peer_connection,")
|
||||||
|
assert.Contains(t, output, "connection_to_wg_handshake_seconds=")
|
||||||
|
assert.Contains(t, output, "signaling_to_connection_seconds=")
|
||||||
|
assert.Contains(t, output, "total_seconds=")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) {
|
||||||
|
m := newInfluxDBMetrics().(*influxDBMetrics)
|
||||||
|
|
||||||
|
agentInfo := AgentInfo{
|
||||||
|
DeploymentType: DeploymentTypeCloud,
|
||||||
|
Version: "1.0.0",
|
||||||
|
OS: "linux",
|
||||||
|
Arch: "amd64",
|
||||||
|
peerID: "abc123",
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := ConnectionStageTimestamps{
|
||||||
|
SignalingReceived: time.Now().Add(-3 * time.Second),
|
||||||
|
ConnectionReady: time.Now().Add(-2 * time.Second),
|
||||||
|
WgHandshakeSuccess: time.Now().Add(-1 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record multiple times and verify consistent field order
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := m.Export(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||||
|
require.Len(t, lines, 10)
|
||||||
|
|
||||||
|
// Extract field portion from each line and verify they're all identical
|
||||||
|
var fieldSections []string
|
||||||
|
for _, line := range lines {
|
||||||
|
parts := strings.SplitN(line, " ", 3)
|
||||||
|
require.Len(t, parts, 3, "each line should have measurement, fields, timestamp")
|
||||||
|
fieldSections = append(fieldSections, parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 1; i < len(fieldSections); i++ {
|
||||||
|
assert.Equal(t, fieldSections[0], fieldSections[i], "field order should be deterministic across samples")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fields should be alphabetically sorted
|
||||||
|
assert.True(t, strings.HasPrefix(fieldSections[0], "connection_to_wg_handshake_seconds="),
|
||||||
|
"fields should be sorted: connection_to_wg < signaling_to < total")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfluxDBMetrics_RecordSyncDuration(t *testing.T) {
|
||||||
|
m := newInfluxDBMetrics().(*influxDBMetrics)
|
||||||
|
|
||||||
|
agentInfo := AgentInfo{
|
||||||
|
DeploymentType: DeploymentTypeSelfHosted,
|
||||||
|
Version: "2.0.0",
|
||||||
|
OS: "darwin",
|
||||||
|
Arch: "arm64",
|
||||||
|
peerID: "def456",
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordSyncDuration(context.Background(), agentInfo, 1500*time.Millisecond)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := m.Export(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
assert.Contains(t, output, "netbird_sync,")
|
||||||
|
assert.Contains(t, output, "duration_seconds=1.5")
|
||||||
|
assert.Contains(t, output, "deployment_type=selfhosted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfluxDBMetrics_Reset(t *testing.T) {
|
||||||
|
m := newInfluxDBMetrics().(*influxDBMetrics)
|
||||||
|
|
||||||
|
agentInfo := AgentInfo{
|
||||||
|
DeploymentType: DeploymentTypeCloud,
|
||||||
|
Version: "1.0.0",
|
||||||
|
OS: "linux",
|
||||||
|
Arch: "amd64",
|
||||||
|
peerID: "abc123",
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordSyncDuration(context.Background(), agentInfo, time.Second)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := m.Export(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, buf.String())
|
||||||
|
|
||||||
|
m.Reset()
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
err = m.Export(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, buf.String(), "should be empty after reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfluxDBMetrics_ExportEmpty(t *testing.T) {
|
||||||
|
m := newInfluxDBMetrics().(*influxDBMetrics)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := m.Export(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfluxDBMetrics_TrimByAge(t *testing.T) {
|
||||||
|
m := newInfluxDBMetrics().(*influxDBMetrics)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.samples = append(m.samples, influxSample{
|
||||||
|
measurement: "old",
|
||||||
|
tags: "t=1",
|
||||||
|
fields: map[string]float64{"v": 1},
|
||||||
|
timestamp: time.Now().Add(-maxSampleAge - time.Hour),
|
||||||
|
})
|
||||||
|
m.trimLocked()
|
||||||
|
remaining := len(m.samples)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
assert.Equal(t, 0, remaining, "old samples should be trimmed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfluxDBMetrics_RecordLoginDuration(t *testing.T) {
|
||||||
|
m := newInfluxDBMetrics().(*influxDBMetrics)
|
||||||
|
|
||||||
|
agentInfo := AgentInfo{
|
||||||
|
DeploymentType: DeploymentTypeCloud,
|
||||||
|
Version: "1.0.0",
|
||||||
|
OS: "linux",
|
||||||
|
Arch: "amd64",
|
||||||
|
peerID: "abc123",
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordLoginDuration(context.Background(), agentInfo, 2500*time.Millisecond, true)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := m.Export(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
assert.Contains(t, output, "netbird_login,")
|
||||||
|
assert.Contains(t, output, "duration_seconds=2.5")
|
||||||
|
assert.Contains(t, output, "result=success")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfluxDBMetrics_RecordLoginDurationFailure(t *testing.T) {
|
||||||
|
m := newInfluxDBMetrics().(*influxDBMetrics)
|
||||||
|
|
||||||
|
agentInfo := AgentInfo{
|
||||||
|
DeploymentType: DeploymentTypeSelfHosted,
|
||||||
|
Version: "1.0.0",
|
||||||
|
OS: "darwin",
|
||||||
|
Arch: "arm64",
|
||||||
|
peerID: "xyz789",
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordLoginDuration(context.Background(), agentInfo, 5*time.Second, false)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := m.Export(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
assert.Contains(t, output, "netbird_login,")
|
||||||
|
assert.Contains(t, output, "result=failure")
|
||||||
|
assert.Contains(t, output, "deployment_type=selfhosted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfluxDBMetrics_TrimBySize(t *testing.T) {
|
||||||
|
m := newInfluxDBMetrics().(*influxDBMetrics)
|
||||||
|
|
||||||
|
maxSamples := maxBufferSize / estimatedSampleSize
|
||||||
|
m.mu.Lock()
|
||||||
|
for i := 0; i < maxSamples+100; i++ {
|
||||||
|
m.samples = append(m.samples, influxSample{
|
||||||
|
measurement: "test",
|
||||||
|
tags: "t=1",
|
||||||
|
fields: map[string]float64{"v": float64(i)},
|
||||||
|
timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
m.trimLocked()
|
||||||
|
remaining := len(m.samples)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
assert.Equal(t, maxSamples, remaining, "should trim to max samples")
|
||||||
|
}
|
||||||
16
client/internal/metrics/infra/.env.example
Normal file
16
client/internal/metrics/infra/.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Copy to .env and adjust values before running docker compose
|
||||||
|
|
||||||
|
# InfluxDB admin (server-side only, never exposed to clients)
|
||||||
|
INFLUXDB_ADMIN_PASSWORD=changeme
|
||||||
|
INFLUXDB_ADMIN_TOKEN=changeme
|
||||||
|
|
||||||
|
# Grafana admin credentials
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
GRAFANA_ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# Remote config served by ingest at /config
|
||||||
|
# Set CONFIG_METRICS_SERVER_URL to the ingest server's public address to enable
|
||||||
|
CONFIG_METRICS_SERVER_URL=
|
||||||
|
CONFIG_VERSION_SINCE=0.0.0
|
||||||
|
CONFIG_VERSION_UNTIL=99.99.99
|
||||||
|
CONFIG_PERIOD_MINUTES=5
|
||||||
1
client/internal/metrics/infra/.gitignore
vendored
Normal file
1
client/internal/metrics/infra/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
194
client/internal/metrics/infra/README.md
Normal file
194
client/internal/metrics/infra/README.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Client Metrics
|
||||||
|
|
||||||
|
Internal documentation for the NetBird client metrics system.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Client metrics track connection performance and sync durations using InfluxDB line protocol (`influxdb.go`). Each event is pushed once then cleared.
|
||||||
|
|
||||||
|
Metrics collection is always active (for debug bundles). Push to backend is:
|
||||||
|
- Disabled by default (opt-in via `NB_METRICS_PUSH_ENABLED=true`)
|
||||||
|
- Managed at daemon layer (survives engine restarts)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Layer Separation
|
||||||
|
|
||||||
|
```text
|
||||||
|
Daemon Layer (connect.go)
|
||||||
|
├─ Creates ClientMetrics instance once
|
||||||
|
├─ Starts/stops push lifecycle
|
||||||
|
└─ Updates AgentInfo on profile switch
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Engine Layer (engine.go)
|
||||||
|
└─ Records metrics via ClientMetrics methods
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingest Server
|
||||||
|
|
||||||
|
Clients do not talk to InfluxDB directly. An ingest server sits between clients and InfluxDB:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client ──POST──▶ Ingest Server (:8087) ──▶ InfluxDB (internal)
|
||||||
|
│
|
||||||
|
├─ Validates line protocol
|
||||||
|
├─ Allowlists measurements, fields, and tags
|
||||||
|
├─ Rejects out-of-bound values
|
||||||
|
└─ Serves remote config at /config
|
||||||
|
```
|
||||||
|
|
||||||
|
- **No secret/token-based client auth** — the ingest server holds the InfluxDB token server-side. Clients must send a hashed peer ID via `X-Peer-ID` header.
|
||||||
|
- **InfluxDB is not exposed** — only accessible within the docker network
|
||||||
|
- Source: `ingest/main.go`
|
||||||
|
|
||||||
|
## Metrics Collected
|
||||||
|
|
||||||
|
### Connection Stage Timing
|
||||||
|
|
||||||
|
Measurement: `netbird_peer_connection`
|
||||||
|
|
||||||
|
| Field | Timestamps | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `signaling_to_connection_seconds` | `SignalingReceived → ConnectionReady` | ICE/relay negotiation time after the first signal is received from the remote peer |
|
||||||
|
| `connection_to_wg_handshake_seconds` | `ConnectionReady → WgHandshakeSuccess` | WireGuard cryptographic handshake latency once the transport layer is ready |
|
||||||
|
| `total_seconds` | `SignalingReceived → WgHandshakeSuccess` | End-to-end connection time anchored at the first received signal |
|
||||||
|
|
||||||
|
Tags:
|
||||||
|
- `deployment_type`: "cloud" | "selfhosted" | "unknown"
|
||||||
|
- `connection_type`: "ice" | "relay"
|
||||||
|
- `attempt_type`: "initial" | "reconnection"
|
||||||
|
- `version`: NetBird version string
|
||||||
|
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
|
||||||
|
- `arch`: CPU architecture (amd64, arm64, etc.)
|
||||||
|
|
||||||
|
**Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online.
|
||||||
|
|
||||||
|
### Sync Duration
|
||||||
|
|
||||||
|
Measurement: `netbird_sync`
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `duration_seconds` | Time to process a sync message from management server |
|
||||||
|
|
||||||
|
Tags:
|
||||||
|
- `deployment_type`: "cloud" | "selfhosted" | "unknown"
|
||||||
|
- `version`: NetBird version string
|
||||||
|
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
|
||||||
|
- `arch`: CPU architecture (amd64, arm64, etc.)
|
||||||
|
|
||||||
|
### Login Duration
|
||||||
|
|
||||||
|
Measurement: `netbird_login`
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `duration_seconds` | Time to complete the login/auth exchange with management server |
|
||||||
|
|
||||||
|
Tags:
|
||||||
|
- `deployment_type`: "cloud" | "selfhosted" | "unknown"
|
||||||
|
- `result`: "success" | "failure"
|
||||||
|
- `version`: NetBird version string
|
||||||
|
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
|
||||||
|
- `arch`: CPU architecture (amd64, arm64, etc.)
|
||||||
|
|
||||||
|
## Buffer Limits
|
||||||
|
|
||||||
|
The InfluxDB backend limits in-memory sample storage to prevent unbounded growth when pushes fail:
|
||||||
|
- **Max age:** Samples older than 5 days are dropped
|
||||||
|
- **Max size:** Estimated buffer size capped at 5 MB (~20k samples)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Client Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `NB_METRICS_PUSH_ENABLED` | `false` | Enable metrics push to backend |
|
||||||
|
| `NB_METRICS_SERVER_URL` | *(from remote config)* | Ingest server URL (e.g., `https://ingest.netbird.io`) |
|
||||||
|
| `NB_METRICS_INTERVAL` | *(from remote config)* | Push interval (e.g., "1m", "30m", "4h") |
|
||||||
|
| `NB_METRICS_FORCE_SENDING` | `false` | Skip remote config, push unconditionally |
|
||||||
|
| `NB_METRICS_CONFIG_URL` | `https://ingest.netbird.io/config` | Remote push config URL |
|
||||||
|
|
||||||
|
`NB_METRICS_SERVER_URL` and `NB_METRICS_INTERVAL` override their respective values but do not bypass remote config eligibility checks (version range). Use `NB_METRICS_FORCE_SENDING=true` to skip all remote config gating.
|
||||||
|
|
||||||
|
### Ingest Server Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `INGEST_LISTEN_ADDR` | `:8087` | Listen address |
|
||||||
|
| `INFLUXDB_URL` | `http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns` | InfluxDB write endpoint |
|
||||||
|
| `INFLUXDB_TOKEN` | *(required)* | InfluxDB auth token (server-side only) |
|
||||||
|
| `CONFIG_METRICS_SERVER_URL` | *(empty — disables /config)* | `server_url` in the remote config JSON (the URL clients push metrics to) |
|
||||||
|
| `CONFIG_VERSION_SINCE` | `0.0.0` | Minimum client version to push metrics |
|
||||||
|
| `CONFIG_VERSION_UNTIL` | `99.99.99` | Maximum client version to push metrics |
|
||||||
|
| `CONFIG_PERIOD_MINUTES` | `5` | Push interval in minutes |
|
||||||
|
|
||||||
|
The ingest server serves a remote config JSON at `GET /config` when `CONFIG_METRICS_SERVER_URL` is set. Clients can use `NB_METRICS_CONFIG_URL=http://<ingest>/config` to fetch it.
|
||||||
|
|
||||||
|
### Configuration Precedence
|
||||||
|
|
||||||
|
For URL and Interval, the precedence is:
|
||||||
|
1. **Environment variable** - `NB_METRICS_SERVER_URL` / `NB_METRICS_INTERVAL`
|
||||||
|
2. **Remote config** - fetched from `NB_METRICS_CONFIG_URL`
|
||||||
|
3. **Default** - 5 minute interval, URL from remote config
|
||||||
|
|
||||||
|
## Push Behavior
|
||||||
|
|
||||||
|
1. `StartPush()` spawns background goroutine with timer
|
||||||
|
2. First push happens immediately on startup
|
||||||
|
3. Periodically: `push()` → `Export()` → HTTP POST to ingest server
|
||||||
|
4. On failure: log error, continue (non-blocking)
|
||||||
|
5. On success: `Reset()` clears pushed samples
|
||||||
|
6. `StopPush()` cancels context and waits for goroutine
|
||||||
|
|
||||||
|
Samples are collected with exact timestamps, pushed once, then cleared. No data is resent.
|
||||||
|
|
||||||
|
## Local Development Setup
|
||||||
|
|
||||||
|
### 1. Configure and Start Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From this directory (client/internal/metrics/infra)
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env to set INFLUXDB_ADMIN_PASSWORD, INFLUXDB_ADMIN_TOKEN, and GRAFANA_ADMIN_PASSWORD
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts:
|
||||||
|
- **Ingest server** on http://localhost:8087 — accepts client metrics (requires `X-Peer-ID` header, no secret/token auth)
|
||||||
|
- **InfluxDB** — internal only, not exposed to host
|
||||||
|
- **Grafana** on http://localhost:3001
|
||||||
|
|
||||||
|
### 2. Configure Client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NB_METRICS_PUSH_ENABLED=true
|
||||||
|
export NB_METRICS_FORCE_SENDING=true
|
||||||
|
export NB_METRICS_SERVER_URL=http://localhost:8087
|
||||||
|
export NB_METRICS_INTERVAL=1m
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run Client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../../../..
|
||||||
|
go run ./client/ up
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. View in Grafana
|
||||||
|
|
||||||
|
- **InfluxDB dashboard:** http://localhost:3001/d/netbird-influxdb-metrics
|
||||||
|
|
||||||
|
### 5. Verify Data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Query via InfluxDB (using admin token from .env)
|
||||||
|
docker compose exec influxdb influx query \
|
||||||
|
'from(bucket: "metrics") |> range(start: -1h)' \
|
||||||
|
--org netbird
|
||||||
|
|
||||||
|
# Check ingest server health
|
||||||
|
curl http://localhost:8087/health
|
||||||
|
```
|
||||||
69
client/internal/metrics/infra/docker-compose.yml
Normal file
69
client/internal/metrics/infra/docker-compose.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
ingest:
|
||||||
|
container_name: ingest
|
||||||
|
build:
|
||||||
|
context: ./ingest
|
||||||
|
ports:
|
||||||
|
- "8087:8087"
|
||||||
|
environment:
|
||||||
|
- INGEST_LISTEN_ADDR=:8087
|
||||||
|
- INFLUXDB_URL=http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns
|
||||||
|
- INFLUXDB_TOKEN=${INFLUXDB_ADMIN_TOKEN:?required}
|
||||||
|
- CONFIG_METRICS_SERVER_URL=${CONFIG_METRICS_SERVER_URL:-}
|
||||||
|
- CONFIG_VERSION_SINCE=${CONFIG_VERSION_SINCE:-0.0.0}
|
||||||
|
- CONFIG_VERSION_UNTIL=${CONFIG_VERSION_UNTIL:-99.99.99}
|
||||||
|
- CONFIG_PERIOD_MINUTES=${CONFIG_PERIOD_MINUTES:-5}
|
||||||
|
depends_on:
|
||||||
|
- influxdb
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- metrics
|
||||||
|
|
||||||
|
influxdb:
|
||||||
|
container_name: influxdb
|
||||||
|
image: influxdb:2
|
||||||
|
# No ports exposed — only accessible within the metrics network
|
||||||
|
volumes:
|
||||||
|
- influxdb-data:/var/lib/influxdb2
|
||||||
|
- ./influxdb/scripts:/docker-entrypoint-initdb.d
|
||||||
|
environment:
|
||||||
|
- DOCKER_INFLUXDB_INIT_MODE=setup
|
||||||
|
- DOCKER_INFLUXDB_INIT_USERNAME=admin
|
||||||
|
- DOCKER_INFLUXDB_INIT_PASSWORD=${INFLUXDB_ADMIN_PASSWORD:?required}
|
||||||
|
- DOCKER_INFLUXDB_INIT_ORG=netbird
|
||||||
|
- DOCKER_INFLUXDB_INIT_BUCKET=metrics
|
||||||
|
- DOCKER_INFLUXDB_INIT_RETENTION=365d
|
||||||
|
- DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${INFLUXDB_ADMIN_TOKEN:-}
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- metrics
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
container_name: grafana
|
||||||
|
image: grafana/grafana:11.6.0
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin}
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?required}
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
- GF_INSTALL_PLUGINS=
|
||||||
|
- INFLUXDB_ADMIN_TOKEN=${INFLUXDB_ADMIN_TOKEN:-}
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||||
|
depends_on:
|
||||||
|
- influxdb
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- metrics
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
influxdb-data:
|
||||||
|
grafana-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
metrics:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: 'NetBird Dashboards'
|
||||||
|
orgId: 1
|
||||||
|
folder: ''
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 10
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards/json
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
{
|
||||||
|
"uid": "netbird-influxdb-metrics",
|
||||||
|
"title": "NetBird Client Metrics (InfluxDB)",
|
||||||
|
"tags": ["netbird", "connections", "influxdb"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Sync Duration Extremes",
|
||||||
|
"type": "stat",
|
||||||
|
"datasource": {
|
||||||
|
"type": "influxdb",
|
||||||
|
"uid": "influxdb"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> min()\n |> set(key: \"_field\", value: \"Min\")",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> max()\n |> set(key: \"_field\", value: \"Max\")",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "ms",
|
||||||
|
"min": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": ["lastNotNull"]
|
||||||
|
},
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "Total Connection Time Extremes",
|
||||||
|
"type": "stat",
|
||||||
|
"datasource": {
|
||||||
|
"type": "influxdb",
|
||||||
|
"uid": "influxdb"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> min()\n |> set(key: \"_field\", value: \"Min\")",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> max()\n |> set(key: \"_field\", value: \"Max\")",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "ms",
|
||||||
|
"min": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": ["lastNotNull"]
|
||||||
|
},
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Sync Duration",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {
|
||||||
|
"type": "influxdb",
|
||||||
|
"uid": "influxdb"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 8
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> set(key: \"_field\", value: \"Sync Duration\")",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "ms",
|
||||||
|
"min": 0,
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "points",
|
||||||
|
"pointSize": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "ICE vs Relay",
|
||||||
|
"type": "piechart",
|
||||||
|
"datasource": {
|
||||||
|
"type": "influxdb",
|
||||||
|
"uid": "influxdb"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 8
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> drop(columns: [\"deployment_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> group(columns: [\"connection_pair_id\"])\n |> last()\n |> group(columns: [\"connection_type\"])\n |> count()",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": ["lastNotNull"]
|
||||||
|
},
|
||||||
|
"pieType": "donut",
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Connection Stage Durations (avg)",
|
||||||
|
"type": "bargauge",
|
||||||
|
"datasource": {
|
||||||
|
"type": "influxdb",
|
||||||
|
"uid": "influxdb"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"signaling_to_connection_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> mean()\n |> drop(columns: [\"_start\", \"_stop\", \"_measurement\", \"_time\", \"_field\"])\n |> rename(columns: {_value: \"Avg Signaling to Connection\"})",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"connection_to_wg_handshake_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> mean()\n |> drop(columns: [\"_start\", \"_stop\", \"_measurement\", \"_time\", \"_field\"])\n |> rename(columns: {_value: \"Avg Connection to WG Handshake\"})",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "ms",
|
||||||
|
"min": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": ["lastNotNull"]
|
||||||
|
},
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"displayMode": "gradient"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Total Connection Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {
|
||||||
|
"type": "influxdb",
|
||||||
|
"uid": "influxdb"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> set(key: \"_field\", value: \"Total Connection Time\")",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "ms",
|
||||||
|
"min": 0,
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "points",
|
||||||
|
"pointSize": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "Login Duration",
|
||||||
|
"type": "timeseries",
|
||||||
|
"datasource": {
|
||||||
|
"type": "influxdb",
|
||||||
|
"uid": "influxdb"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 24
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_login\" and r._field == \"duration_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> set(key: \"_field\", value: \"Login Duration\")",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "ms",
|
||||||
|
"min": 0,
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "points",
|
||||||
|
"pointSize": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"title": "Login Success vs Failure",
|
||||||
|
"type": "piechart",
|
||||||
|
"datasource": {
|
||||||
|
"type": "influxdb",
|
||||||
|
"uid": "influxdb"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 24
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_login\" and r._field == \"duration_seconds\")\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> group(columns: [\"result\"])\n |> count()",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": ["lastNotNull"]
|
||||||
|
},
|
||||||
|
"pieType": "donut",
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schemaVersion": 27,
|
||||||
|
"version": 2,
|
||||||
|
"refresh": "30s"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: InfluxDB
|
||||||
|
uid: influxdb
|
||||||
|
type: influxdb
|
||||||
|
access: proxy
|
||||||
|
url: http://influxdb:8086
|
||||||
|
editable: true
|
||||||
|
jsonData:
|
||||||
|
version: Flux
|
||||||
|
organization: netbird
|
||||||
|
defaultBucket: metrics
|
||||||
|
secureJsonData:
|
||||||
|
token: ${INFLUXDB_ADMIN_TOKEN}
|
||||||
25
client/internal/metrics/infra/influxdb/scripts/create-tokens.sh
Executable file
25
client/internal/metrics/infra/influxdb/scripts/create-tokens.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Creates a scoped InfluxDB read-only token for Grafana.
|
||||||
|
# Clients do not need a token — they push via the ingest server.
|
||||||
|
|
||||||
|
BUCKET_ID=$(influx bucket list --org netbird --name metrics --json | grep -oP '"id"\s*:\s*"\K[^"]+' | head -1)
|
||||||
|
ORG_ID=$(influx org list --name netbird --json | grep -oP '"id"\s*:\s*"\K[^"]+' | head -1)
|
||||||
|
|
||||||
|
if [[ -z "$BUCKET_ID" ]] || [[ -z "$ORG_ID" ]]; then
|
||||||
|
echo "ERROR: Could not determine bucket or org ID" >&2
|
||||||
|
echo "BUCKET_ID=$BUCKET_ID ORG_ID=$ORG_ID" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create read-only token for Grafana
|
||||||
|
READ_TOKEN=$(influx auth create \
|
||||||
|
--org netbird \
|
||||||
|
--read-bucket "$BUCKET_ID" \
|
||||||
|
--description "Grafana read-only token" \
|
||||||
|
--json | grep -oP '"token"\s*:\s*"\K[^"]+' | head -1)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo "GRAFANA READ-ONLY TOKEN:"
|
||||||
|
echo "$READ_TOKEN"
|
||||||
|
echo "============================================"
|
||||||
10
client/internal/metrics/infra/ingest/Dockerfile
Normal file
10
client/internal/metrics/infra/ingest/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod main.go ./
|
||||||
|
RUN CGO_ENABLED=0 go build -o ingest .
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN adduser -D -H ingest
|
||||||
|
COPY --from=build /app/ingest /usr/local/bin/ingest
|
||||||
|
USER ingest
|
||||||
|
ENTRYPOINT ["ingest"]
|
||||||
11
client/internal/metrics/infra/ingest/go.mod
Normal file
11
client/internal/metrics/infra/ingest/go.mod
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module github.com/netbirdio/netbird/client/internal/metrics/infra/ingest
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require github.com/stretchr/testify v1.11.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
10
client/internal/metrics/infra/ingest/go.sum
Normal file
10
client/internal/metrics/infra/ingest/go.sum
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
355
client/internal/metrics/infra/ingest/main.go
Normal file
355
client/internal/metrics/infra/ingest/main.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultListenAddr = ":8087"
|
||||||
|
defaultInfluxDBURL = "http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns"
|
||||||
|
maxBodySize = 50 * 1024 * 1024 // 50 MB max request body
|
||||||
|
maxDurationSeconds = 300.0 // reject any duration field > 5 minutes
|
||||||
|
peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars
|
||||||
|
maxTagValueLength = 64 // reject tag values longer than this
|
||||||
|
)
|
||||||
|
|
||||||
|
type measurementSpec struct {
|
||||||
|
allowedFields map[string]bool
|
||||||
|
allowedTags map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedMeasurements = map[string]measurementSpec{
|
||||||
|
"netbird_peer_connection": {
|
||||||
|
allowedFields: map[string]bool{
|
||||||
|
"signaling_to_connection_seconds": true,
|
||||||
|
"connection_to_wg_handshake_seconds": true,
|
||||||
|
"total_seconds": true,
|
||||||
|
},
|
||||||
|
allowedTags: map[string]bool{
|
||||||
|
"deployment_type": true,
|
||||||
|
"connection_type": true,
|
||||||
|
"attempt_type": true,
|
||||||
|
"version": true,
|
||||||
|
"os": true,
|
||||||
|
"arch": true,
|
||||||
|
"peer_id": true,
|
||||||
|
"connection_pair_id": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"netbird_sync": {
|
||||||
|
allowedFields: map[string]bool{
|
||||||
|
"duration_seconds": true,
|
||||||
|
},
|
||||||
|
allowedTags: map[string]bool{
|
||||||
|
"deployment_type": true,
|
||||||
|
"version": true,
|
||||||
|
"os": true,
|
||||||
|
"arch": true,
|
||||||
|
"peer_id": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"netbird_login": {
|
||||||
|
allowedFields: map[string]bool{
|
||||||
|
"duration_seconds": true,
|
||||||
|
},
|
||||||
|
allowedTags: map[string]bool{
|
||||||
|
"deployment_type": true,
|
||||||
|
"result": true,
|
||||||
|
"version": true,
|
||||||
|
"os": true,
|
||||||
|
"arch": true,
|
||||||
|
"peer_id": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listenAddr := envOr("INGEST_LISTEN_ADDR", defaultListenAddr)
|
||||||
|
influxURL := envOr("INFLUXDB_URL", defaultInfluxDBURL)
|
||||||
|
influxToken := os.Getenv("INFLUXDB_TOKEN")
|
||||||
|
|
||||||
|
if influxToken == "" {
|
||||||
|
log.Fatal("INFLUXDB_TOKEN is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
http.HandleFunc("/", handleIngest(client, influxURL, influxToken))
|
||||||
|
|
||||||
|
// Build config JSON once at startup from env vars
|
||||||
|
configJSON := buildConfigJSON()
|
||||||
|
if configJSON != nil {
|
||||||
|
log.Printf("serving remote config at /config")
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if configJSON == nil {
|
||||||
|
http.Error(w, "config not configured", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(configJSON) //nolint:errcheck
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, "ok") //nolint:errcheck
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("ingest server listening on %s, forwarding to %s", listenAddr, influxURL)
|
||||||
|
if err := http.ListenAndServe(listenAddr, nil); err != nil { //nolint:gosec
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIngest(client *http.Client, influxURL, influxToken string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAuth(r); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body) > maxBodySize {
|
||||||
|
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
validated, err := validateLineProtocol(body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARN validation failed from %s: %v", r.RemoteAddr, err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardToInflux(w, r, client, influxURL, influxToken, validated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forwardToInflux(w http.ResponseWriter, r *http.Request, client *http.Client, influxURL, influxToken string, body []byte) {
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, influxURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR create request: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
req.Header.Set("Authorization", "Token "+influxToken)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR forward to influxdb: %v", err)
|
||||||
|
http.Error(w, "upstream error", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
io.Copy(w, resp.Body) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAuth checks that the X-Peer-ID header contains a valid hashed peer ID.
|
||||||
|
func validateAuth(r *http.Request) error {
|
||||||
|
peerID := r.Header.Get("X-Peer-ID")
|
||||||
|
if peerID == "" {
|
||||||
|
return fmt.Errorf("missing X-Peer-ID header")
|
||||||
|
}
|
||||||
|
if len(peerID) != peerIDLength {
|
||||||
|
return fmt.Errorf("invalid X-Peer-ID header length")
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(peerID); err != nil {
|
||||||
|
return fmt.Errorf("invalid X-Peer-ID header format")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBody reads the request body, decompressing gzip if Content-Encoding indicates it.
|
||||||
|
func readBody(r *http.Request) ([]byte, error) {
|
||||||
|
reader := io.LimitReader(r.Body, maxBodySize+1)
|
||||||
|
|
||||||
|
if r.Header.Get("Content-Encoding") == "gzip" {
|
||||||
|
gz, err := gzip.NewReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid gzip: %w", err)
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
reader = io.LimitReader(gz, maxBodySize+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return io.ReadAll(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateLineProtocol parses InfluxDB line protocol lines,
|
||||||
|
// whitelists measurements and fields, and checks value bounds.
|
||||||
|
func validateLineProtocol(body []byte) ([]byte, error) {
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||||
|
var valid []string
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateLine(line); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
valid = append(valid, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valid) == 0 {
|
||||||
|
return nil, fmt.Errorf("no valid lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(strings.Join(valid, "\n") + "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLine(line string) error {
|
||||||
|
// line protocol: measurement,tag=val,tag=val field=val,field=val timestamp
|
||||||
|
parts := strings.SplitN(line, " ", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fmt.Errorf("invalid line protocol: %q", truncate(line, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parts[0] is "measurement,tag=val,tag=val"
|
||||||
|
measurementAndTags := strings.Split(parts[0], ",")
|
||||||
|
measurement := measurementAndTags[0]
|
||||||
|
|
||||||
|
spec, ok := allowedMeasurements[measurement]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown measurement: %q", measurement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate tags (everything after measurement name in parts[0])
|
||||||
|
for _, tagPair := range measurementAndTags[1:] {
|
||||||
|
if err := validateTag(tagPair, measurement, spec.allowedTags); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate fields
|
||||||
|
for _, pair := range strings.Split(parts[1], ",") {
|
||||||
|
if err := validateField(pair, measurement, spec.allowedFields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTag(pair, measurement string, allowedTags map[string]bool) error {
|
||||||
|
kv := strings.SplitN(pair, "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
return fmt.Errorf("invalid tag: %q", pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagName := kv[0]
|
||||||
|
if !allowedTags[tagName] {
|
||||||
|
return fmt.Errorf("unknown tag %q in measurement %q", tagName, measurement)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(kv[1]) > maxTagValueLength {
|
||||||
|
return fmt.Errorf("tag value too long for %q: %d > %d", tagName, len(kv[1]), maxTagValueLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateField(pair, measurement string, allowedFields map[string]bool) error {
|
||||||
|
kv := strings.SplitN(pair, "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
return fmt.Errorf("invalid field: %q", pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldName := kv[0]
|
||||||
|
if !allowedFields[fieldName] {
|
||||||
|
return fmt.Errorf("unknown field %q in measurement %q", fieldName, measurement)
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := strconv.ParseFloat(kv[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid field value %q for %q", kv[1], fieldName)
|
||||||
|
}
|
||||||
|
if val < 0 {
|
||||||
|
return fmt.Errorf("negative value for %q: %g", fieldName, val)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(fieldName, "_seconds") && val > maxDurationSeconds {
|
||||||
|
return fmt.Errorf("%q too large: %g > %g", fieldName, val, maxDurationSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildConfigJSON builds the remote config JSON from env vars.
|
||||||
|
// Returns nil if required vars are not set.
|
||||||
|
func buildConfigJSON() []byte {
|
||||||
|
serverURL := os.Getenv("CONFIG_METRICS_SERVER_URL")
|
||||||
|
versionSince := envOr("CONFIG_VERSION_SINCE", "0.0.0")
|
||||||
|
versionUntil := envOr("CONFIG_VERSION_UNTIL", "99.99.99")
|
||||||
|
periodMinutes := envOr("CONFIG_PERIOD_MINUTES", "5")
|
||||||
|
|
||||||
|
if serverURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
period, err := strconv.Atoi(periodMinutes)
|
||||||
|
if err != nil || period <= 0 {
|
||||||
|
log.Printf("WARN invalid CONFIG_PERIOD_MINUTES: %q, using 5", periodMinutes)
|
||||||
|
period = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := map[string]any{
|
||||||
|
"server_url": serverURL,
|
||||||
|
"version-since": versionSince,
|
||||||
|
"version-until": versionUntil,
|
||||||
|
"period_minutes": period,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR failed to marshal config: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, defaultVal string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
124
client/internal/metrics/infra/ingest/main_test.go
Normal file
124
client/internal/metrics/infra/ingest/main_test.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateLine_ValidPeerConnection(t *testing.T) {
|
||||||
|
line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abcdef0123456789,connection_pair_id=pair1234 signaling_to_connection_seconds=1.5,connection_to_wg_handshake_seconds=0.5,total_seconds=2 1234567890`
|
||||||
|
assert.NoError(t, validateLine(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLine_ValidSync(t *testing.T) {
|
||||||
|
line := `netbird_sync,deployment_type=selfhosted,version=2.0.0,os=darwin,arch=arm64,peer_id=abcdef0123456789 duration_seconds=1.5 1234567890`
|
||||||
|
assert.NoError(t, validateLine(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLine_ValidLogin(t *testing.T) {
|
||||||
|
line := `netbird_login,deployment_type=cloud,result=success,version=1.0.0,os=linux,arch=amd64,peer_id=abcdef0123456789 duration_seconds=3.2 1234567890`
|
||||||
|
assert.NoError(t, validateLine(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLine_UnknownMeasurement(t *testing.T) {
|
||||||
|
line := `unknown_metric,foo=bar value=1 1234567890`
|
||||||
|
err := validateLine(line)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unknown measurement")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLine_UnknownTag(t *testing.T) {
|
||||||
|
line := `netbird_sync,deployment_type=cloud,evil_tag=injected,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890`
|
||||||
|
err := validateLine(line)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unknown tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLine_UnknownField(t *testing.T) {
|
||||||
|
line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc injected_field=1 1234567890`
|
||||||
|
err := validateLine(line)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unknown field")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLine_NegativeValue(t *testing.T) {
|
||||||
|
line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=-1.5 1234567890`
|
||||||
|
err := validateLine(line)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLine_DurationTooLarge(t *testing.T) {
|
||||||
|
line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=999 1234567890`
|
||||||
|
err := validateLine(line)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLine_TotalSecondsTooLarge(t *testing.T) {
|
||||||
|
line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=500 1234567890`
|
||||||
|
err := validateLine(line)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLine_TagValueTooLong(t *testing.T) {
|
||||||
|
longTag := strings.Repeat("a", maxTagValueLength+1)
|
||||||
|
line := `netbird_sync,deployment_type=` + longTag + `,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890`
|
||||||
|
err := validateLine(line)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "tag value too long")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLineProtocol_MultipleLines(t *testing.T) {
|
||||||
|
body := []byte(
|
||||||
|
"netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890\n" +
|
||||||
|
"netbird_login,deployment_type=cloud,result=success,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=2.0 1234567890\n",
|
||||||
|
)
|
||||||
|
validated, err := validateLineProtocol(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(validated), "netbird_sync")
|
||||||
|
assert.Contains(t, string(validated), "netbird_login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLineProtocol_RejectsOnBadLine(t *testing.T) {
|
||||||
|
body := []byte(
|
||||||
|
"netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890\n" +
|
||||||
|
"evil_metric,foo=bar value=1 1234567890\n",
|
||||||
|
)
|
||||||
|
_, err := validateLineProtocol(body)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAuth(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
peerID string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid hex", "abcdef0123456789", false},
|
||||||
|
{"empty", "", true},
|
||||||
|
{"too short", "abcdef01234567", true},
|
||||||
|
{"too long", "abcdef01234567890", true},
|
||||||
|
{"invalid hex", "ghijklmnopqrstuv", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
if tt.peerID != "" {
|
||||||
|
r.Header.Set("X-Peer-ID", tt.peerID)
|
||||||
|
}
|
||||||
|
err := validateAuth(r)
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
224
client/internal/metrics/metrics.go
Normal file
224
client/internal/metrics/metrics.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/metrics/remoteconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentInfo holds static information about the agent
|
||||||
|
type AgentInfo struct {
|
||||||
|
DeploymentType DeploymentType
|
||||||
|
Version string
|
||||||
|
OS string // runtime.GOOS (linux, darwin, windows, etc.)
|
||||||
|
Arch string // runtime.GOARCH (amd64, arm64, etc.)
|
||||||
|
peerID string // anonymised peer identifier (SHA-256 of WireGuard public key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// peerIDFromPublicKey returns a truncated SHA-256 hash (8 bytes / 16 hex chars) of the given WireGuard public key.
|
||||||
|
func peerIDFromPublicKey(pubKey string) string {
|
||||||
|
hash := sha256.Sum256([]byte(pubKey))
|
||||||
|
return hex.EncodeToString(hash[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectionPairID returns a deterministic identifier for a connection between two peers.
|
||||||
|
// It sorts the two peer IDs before hashing so the same pair always produces the same ID
|
||||||
|
// regardless of which side computes it.
|
||||||
|
func connectionPairID(peerID1, peerID2 string) string {
|
||||||
|
a, b := peerID1, peerID2
|
||||||
|
if a > b {
|
||||||
|
a, b = b, a
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256([]byte(a + b))
|
||||||
|
return hex.EncodeToString(hash[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricsImplementation defines the internal interface for metrics implementations
|
||||||
|
type metricsImplementation interface {
|
||||||
|
// RecordConnectionStages records connection stage metrics from timestamps
|
||||||
|
RecordConnectionStages(
|
||||||
|
ctx context.Context,
|
||||||
|
agentInfo AgentInfo,
|
||||||
|
connectionPairID string,
|
||||||
|
connectionType ConnectionType,
|
||||||
|
isReconnection bool,
|
||||||
|
timestamps ConnectionStageTimestamps,
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecordSyncDuration records how long it took to process a sync message
|
||||||
|
RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration)
|
||||||
|
|
||||||
|
// RecordLoginDuration records how long the login to management took
|
||||||
|
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
|
||||||
|
|
||||||
|
// Export exports metrics in InfluxDB line protocol format
|
||||||
|
Export(w io.Writer) error
|
||||||
|
|
||||||
|
// Reset clears all collected metrics
|
||||||
|
Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientMetrics struct {
|
||||||
|
impl metricsImplementation
|
||||||
|
|
||||||
|
agentInfo AgentInfo
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
push *Push
|
||||||
|
pushMu sync.Mutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
pushCancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionStageTimestamps holds timestamps for each connection stage
|
||||||
|
type ConnectionStageTimestamps struct {
|
||||||
|
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
|
||||||
|
ConnectionReady time.Time
|
||||||
|
WgHandshakeSuccess time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a human-readable representation of the connection stage timestamps
|
||||||
|
func (c ConnectionStageTimestamps) String() string {
|
||||||
|
return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}",
|
||||||
|
c.SignalingReceived.Format(time.RFC3339Nano),
|
||||||
|
c.ConnectionReady.Format(time.RFC3339Nano),
|
||||||
|
c.WgHandshakeSuccess.Format(time.RFC3339Nano),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordConnectionStages calculates stage durations from timestamps and records them.
|
||||||
|
// remotePubKey is the remote peer's WireGuard public key; it will be hashed for anonymisation.
|
||||||
|
func (c *ClientMetrics) RecordConnectionStages(
|
||||||
|
ctx context.Context,
|
||||||
|
remotePubKey string,
|
||||||
|
connectionType ConnectionType,
|
||||||
|
isReconnection bool,
|
||||||
|
timestamps ConnectionStageTimestamps,
|
||||||
|
) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
agentInfo := c.agentInfo
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
remotePeerID := peerIDFromPublicKey(remotePubKey)
|
||||||
|
pairID := connectionPairID(agentInfo.peerID, remotePeerID)
|
||||||
|
c.impl.RecordConnectionStages(ctx, agentInfo, pairID, connectionType, isReconnection, timestamps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSyncDuration records the duration of sync message processing
|
||||||
|
func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Duration) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
agentInfo := c.agentInfo
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLoginDuration records how long the login to management server took
|
||||||
|
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
agentInfo := c.agentInfo
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
c.impl.RecordLoginDuration(ctx, agentInfo, duration, success)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentInfo updates the agent information (e.g., when switching profiles).
|
||||||
|
// publicKey is the WireGuard public key; it will be hashed for anonymisation.
|
||||||
|
func (c *ClientMetrics) UpdateAgentInfo(agentInfo AgentInfo, publicKey string) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
agentInfo.peerID = peerIDFromPublicKey(publicKey)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.agentInfo = agentInfo
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
c.pushMu.Lock()
|
||||||
|
push := c.push
|
||||||
|
c.pushMu.Unlock()
|
||||||
|
if push != nil {
|
||||||
|
push.SetPeerID(agentInfo.peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export exports metrics to the writer
|
||||||
|
func (c *ClientMetrics) Export(w io.Writer) error {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.impl.Export(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPush starts periodic pushing of metrics with the given configuration
|
||||||
|
// Precedence: PushConfig.ServerAddress > remote config server_url
|
||||||
|
func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.pushMu.Lock()
|
||||||
|
defer c.pushMu.Unlock()
|
||||||
|
|
||||||
|
if c.push != nil {
|
||||||
|
log.Warnf("metrics push already running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
agentVersion := c.agentInfo.Version
|
||||||
|
peerID := c.agentInfo.peerID
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
configManager := remoteconfig.NewManager(getMetricsConfigURL(), remoteconfig.DefaultMinRefreshInterval)
|
||||||
|
push, err := NewPush(c.impl, configManager, config, agentVersion)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to create metrics push: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
push.SetPeerID(peerID)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
c.pushCancel = cancel
|
||||||
|
|
||||||
|
c.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer c.wg.Done()
|
||||||
|
push.Start(ctx)
|
||||||
|
}()
|
||||||
|
c.push = push
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClientMetrics) StopPush() {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.pushMu.Lock()
|
||||||
|
defer c.pushMu.Unlock()
|
||||||
|
if c.push == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.pushCancel()
|
||||||
|
c.wg.Wait()
|
||||||
|
c.push = nil
|
||||||
|
}
|
||||||
11
client/internal/metrics/metrics_default.go
Normal file
11
client/internal/metrics/metrics_default.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !js
|
||||||
|
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
// NewClientMetrics creates a new ClientMetrics instance
|
||||||
|
func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics {
|
||||||
|
return &ClientMetrics{
|
||||||
|
impl: newInfluxDBMetrics(),
|
||||||
|
agentInfo: agentInfo,
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user