mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-12 18:51:28 +02:00
Compare commits
24 Commits
refactor/p
...
l10n_main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b2d3f351 | ||
|
|
ebfdf7d7b8 | ||
|
|
e8671a811d | ||
|
|
1ca26d8faa | ||
|
|
f9abe2727f | ||
|
|
664a3d026c | ||
|
|
d2c961f67c | ||
|
|
5584f8ef0a | ||
|
|
f65f7b347e | ||
|
|
179e8f6e13 | ||
|
|
2ee21d2b5c | ||
|
|
eb619fc7e3 | ||
|
|
8632a0d215 | ||
|
|
f63fd21e0c | ||
|
|
524b8b9718 | ||
|
|
2ce6323602 | ||
|
|
9a05a1c698 | ||
|
|
5dd914782a | ||
|
|
8c19b7a30a | ||
|
|
1e2a7aa571 | ||
|
|
98f8a24108 | ||
|
|
b2ae98f0fd | ||
|
|
40ac64bb4e | ||
|
|
19a6cedfff |
133
.github/workflows/release.yml
vendored
133
.github/workflows/release.yml
vendored
@@ -475,6 +475,132 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 3
|
||||
|
||||
release_ui_gtk3:
|
||||
# Legacy GTK3/WebKit2GTK 4.1 UI build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). Runs on ubuntu-22.04 so
|
||||
# the binary links against the oldest supported glibc.
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release_ui_gtk3_artifact_url: ${{ steps.upload_release_ui_gtk3.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||
persist-credentials: false
|
||||
|
||||
- name: Parse semver string
|
||||
id: semver_parser
|
||||
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
|
||||
- name: Set snapshot flag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
|
||||
- name: Set build vars
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
- name: Cache Go modules
|
||||
# Restore-only from the release_ui cache written by trusted runs; the
|
||||
# module cache is identical (same go.sum) and stale build-cache
|
||||
# entries just miss.
|
||||
uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: ${{ runner.os }}-ui-go-releaser-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ui-go-releaser-
|
||||
|
||||
- name: Install modules
|
||||
run: go mod tidy
|
||||
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- 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 wails3 CLI
|
||||
# Version derived from go.mod so the binding generator always matches
|
||||
# the wails runtime the binary links against.
|
||||
# -tags gtk3: the CLI links the wails runtime's cgo packages, and the
|
||||
# default tags request gtk4/webkitgtk-6.0 pkg-config entries that do
|
||||
# not exist on ubuntu-22.04.
|
||||
run: |
|
||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||
go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||
with:
|
||||
version: ${{ env.GORELEASER_VER }}
|
||||
args: release --config .goreleaser_ui_gtk3.yaml --clean ${{ env.flags }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UPLOAD_DEBIAN_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_GTK3_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||
- 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
|
||||
id: upload_release_ui_gtk3
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
name: release-ui-gtk3
|
||||
path: dist/
|
||||
retention-days: 3
|
||||
|
||||
release_ui_darwin:
|
||||
runs-on: macos-latest
|
||||
outputs:
|
||||
@@ -688,7 +814,7 @@ jobs:
|
||||
comment_release_artifacts:
|
||||
name: Comment release artifacts
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release, release_ui, release_ui_darwin]
|
||||
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin]
|
||||
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -700,12 +826,14 @@ jobs:
|
||||
env:
|
||||
RELEASE_RESULT: ${{ needs.release.result }}
|
||||
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
||||
RELEASE_UI_GTK3_RESULT: ${{ needs.release_ui_gtk3.result }}
|
||||
RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }}
|
||||
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
|
||||
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
|
||||
WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }}
|
||||
MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }}
|
||||
RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }}
|
||||
RELEASE_UI_GTK3_ARTIFACT_URL: ${{ needs.release_ui_gtk3.outputs.release_ui_gtk3_artifact_url }}
|
||||
RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }}
|
||||
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
|
||||
with:
|
||||
@@ -728,6 +856,7 @@ jobs:
|
||||
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||
['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||
['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT],
|
||||
['UI GTK3 artifacts', process.env.RELEASE_UI_GTK3_ARTIFACT_URL, process.env.RELEASE_UI_GTK3_RESULT],
|
||||
['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT],
|
||||
];
|
||||
|
||||
@@ -784,7 +913,7 @@ jobs:
|
||||
|
||||
trigger_signer:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release, release_ui, release_ui_darwin, test_windows_installer]
|
||||
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin, test_windows_installer]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Trigger binaries sign pipelines
|
||||
|
||||
@@ -257,6 +257,15 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify fresh-install session cookie key hardening
|
||||
run: |
|
||||
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' install -m 600 /dev/null config.yaml' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' openssl rand -base64 32' infrastructure_files/getting-started-enterprise.sh
|
||||
grep -Fxq ' NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)' infrastructure_files/getting-started-enterprise.sh
|
||||
grep -Fxq ' sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"' infrastructure_files/getting-started-enterprise.sh
|
||||
|
||||
- name: Verify Dex retirement notice
|
||||
run: |
|
||||
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
|
||||
|
||||
@@ -96,6 +96,7 @@ nfpms:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
- libwebkitgtk-6.0-4
|
||||
- xdg-utils
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
@@ -119,6 +120,7 @@ nfpms:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||
- xdg-utils
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
|
||||
142
.goreleaser_ui_gtk3.yaml
Normal file
142
.goreleaser_ui_gtk3.yaml
Normal file
@@ -0,0 +1,142 @@
|
||||
version: 2
|
||||
env:
|
||||
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
||||
project_name: netbird-ui
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# Bindings are gitignored; regenerate before the frontend build so
|
||||
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
||||
# build without them).
|
||||
# -f '-tags gtk3': the generator type-checks client/ui, whose cgo imports
|
||||
# would otherwise resolve gtk4/webkitgtk-6.0 pkg-config entries that do
|
||||
# not exist on ubuntu-22.04.
|
||||
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts -f "-tags gtk3"'
|
||||
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||
|
||||
builds:
|
||||
# Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). The gtk3 tag flips the
|
||||
# Wails Linux backend to the GTK3 stack and swaps our GTK4-only XEmbed
|
||||
# tray host for the pure-Go stub (client/ui/xembed_host_gtk3_linux.go).
|
||||
# Must be built on the oldest supported glibc (ubuntu-22.04 runner).
|
||||
- id: netbird-ui-gtk3
|
||||
dir: client/ui
|
||||
binary: netbird-ui
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- production
|
||||
- gtk3
|
||||
|
||||
archives:
|
||||
- id: linux-gtk3-arch
|
||||
name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
|
||||
nfpms:
|
||||
# Same package_name as the GTK4 packages -- the two are mutually-exclusive
|
||||
# alternatives served from separate repo paths (see uploads below); a given
|
||||
# distro points at exactly one of them. The file names must still differ:
|
||||
# the Debian pool is shared storage keyed by file name, so a default-named
|
||||
# gtk3 .deb would overwrite the stable one.
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_deb_gtk3
|
||||
package_name: netbird-ui
|
||||
file_name_template: "{{ .PackageName }}-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- deb
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
- xdg-utils
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_rpm_gtk3
|
||||
package_name: netbird-ui
|
||||
file_name_template: "{{ .PackageName }}-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- rpm
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk3 or libgtk-3-0)
|
||||
- (webkit2gtk4.1 or libwebkit2gtk-4_1-0)
|
||||
- xdg-utils
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
# The GTK4 UI job shares project_name, so the default checksum file name would
|
||||
# collide with it on the shared GitHub release.
|
||||
checksum:
|
||||
name_template: "{{ .ProjectName }}_gtk3_checksums.txt"
|
||||
|
||||
changelog:
|
||||
disable: true
|
||||
|
||||
uploads:
|
||||
# The gtk3 packages reuse the netbird-ui package name, so they live in
|
||||
# dedicated repo paths (deb distribution `gtk3`, yum path `yum-gtk3`) that
|
||||
# legacy distros point their repo config at.
|
||||
#
|
||||
# GoReleaser derives the credential env var from the upload name, so these
|
||||
# would look for UPLOAD_DEBIAN-GTK3_SECRET / UPLOAD_YUM-GTK3_SECRET. The
|
||||
# release workflow only exports UPLOAD_DEBIAN_SECRET / UPLOAD_YUM_SECRET, and
|
||||
# a missing secret is a silent skip rather than a failure -- the packages
|
||||
# reached the GitHub release but never the package repositories. Point
|
||||
# `password` at the exported vars so both uploads authenticate.
|
||||
- name: debian-gtk3
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_ui_deb_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||
username: dev@wiretrustee.com
|
||||
password: "{{ .Env.UPLOAD_DEBIAN_SECRET }}"
|
||||
method: PUT
|
||||
|
||||
- name: yum-gtk3
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_ui_rpm_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
password: "{{ .Env.UPLOAD_YUM_SECRET }}"
|
||||
method: PUT
|
||||
317
AGENTS.md
317
AGENTS.md
@@ -14,20 +14,22 @@ in this file, not duplicated there.
|
||||
|
||||
## Contents
|
||||
|
||||
- [NetBird Agent Guidelines](#netbird-agent-guidelines)
|
||||
- [Contents](#contents)
|
||||
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
||||
- [Quick reference](#quick-reference)
|
||||
- [Structure](#structure)
|
||||
- [Where to look](#where-to-look)
|
||||
- [Repo-wide principles](#repo-wide-principles)
|
||||
- [Error handling](#error-handling)
|
||||
- [Comments](#comments)
|
||||
- [Testing](#testing)
|
||||
- [Pitfalls](#pitfalls)
|
||||
- [Commits, PRs, releases](#commits-prs-releases)
|
||||
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
|
||||
- [Discussion and support](#discussion-and-support)
|
||||
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
||||
- [Quick reference](#quick-reference)
|
||||
- [Structure](#structure)
|
||||
- [Where to look](#where-to-look)
|
||||
- [Security](#security)
|
||||
- [Agent conventions](#agent-conventions)
|
||||
- [Repo-wide principles](#repo-wide-principles)
|
||||
- [Type safety](#type-safety)
|
||||
- [Concurrency and lifecycle](#concurrency-and-lifecycle)
|
||||
- [Error handling](#error-handling)
|
||||
- [Comments](#comments)
|
||||
- [Testing](#testing)
|
||||
- [Pitfalls](#pitfalls)
|
||||
- [Commits, PRs, releases](#commits-prs-releases)
|
||||
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
|
||||
- [Discussion and support](#discussion-and-support)
|
||||
|
||||
## STOP and ask the user before
|
||||
|
||||
@@ -157,11 +159,125 @@ netbird/
|
||||
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
|
||||
| End-to-end tests | `e2e/` |
|
||||
|
||||
## Security
|
||||
|
||||
### Never fail open
|
||||
|
||||
When a security check — access control, an IP restriction, an auth decision —
|
||||
hits an error such as an unparseable value, an unavailable lookup, or a state it
|
||||
does not recognize, it must **deny**. Never skip the check or allow the request
|
||||
through because the check itself failed, and make the `default` and unknown cases
|
||||
of a security-related `switch` deny rather than fall through.
|
||||
|
||||
### Daemon RPC input is untrusted
|
||||
|
||||
The agent runs as root (LocalSystem on Windows), so a daemon RPC crosses a
|
||||
privilege boundary: treat every field as untrusted input rather than as something
|
||||
the UI or CLI validated on the way in.
|
||||
|
||||
When you add or change an RPC, ask what the handler does with caller input while
|
||||
running as root. If the answer touches a filesystem path, a URL or host, or a
|
||||
privileged state change, it needs a gate **in the handler** — a check in the client
|
||||
that normally calls it is not a check at all.
|
||||
|
||||
- **A caller-supplied path the daemon opens.** Never `os.Open` it as root.
|
||||
Constrain it, then open it *as the caller* with `ipcauth.OpenOwnedFile`, which
|
||||
opens `O_NOFOLLOW`, requires a regular file, and refuses a file the caller does
|
||||
not own — so a symlink or hardlink aimed at a root-only file is rejected.
|
||||
- **A caller-supplied URL or host the daemon fetches.** Restrict the scheme and
|
||||
allow only known hosts for unprivileged callers. Prefer a lexical host
|
||||
allowlist plus TLS verification over "resolve the host, then reject private
|
||||
IPs": the resolve-then-trust pattern has a DNS-rebinding race (public IP at
|
||||
check time, attacker IP at connect time), while a name allowlist has no IP
|
||||
check to race. Never accept `http://` where `https://` is expected.
|
||||
- **A privileged state change** (SSH root login, management URL, deregistration)
|
||||
gates on the caller identity from `ipcauth.CallerIdentity(ctx)`.
|
||||
|
||||
Caller identity comes from the kernel — `SO_PEERCRED`, `LOCAL_PEERCRED`, or the
|
||||
named-pipe client token — and never from an RPC field. When
|
||||
`ipcauth.CallerIdentity` reports that it could not determine an identity, **deny**;
|
||||
do not fall back to treating the caller as the transport peer.
|
||||
|
||||
## Agent conventions
|
||||
|
||||
### Three networking modes
|
||||
|
||||
Where packets actually flow depends on the mode the agent is running in. The
|
||||
three are not interchangeable, so establish which one a change applies to — and
|
||||
what it should do in the other two — before you write it.
|
||||
|
||||
- **kernel mode** (Linux only): in-kernel WireGuard®. The kernel handles both
|
||||
peer-to-peer and routed traffic, and ACLs are iptables or nftables rules. The
|
||||
client programs kernel facilities but never sees the traffic itself.
|
||||
- **userspace mode** (wireguard-go with a TUN): wireguard-go runs in-process. The
|
||||
kernel handles peer-to-peer traffic once it leaves the TUN, while routed traffic
|
||||
— exit nodes and network routes — goes through the userspace forwarder, which
|
||||
terminates the connection and re-establishes it over OS sockets. Used on
|
||||
platforms without kernel WireGuard® or when the user opts out.
|
||||
- **netstack mode**: wireguard-go in-process with no TUN and no kernel
|
||||
networking. The forwarder does all routing by stitching userspace sockets, and
|
||||
listeners such as the embedded SSH and DNS servers bind on a gVisor netstack.
|
||||
Used where the process cannot create a TUN device, such as the embedded client
|
||||
(`client/embed/`) and the WASM build.
|
||||
|
||||
### The overlay interface is not "WireGuard"
|
||||
|
||||
Do not put "WireGuard" in identifiers or comments unless the code is genuinely
|
||||
coupled to WireGuard® specifically — a wireguard-go call, a handshake field, a
|
||||
kernel WireGuard® netlink attribute. For the interface, the host, peers, or
|
||||
traffic in general, say "the NetBird interface", "the interface", or "the overlay".
|
||||
Most firewall, routing, and DNS code is transport-agnostic, so a WireGuard®
|
||||
reference there is simply inaccurate and rots as the transports change.
|
||||
|
||||
### IPv6 is a soft feature
|
||||
|
||||
The IPv6 overlay is opt-in dual-stack, and capability can change at runtime. Treat
|
||||
it as soft rather than a requirement:
|
||||
|
||||
- Gate local v6 paths on the interface accessor (`wgIface.Address().HasIPv6()`),
|
||||
not on raw state fields, and skip the v6 path when the host has no v6 rather
|
||||
than returning an error.
|
||||
- Treat an empty or unparseable peer v6 address as "no v6 for that peer" and skip
|
||||
it, keeping the v4 path working.
|
||||
- Never let a missing v6 break v4. Fail-closed is for security checks; a
|
||||
capability mismatch skips the v6 work and carries on.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Name the variable in a constant and parse booleans with `strconv.ParseBool` rather
|
||||
than comparing strings inline, so an unexpected value is logged instead of
|
||||
silently meaning false:
|
||||
|
||||
```go
|
||||
const EnvDisableFeature = "NB_DISABLE_FEATURE"
|
||||
|
||||
func isDisabledByEnv() bool {
|
||||
val := os.Getenv(EnvDisableFeature)
|
||||
if val == "" {
|
||||
return false
|
||||
}
|
||||
disabled, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s: %v", EnvDisableFeature, err)
|
||||
return false
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
```
|
||||
|
||||
### Validating against protocol specs
|
||||
|
||||
When a change depends on what a protocol actually mandates, read the specification
|
||||
text from the [IETF datatracker](https://datatracker.ietf.org/) rather than a
|
||||
summary, and check that you have the current RFC — the widely cited one for a
|
||||
protocol is often superseded. Cite the section, not just the document, so a
|
||||
reviewer can jump straight to the rule.
|
||||
|
||||
## Repo-wide principles
|
||||
|
||||
1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
|
||||
2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings on code you
|
||||
touch, and delete imports, helpers, and parameters your refactor orphaned.
|
||||
2. **Zero unaddressed linter warnings.** Fix what `golangci-lint` reports on code
|
||||
you touch, and delete imports, helpers, and parameters your refactor orphaned.
|
||||
Exception: unused parameters in shared code may be consumed by builds outside
|
||||
this repository — do not remove them, ask instead.
|
||||
3. **Function comments are mandatory for exported functions**, written as full
|
||||
@@ -175,9 +291,12 @@ netbird/
|
||||
7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
|
||||
prose, trailing summaries. Defaults, not absolute bans. Applies to code,
|
||||
comments, commit messages, and PR descriptions alike.
|
||||
8. **Concurrency: do a two-pass race analysis after every change** that adds
|
||||
shared state. Guard maps and slices with a mutex, keep critical sections
|
||||
short, and run `go test -race` on the touched packages.
|
||||
8. **Concurrency: do a two-pass race analysis after every change** that touches
|
||||
shared state, including reads of existing maps and slices. Guard them with a
|
||||
mutex (or an atomic or channel where that fits better), keep critical
|
||||
sections short, and run `go test -race` on the touched packages. See
|
||||
[Concurrency and lifecycle](#concurrency-and-lifecycle) for the failure modes
|
||||
to check for.
|
||||
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
|
||||
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
|
||||
add the counterpart or a build-tagged fallback for the others.
|
||||
@@ -185,6 +304,93 @@ netbird/
|
||||
11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
|
||||
keep peer IPs and hostnames out of logs above debug level.
|
||||
|
||||
## Type safety
|
||||
|
||||
**No bare primitives for domain concepts.** A `string` parameter for an account
|
||||
ID next to a `string` parameter for a peer ID is two bugs waiting to happen,
|
||||
because the compiler cannot catch the swap. Declare the type once and use it
|
||||
throughout, converting only at the boundaries where data enters or leaves —
|
||||
protobuf, gRPC, HTTP, an external library.
|
||||
|
||||
```go
|
||||
type ServiceID string
|
||||
type AccountID string
|
||||
|
||||
// Internal: typed all the way through
|
||||
func (r *Router) RemoveRoute(host SNIHost, svcID ServiceID) { ... }
|
||||
|
||||
// Proto boundary: convert once, on the way in and on the way out
|
||||
svcID := ServiceID(mapping.GetId())
|
||||
req.ServiceId = string(svcID)
|
||||
```
|
||||
|
||||
- **IP addresses are `netip.Addr`**, not `string` and not `net.IP`. Parse at the
|
||||
boundary and pass the typed value inward.
|
||||
- **Always `Unmap()`** after parsing an address, after converting from `net.IP`,
|
||||
and after extracting one from `RemoteAddr()`. This normalizes a v4-mapped v6
|
||||
address (`::ffff:10.1.2.3`) to plain v4 so IPv4 rules match it. A stored or
|
||||
compared mapped address silently fails to match those rules.
|
||||
- **Ports are `uint16`** internally; use `int` only where a library forces it and
|
||||
convert immediately.
|
||||
- **Enums are a typed string with constants**, so the valid set is discoverable
|
||||
and a typo fails to compile.
|
||||
- **Map keys follow the same rule**, and must be a real type (`type ServiceID
|
||||
string`) rather than an alias (`type serviceID = string`) — an alias silently
|
||||
accepts bare strings.
|
||||
|
||||
## Concurrency and lifecycle
|
||||
|
||||
Beyond the mutex hygiene in the principles above, check for these failure
|
||||
modes.
|
||||
|
||||
- **Never read a struct field inside a goroutine** when another goroutine may nil
|
||||
or reassign it. Pass the value as a parameter, or capture it into a local before
|
||||
launching. This matters most when `Stop()` nils a field without waiting for the
|
||||
goroutine to finish.
|
||||
|
||||
```go
|
||||
go func(ifaceName string) { // good: passed in, cannot be nilled underneath
|
||||
m.Start(ctx, ifaceName)
|
||||
}(iface.Name())
|
||||
```
|
||||
|
||||
- **Never wait on a channel while holding a lock the sender needs.** Copy what you
|
||||
need out from under the lock, release it, then wait.
|
||||
|
||||
```go
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
cancel, done := m.cancel, m.done
|
||||
m.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`Stop`/`Close` must be idempotent** — guard on an already-stopped flag or a
|
||||
nil cancel — and must release the state they guarded. Clear maps and caches;
|
||||
a cancelled goroutine holding a live map still pins that memory. Note that a
|
||||
nil map only panics on writes; reads and iteration behave like an empty map,
|
||||
so where post-close use must be rejected, check the stopped flag explicitly.
|
||||
- **Publish coupled state only after every fallible step succeeds.** When several
|
||||
fields form an invariant, build them into locals and assign them to the receiver
|
||||
at the end. Assigning as you go leaves the object half-initialized when a later
|
||||
step fails, so a readiness predicate reports ready while a coupled field is nil.
|
||||
If an earlier step already had an external side effect — a created chain, an
|
||||
opened handle, an inserted rule — roll it back before returning the error.
|
||||
- **Clean up what you own on constructor error paths.** Once a constructor has
|
||||
started something, every later error path must undo it: cancel a goroutine and
|
||||
wait for it to exit, stop a ticker, close a watcher. The object is never
|
||||
returned, so its `Close` will never run.
|
||||
- **A failed `Start` must undo everything it started.** When a component brings up
|
||||
several subsystems in sequence — connection manager, watchers, routing, DNS,
|
||||
flow, persisted state — a failure partway through has to tear down the ones
|
||||
already running, not just close the handle the error came from. Put the
|
||||
already-started guard *before* that teardown path, so a rejected second `Start`
|
||||
cannot dismantle the one that is running.
|
||||
|
||||
## Error handling
|
||||
|
||||
Use single-assignment form when the error is only needed inside the `if`:
|
||||
@@ -248,6 +454,45 @@ Log the errors you choose not to act on:
|
||||
- Close errors may be ignored for read-only operations; log them at debug for
|
||||
writes.
|
||||
|
||||
**Do not log and return the same error.** It gets reported twice, from two places,
|
||||
and the second reader cannot tell whether it happened once or twice. Return it and
|
||||
let the caller decide. The exception is an API handler that has already written a
|
||||
response. Internal helpers return errors rather than logging and swallowing them.
|
||||
|
||||
**Never return a typed nil as an error.** A nil `*MyError` stored in an `error`
|
||||
interface is not nil, so `err != nil` is true and callers take the failure path on
|
||||
success. Return the error only where it is actually set:
|
||||
|
||||
```go
|
||||
if _, err := conn.Write(buf); err != nil { // good
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
```
|
||||
|
||||
**Accumulate with `multierror` when an operation should continue past individual
|
||||
failures** — teardown, cleanup, or setup where partial success is acceptable.
|
||||
`client/errors.FormatErrorOrNil` returns nil for an empty accumulator, so callers
|
||||
still see a plain nil on full success:
|
||||
|
||||
```go
|
||||
func (m *Manager) Cleanup() error {
|
||||
var merr *multierror.Error
|
||||
for _, r := range m.resources {
|
||||
if err := r.Close(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("close %s: %w", r.Name, err))
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
```
|
||||
|
||||
| Scenario | Approach | Why |
|
||||
| --------------------- | --------------------- | ----------------------------------------- |
|
||||
| Cleanup / teardown | Accumulate | Clean up as much as possible |
|
||||
| Setup with rollback | Abort on first error | Partial state is invalid; undo what stuck |
|
||||
| Setup with partial OK | Accumulate | Degraded operation is still useful |
|
||||
|
||||
## Comments
|
||||
|
||||
Comment the **why**, never the **what**. Default to no comment, and add one only
|
||||
@@ -269,10 +514,14 @@ checksum = updateChecksum(checksum, oldPort, newPort)
|
||||
|
||||
### Length budget
|
||||
|
||||
- **90 characters per line.** Wrap the comment, do not run past it.
|
||||
- **250 characters per comment**, roughly three wrapped lines. Doc comments on
|
||||
exported identifiers may exceed it when the API genuinely needs the
|
||||
explanation; inline comments inside a function body may not.
|
||||
Neither of these is linter-enforced, so they are conventions the surrounding code
|
||||
mostly follows rather than hard limits:
|
||||
|
||||
- **Around 90 characters per line.** Wrap the comment rather than running well past
|
||||
it.
|
||||
- **Roughly 250 characters per comment**, about three wrapped lines. Doc comments
|
||||
on exported identifiers may exceed it when the API genuinely needs the
|
||||
explanation; inline comments inside a function body rarely should.
|
||||
|
||||
The budget is a smell detector, not a rule to game. Do not compress a needed
|
||||
explanation into cryptic shorthand to fit — if a block of code needs more than
|
||||
@@ -329,6 +578,19 @@ up, and the 250-character budget does not apply to them.
|
||||
otherwise.
|
||||
- **Message guidance:** optional for `NoError`/`Error`; always give context for
|
||||
comparison, boolean, and collection assertions.
|
||||
- **Reproduce a bug before fixing it.** Write the test, watch it fail *for the
|
||||
reason you expect* — a test that fails for an unrelated reason proves nothing —
|
||||
then apply the fix and confirm it passes. Add the thin surrounding cases while
|
||||
you are there.
|
||||
- **Use `t.Setenv`** rather than `os.Setenv` so the previous value is restored on
|
||||
cleanup. To test the unset case, call `t.Setenv` first to register the restore,
|
||||
then `os.Unsetenv`.
|
||||
- **Prefer `t.Cleanup` over `defer`** in any test with parallel subtests: the
|
||||
parent function returns, running its `defer`s, while parallel subtests are
|
||||
still suspended. Sequential subtests finish inside `t.Run`, so `defer` is safe
|
||||
there, but `t.Cleanup` works in both cases.
|
||||
- **Explanatory comments in tests are welcome.** Describe the scenario being set
|
||||
up; the comment budget below does not apply to them.
|
||||
|
||||
```go
|
||||
server, err := StartTestServer()
|
||||
@@ -380,7 +642,8 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
|
||||
than replacing it with your own summary: describe the change, link the issue,
|
||||
tick the checklist honestly (including "ran locally" and "single purpose"),
|
||||
and complete the documentation section. Do not tick a box you have not
|
||||
verified, and do not delete rows that do not apply.
|
||||
verified, and do not delete rows that do not apply — the docs gate in CI reads
|
||||
that section and fails when it is missing.
|
||||
|
||||
- **Keep the PR description short.** Under 1000 words on top of the template's
|
||||
own text, and usually far less — a few paragraphs. Reviewers read the diff;
|
||||
@@ -439,6 +702,12 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
|
||||
on their own. Propose that split to the user rather than opening one large PR
|
||||
and hoping.
|
||||
|
||||
Prefer GitHub's stacked pull requests for such a sequence, rather than
|
||||
hand-managing base branches: open each PR against the branch below it instead of
|
||||
`main`, so every PR's diff shows only its own change. Merging a layer retargets
|
||||
the PRs above it, and branch protections and required checks on the base branch
|
||||
still apply to each one.
|
||||
|
||||
- **User-facing changes need a docs PR** in
|
||||
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
|
||||
description.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
@@ -32,6 +33,13 @@ import (
|
||||
types "github.com/netbirdio/netbird/upload-server/types"
|
||||
)
|
||||
|
||||
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
|
||||
// anonymizeLevel values for DebugBundle.
|
||||
const (
|
||||
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
@@ -278,8 +286,10 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||
// It works both with and without a running engine.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||
// It works both with and without a running engine. anonymizeLevel is "default"
|
||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys, and implies anonymize.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
|
||||
cfg, cacheDir, cc := c.stateSnapshot()
|
||||
|
||||
// If the engine hasn't been started, load config from disk
|
||||
@@ -298,6 +308,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
InternalConfig: cfg,
|
||||
StatusRecorder: c.recorder,
|
||||
TempDir: cacheDir,
|
||||
StatePath: platformFiles.StateFilePath(),
|
||||
}
|
||||
|
||||
if cc != nil {
|
||||
@@ -321,6 +332,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
deps,
|
||||
debug.BundleConfig{
|
||||
Anonymize: anonymize,
|
||||
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package anonymize
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
@@ -15,13 +16,88 @@ import (
|
||||
|
||||
const anonTLD = ".domain"
|
||||
|
||||
// Level selects how much the anonymizer redacts. Levels are ordered: a higher
|
||||
// level redacts strictly more. On the wire (protos, flags) levels travel as
|
||||
// their string form.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC
|
||||
// addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are
|
||||
// preserved so support can reason about the real topology.
|
||||
LevelDefault Level = iota
|
||||
// LevelStrict additionally anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys.
|
||||
LevelStrict
|
||||
)
|
||||
|
||||
// LevelDefaultString and LevelStrictString are the wire forms of the levels,
|
||||
// for boundaries that pass levels as strings (flags, protos, mobile bindings).
|
||||
const (
|
||||
LevelDefaultString = "default"
|
||||
LevelStrictString = "strict"
|
||||
)
|
||||
|
||||
// ParseLevel maps s to a Level. Empty means LevelDefault; anything
|
||||
// unrecognized maps to LevelStrict so an unknown request never yields less
|
||||
// anonymization than intended.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(s) {
|
||||
case "", LevelDefaultString:
|
||||
return LevelDefault
|
||||
default:
|
||||
return LevelStrict
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the wire form of the level: "default" or "strict".
|
||||
func (l Level) String() string {
|
||||
if l >= LevelStrict {
|
||||
return LevelStrictString
|
||||
}
|
||||
return LevelDefaultString
|
||||
}
|
||||
|
||||
// protectedDomains are NetBird-operated suffixes that stay recognizable in an
|
||||
// anonymized bundle. At LevelStrict the labels in front of them (the peer
|
||||
// name) are still replaced, except under netbird.io, which only hosts
|
||||
// NetBird infrastructure (api, signal, flow), never peer names.
|
||||
var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"}
|
||||
|
||||
const infraDomain = "netbird.io"
|
||||
|
||||
var (
|
||||
macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`)
|
||||
macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`)
|
||||
wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`)
|
||||
)
|
||||
|
||||
type Anonymizer struct {
|
||||
ipAnonymizer map[netip.Addr]netip.Addr
|
||||
domainAnonymizer map[string]string
|
||||
currentAnonIPv4 netip.Addr
|
||||
currentAnonIPv6 netip.Addr
|
||||
startAnonIPv4 netip.Addr
|
||||
startAnonIPv6 netip.Addr
|
||||
// domainOrder caches the keys of domainAnonymizer sorted longest-first
|
||||
// for AnonymizeString; it is rebuilt when the map gains entries.
|
||||
domainOrder []string
|
||||
labelAnonymizer map[string]string
|
||||
labelAnonymized map[string]struct{}
|
||||
labelCounter uint32
|
||||
macAnonymizer map[string]string
|
||||
macCounter uint32
|
||||
wgKeyAnonymizer map[string]string
|
||||
wgKeyAnonymized map[string]struct{}
|
||||
currentAnonIPv4 netip.Addr
|
||||
currentAnonIPv6 netip.Addr
|
||||
startAnonIPv4 netip.Addr
|
||||
startAnonIPv6 netip.Addr
|
||||
|
||||
// LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT,
|
||||
// link-local), replacing them from the dedicated internal pools below so
|
||||
// a reader can still tell an internal address from a public one.
|
||||
level Level
|
||||
currentAnonInternalIPv4 netip.Addr
|
||||
currentAnonInternalIPv6 netip.Addr
|
||||
startAnonInternalIPv4 netip.Addr
|
||||
startAnonInternalIPv6 netip.Addr
|
||||
|
||||
domainKeyRegex *regexp.Regexp
|
||||
}
|
||||
@@ -32,25 +108,50 @@ func DefaultAddresses() (netip.Addr, netip.Addr) {
|
||||
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::")
|
||||
}
|
||||
|
||||
// InternalAddresses returns the pool starts used in strict mode for internal
|
||||
// ranges. Both are reserved ranges that cannot collide with real addressing:
|
||||
// 198.18.0.0 (RFC 2544 benchmarking), 2001:db8:1:: (RFC 3849 documentation).
|
||||
func InternalAddresses() (netip.Addr, netip.Addr) {
|
||||
return netip.AddrFrom4([4]byte{198, 18, 0, 0}), netip.MustParseAddr("2001:db8:1::")
|
||||
}
|
||||
|
||||
func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
|
||||
internalIPv4, internalIPv6 := InternalAddresses()
|
||||
return &Anonymizer{
|
||||
ipAnonymizer: map[netip.Addr]netip.Addr{},
|
||||
domainAnonymizer: map[string]string{},
|
||||
labelAnonymizer: map[string]string{},
|
||||
labelAnonymized: map[string]struct{}{},
|
||||
macAnonymizer: map[string]string{},
|
||||
wgKeyAnonymizer: map[string]string{},
|
||||
wgKeyAnonymized: map[string]struct{}{},
|
||||
currentAnonIPv4: startIPv4,
|
||||
currentAnonIPv6: startIPv6,
|
||||
startAnonIPv4: startIPv4,
|
||||
startAnonIPv6: startIPv6,
|
||||
|
||||
level: LevelDefault,
|
||||
currentAnonInternalIPv4: internalIPv4,
|
||||
currentAnonInternalIPv6: internalIPv6,
|
||||
startAnonInternalIPv4: internalIPv4,
|
||||
startAnonInternalIPv6: internalIPv6,
|
||||
|
||||
domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`),
|
||||
}
|
||||
}
|
||||
|
||||
// SetLevel selects the anonymization level. The zero value of a new
|
||||
// Anonymizer is LevelDefault.
|
||||
func (a *Anonymizer) SetLevel(level Level) {
|
||||
a.level = level
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
|
||||
// Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps
|
||||
// like 192.168.1.1.
|
||||
ip = ip.Unmap()
|
||||
|
||||
if ip.IsLoopback() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
(ip.Is4() && ip.IsPrivate()) ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsMulticast() ||
|
||||
isWellKnown(ip) ||
|
||||
@@ -59,18 +160,100 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
|
||||
return ip
|
||||
}
|
||||
|
||||
if isInternal(ip) && a.level < LevelStrict {
|
||||
return ip
|
||||
}
|
||||
|
||||
if _, ok := a.ipAnonymizer[ip]; !ok {
|
||||
if ip.Is4() {
|
||||
a.ipAnonymizer[ip] = a.currentAnonIPv4
|
||||
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
|
||||
} else {
|
||||
a.ipAnonymizer[ip] = a.currentAnonIPv6
|
||||
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
|
||||
}
|
||||
a.ipAnonymizer[ip] = a.nextAnonIP(ip)
|
||||
}
|
||||
return a.ipAnonymizer[ip]
|
||||
}
|
||||
|
||||
func (a *Anonymizer) nextAnonIP(ip netip.Addr) netip.Addr {
|
||||
// At the strict level, internal addresses (including IPv6 ULA, matched
|
||||
// by IsPrivate) come from the internal pools so they remain recognizable
|
||||
// as internal without disclosing the real values.
|
||||
if a.level >= LevelStrict && (isInternal(ip) || ip.IsPrivate()) {
|
||||
if ip.Is4() {
|
||||
anon := a.currentAnonInternalIPv4
|
||||
a.currentAnonInternalIPv4 = a.currentAnonInternalIPv4.Next()
|
||||
return anon
|
||||
}
|
||||
anon := a.currentAnonInternalIPv6
|
||||
a.currentAnonInternalIPv6 = a.currentAnonInternalIPv6.Next()
|
||||
return anon
|
||||
}
|
||||
|
||||
if ip.Is4() {
|
||||
anon := a.currentAnonIPv4
|
||||
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
|
||||
return anon
|
||||
}
|
||||
anon := a.currentAnonIPv6
|
||||
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
|
||||
return anon
|
||||
}
|
||||
|
||||
// AnonymizeMAC replaces a MAC address with a consistent placeholder from the
|
||||
// locally administered range starting at 02:00:00:00:00:01, at every
|
||||
// anonymization level. Broadcast, multicast, all-zero, and already assigned
|
||||
// placeholder addresses are preserved. The colon and dash spellings of the
|
||||
// same address share one placeholder; the output keeps the input's separator.
|
||||
func (a *Anonymizer) AnonymizeMAC(mac string) string {
|
||||
hw, err := net.ParseMAC(mac)
|
||||
if err != nil || len(hw) != 6 {
|
||||
return mac
|
||||
}
|
||||
|
||||
if isWellKnownMAC(hw) || a.isAnonymizedMAC(hw) {
|
||||
return mac
|
||||
}
|
||||
|
||||
key := hw.String()
|
||||
anon, ok := a.macAnonymizer[key]
|
||||
if !ok {
|
||||
a.macCounter++
|
||||
anon = fmt.Sprintf("02:00:00:%02x:%02x:%02x", byte(a.macCounter>>16), byte(a.macCounter>>8), byte(a.macCounter))
|
||||
a.macAnonymizer[key] = anon
|
||||
}
|
||||
|
||||
if strings.Contains(mac, "-") {
|
||||
anon = strings.ReplaceAll(anon, ":", "-")
|
||||
}
|
||||
return anon
|
||||
}
|
||||
|
||||
// isAnonymizedMAC reports whether hw is a placeholder this anonymizer already
|
||||
// handed out, so a second pass over anonymized output leaves it unchanged.
|
||||
func (a *Anonymizer) isAnonymizedMAC(hw net.HardwareAddr) bool {
|
||||
if hw[0] != 0x02 || hw[1] != 0 || hw[2] != 0 {
|
||||
return false
|
||||
}
|
||||
value := uint32(hw[3])<<16 | uint32(hw[4])<<8 | uint32(hw[5])
|
||||
return value <= a.macCounter
|
||||
}
|
||||
|
||||
// AnonymizeWGKey replaces a WireGuard public key with a consistent random
|
||||
// placeholder of the same shape. Keys are only anonymized at LevelStrict;
|
||||
// placeholders already handed out pass through unchanged.
|
||||
func (a *Anonymizer) AnonymizeWGKey(key string) string {
|
||||
if a.level < LevelStrict || !looksLikeWGKey(key) {
|
||||
return key
|
||||
}
|
||||
if _, ok := a.wgKeyAnonymized[key]; ok {
|
||||
return key
|
||||
}
|
||||
|
||||
anon, ok := a.wgKeyAnonymizer[key]
|
||||
if !ok {
|
||||
anon = generateAnonymousKey()
|
||||
a.wgKeyAnonymizer[key] = anon
|
||||
a.wgKeyAnonymized[anon] = struct{}{}
|
||||
}
|
||||
return anon
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
|
||||
// Convert IP to netip.Addr
|
||||
ip, ok := netip.AddrFromSlice(addr.IP)
|
||||
@@ -89,12 +272,12 @@ func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
|
||||
|
||||
// isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs
|
||||
func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
|
||||
if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 {
|
||||
return true
|
||||
} else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 {
|
||||
return true
|
||||
if ip.Is4() {
|
||||
return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) ||
|
||||
inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4)
|
||||
}
|
||||
return false
|
||||
return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) ||
|
||||
inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6)
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeIPString(ip string) string {
|
||||
@@ -118,14 +301,17 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
baseDomain = domain[:len(domain)-1]
|
||||
}
|
||||
|
||||
if strings.HasSuffix(baseDomain, "netbird.io") ||
|
||||
strings.HasSuffix(baseDomain, "netbird.selfhosted") ||
|
||||
strings.HasSuffix(baseDomain, "netbird.cloud") ||
|
||||
strings.HasSuffix(baseDomain, "netbird.stage") ||
|
||||
strings.HasSuffix(baseDomain, anonTLD) {
|
||||
if strings.HasSuffix(baseDomain, anonTLD) {
|
||||
return domain
|
||||
}
|
||||
|
||||
if suffix := protectedSuffix(baseDomain); suffix != "" {
|
||||
if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
|
||||
return domain
|
||||
}
|
||||
return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot)
|
||||
}
|
||||
|
||||
parts := strings.Split(baseDomain, ".")
|
||||
if len(parts) < 2 {
|
||||
return domain
|
||||
@@ -141,12 +327,53 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
}
|
||||
|
||||
result := strings.Replace(baseDomain, baseForLookup, anonymized, 1)
|
||||
if hasDot {
|
||||
result += "."
|
||||
if a.level >= LevelStrict && len(parts) > 2 {
|
||||
prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup)
|
||||
result = a.anonymizeLabels(prefix, "host") + "." + anonymized
|
||||
// The full mapping feeds AnonymizeString so seeded FQDNs are caught
|
||||
// in log lines as a whole, labels included.
|
||||
a.domainAnonymizer[baseDomain] = result
|
||||
}
|
||||
return withTrailingDot(result, hasDot)
|
||||
}
|
||||
|
||||
// anonymizePeerName replaces the labels in front of a protected suffix with
|
||||
// numbered peer placeholders, keeping the suffix, and records the full
|
||||
// mapping for string replacement in logs. The numbering keeps a peer
|
||||
// recognizable across the whole bundle without disclosing its name.
|
||||
func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string {
|
||||
prefix := strings.TrimSuffix(baseDomain, "."+suffix)
|
||||
result := a.anonymizeLabels(prefix, "peer") + "." + suffix
|
||||
if result != baseDomain {
|
||||
a.domainAnonymizer[baseDomain] = result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// anonymizeLabels replaces each dot-separated label with a consistent
|
||||
// numbered placeholder ("<placeholder>-<n>"). Wildcard labels and
|
||||
// placeholders already handed out pass through unchanged.
|
||||
func (a *Anonymizer) anonymizeLabels(prefix, placeholder string) string {
|
||||
labels := strings.Split(prefix, ".")
|
||||
for i, label := range labels {
|
||||
if label == "*" {
|
||||
continue
|
||||
}
|
||||
if _, ok := a.labelAnonymized[label]; ok {
|
||||
continue
|
||||
}
|
||||
anon, ok := a.labelAnonymizer[label]
|
||||
if !ok {
|
||||
a.labelCounter++
|
||||
anon = fmt.Sprintf("%s-%d", placeholder, a.labelCounter)
|
||||
a.labelAnonymizer[label] = anon
|
||||
a.labelAnonymized[anon] = struct{}{}
|
||||
}
|
||||
labels[i] = anon
|
||||
}
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeURI(uri string) string {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
@@ -181,16 +408,70 @@ func (a *Anonymizer) AnonymizeString(str string) string {
|
||||
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
|
||||
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
|
||||
|
||||
for domain, anonDomain := range a.domainAnonymizer {
|
||||
str = strings.ReplaceAll(str, domain, anonDomain)
|
||||
for _, domain := range a.sortedDomains() {
|
||||
str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain])
|
||||
}
|
||||
|
||||
str = a.AnonymizeSchemeURI(str)
|
||||
str = a.AnonymizeDNSLogLine(str)
|
||||
|
||||
// MAC handling runs after the IP passes so preserved IPv6 addresses are
|
||||
// already out of the way; the separator guard skips matches embedded in a
|
||||
// longer colon- or dash-separated sequence (such as an IPv6 tail).
|
||||
str = a.anonymizeMACsInString(str, macColonRegex, ':')
|
||||
str = a.anonymizeMACsInString(str, macDashRegex, '-')
|
||||
|
||||
if a.level >= LevelStrict {
|
||||
str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// sortedDomains returns the domain mappings longest-first, so a full-FQDN
|
||||
// mapping (strict level) is applied before the base-domain mapping it
|
||||
// contains. The order is rebuilt only when domainAnonymizer has grown.
|
||||
func (a *Anonymizer) sortedDomains() []string {
|
||||
if len(a.domainOrder) == len(a.domainAnonymizer) {
|
||||
return a.domainOrder
|
||||
}
|
||||
|
||||
a.domainOrder = a.domainOrder[:0]
|
||||
for domain := range a.domainAnonymizer {
|
||||
a.domainOrder = append(a.domainOrder, domain)
|
||||
}
|
||||
slices.SortFunc(a.domainOrder, func(x, y string) int {
|
||||
if d := len(y) - len(x); d != 0 {
|
||||
return d
|
||||
}
|
||||
return strings.Compare(x, y)
|
||||
})
|
||||
return a.domainOrder
|
||||
}
|
||||
|
||||
// anonymizeMACsInString replaces MAC addresses matched by re, skipping
|
||||
// matches that directly adjoin another sep so a six-group run inside a longer
|
||||
// separated sequence is left alone.
|
||||
func (a *Anonymizer) anonymizeMACsInString(str string, re *regexp.Regexp, sep byte) string {
|
||||
matches := re.FindAllStringIndex(str, -1)
|
||||
if len(matches) == 0 {
|
||||
return str
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, m := range matches {
|
||||
if (m[0] > 0 && str[m[0]-1] == sep) || (m[1] < len(str) && str[m[1]] == sep) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(str[last:m[0]])
|
||||
b.WriteString(a.AnonymizeMAC(str[m[0]:m[1]]))
|
||||
last = m[1]
|
||||
}
|
||||
b.WriteString(str[last:])
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes.
|
||||
func (a *Anonymizer) AnonymizeSchemeURI(text string) string {
|
||||
re := regexp.MustCompile(`(?i)\b(wss?://|rels?://|stuns?:|turns?:|https?://)\S+\b`)
|
||||
@@ -239,10 +520,79 @@ func isWellKnown(addr netip.Addr) bool {
|
||||
"128.0.0.0", "8000::", // 2nd split subnet for default routes
|
||||
}
|
||||
|
||||
if slices.Contains(wellKnown, addr.String()) {
|
||||
return slices.Contains(wellKnown, addr.String())
|
||||
}
|
||||
|
||||
// isInternal reports whether ip identifies a host only within the local
|
||||
// network: IPv4 private (RFC 1918), CGNAT (RFC 6598), and link-local (v4 and
|
||||
// v6). These are preserved at the default level so support can reason about
|
||||
// the real topology, and replaced from the internal pools at the strict
|
||||
// level. IPv6 ULA is deliberately not internal: its random global ID uniquely
|
||||
// fingerprints the network, so it is anonymized at every level.
|
||||
func isInternal(ip netip.Addr) bool {
|
||||
return (ip.Is4() && ip.IsPrivate()) ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
isCGNAT(ip)
|
||||
}
|
||||
|
||||
func inPoolRange(ip, start, current netip.Addr) bool {
|
||||
return ip.Compare(start) >= 0 && ip.Compare(current) <= 0
|
||||
}
|
||||
|
||||
// isWellKnownMAC reports whether hw carries no stable host identity: all-zero
|
||||
// or a group address (broadcast and multicast).
|
||||
func isWellKnownMAC(hw net.HardwareAddr) bool {
|
||||
if hw[0]&1 == 1 {
|
||||
return true
|
||||
}
|
||||
for _, b := range hw {
|
||||
if b != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// looksLikeWGKey reports whether s has the shape of a WireGuard key:
|
||||
// 44 base64 characters decoding to 32 bytes.
|
||||
func looksLikeWGKey(s string) bool {
|
||||
if len(s) != 44 || s[43] != '=' {
|
||||
return false
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(s)
|
||||
return err == nil && len(decoded) == 32
|
||||
}
|
||||
|
||||
func generateAnonymousKey() string {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return strings.Repeat("A", 43) + "="
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// protectedSuffix returns the protected NetBird suffix baseDomain ends with,
|
||||
// or empty. The match is label-anchored so an unrelated domain that merely
|
||||
// ends in the same characters is not preserved.
|
||||
func protectedSuffix(baseDomain string) string {
|
||||
for _, d := range protectedDomains {
|
||||
if baseDomain == d || strings.HasSuffix(baseDomain, "."+d) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withTrailingDot(domain string, hasDot bool) string {
|
||||
if hasDot {
|
||||
return domain + "."
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
// isCGNAT reports whether addr is in 100.64.0.0/10 (RFC 6598), the range
|
||||
// NetBird assigns overlay peer addresses from.
|
||||
func isCGNAT(addr netip.Addr) bool {
|
||||
cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0})
|
||||
cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10)
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package anonymize_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -44,6 +47,301 @@ func TestAnonymizeIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expect anonymize.Level
|
||||
}{
|
||||
{"", anonymize.LevelDefault},
|
||||
{"default", anonymize.LevelDefault},
|
||||
{"DEFAULT", anonymize.LevelDefault},
|
||||
{"strict", anonymize.LevelStrict},
|
||||
{"STRICT", anonymize.LevelStrict},
|
||||
// Unknown values must never yield less anonymization than requested.
|
||||
{"garbage", anonymize.LevelStrict},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run("input="+tc.input, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expect, anonymize.ParseLevel(tc.input), "parsed level should match")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeIP_DefaultLevelInternalRanges(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expect string
|
||||
}{
|
||||
{"RFC1918 10/8", "10.1.2.3", "10.1.2.3"},
|
||||
{"RFC1918 172.16/12", "172.16.5.5", "172.16.5.5"},
|
||||
{"RFC1918 192.168/16", "192.168.1.1", "192.168.1.1"},
|
||||
{"CGNAT", "100.64.0.5", "100.64.0.5"},
|
||||
{"IPv4 link-local", "169.254.1.1", "169.254.1.1"},
|
||||
{"IPv6 link-local", "fe80::1", "fe80::1"},
|
||||
// ULA is anonymized even at the default level: its random global ID
|
||||
// uniquely fingerprints the network, unlike shared RFC 1918 space.
|
||||
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:ffff::"},
|
||||
// 4-in-6 addresses classify like their unmapped IPv4 form.
|
||||
{"4-in-6 RFC1918", "::ffff:192.168.1.1", "192.168.1.1"},
|
||||
{"4-in-6 CGNAT", "::ffff:100.64.0.5", "100.64.0.5"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
|
||||
assert.Equal(t, tc.expect, result.String(), "default level should preserve internal ranges except ULA")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeIP_StrictLevel(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
// Order matters: internal pool addresses are assigned sequentially.
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expect string
|
||||
}{
|
||||
{"RFC1918 192.168/16", "192.168.1.1", "198.18.0.0"},
|
||||
{"Second RFC1918", "192.168.1.2", "198.18.0.1"},
|
||||
{"Repeated RFC1918", "192.168.1.1", "198.18.0.0"},
|
||||
{"RFC1918 10/8", "10.1.2.3", "198.18.0.2"},
|
||||
{"RFC1918 172.16/12", "172.16.5.5", "198.18.0.3"},
|
||||
{"CGNAT", "100.64.0.5", "198.18.0.4"},
|
||||
{"IPv4 link-local", "169.254.1.1", "198.18.0.5"},
|
||||
{"Public IPv4 uses public pool", "1.2.3.4", "198.51.100.0"},
|
||||
{"IPv6 link-local", "fe80::1", "2001:db8:1::"},
|
||||
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:1::1"},
|
||||
{"Public IPv6 uses public pool", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
|
||||
{"Loopback IPv4", "127.0.0.1", "127.0.0.1"},
|
||||
{"Loopback IPv6", "::1", "::1"},
|
||||
{"Unspecified", "0.0.0.0", "0.0.0.0"},
|
||||
{"Multicast", "224.0.0.251", "224.0.0.251"},
|
||||
{"Well known resolver", "8.8.8.8", "8.8.8.8"},
|
||||
{"Well known split marker", "128.0.0.0", "128.0.0.0"},
|
||||
{"In internal pool range", "198.18.0.3", "198.18.0.3"},
|
||||
{"In public pool range", "198.51.100.0", "198.51.100.0"},
|
||||
{"4-in-6 repeated RFC1918", "::ffff:192.168.1.1", "198.18.0.0"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
|
||||
assert.Equal(t, tc.expect, result.String(), "strict level should replace internal ranges from the internal pools")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeString_StrictInternalIPs(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
input := "route 10.20.30.0/24 via 192.168.1.1 dev eth0 src 100.64.0.7"
|
||||
firstPass := anonymizer.AnonymizeString(input)
|
||||
secondPass := anonymizer.AnonymizeString(firstPass)
|
||||
|
||||
assert.NotContains(t, firstPass, "10.20.30.0", "private network address should be anonymized")
|
||||
assert.NotContains(t, firstPass, "192.168.1.1", "private gateway should be anonymized")
|
||||
assert.NotContains(t, firstPass, "100.64.0.7", "CGNAT address should be anonymized")
|
||||
assert.Contains(t, firstPass, "/24", "prefix length should be preserved")
|
||||
assert.Equal(t, firstPass, secondPass, "second pass should not further anonymize the string")
|
||||
}
|
||||
|
||||
func TestAnonymizeMAC(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
first := anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f")
|
||||
assert.Equal(t, "02:00:00:00:00:01", first, "first MAC should get the first placeholder")
|
||||
assert.Equal(t, first, anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f"), "repeated MAC should map to the same placeholder")
|
||||
assert.Equal(t, first, anonymizer.AnonymizeMAC("AA:BB:CC:DD:EE:0F"), "case should not affect the mapping")
|
||||
assert.Equal(t, "02-00-00-00-00-01", anonymizer.AnonymizeMAC("AA-BB-CC-DD-EE-0F"), "dash form should keep its separator but share the mapping")
|
||||
|
||||
second := anonymizer.AnonymizeMAC("10:22:33:44:55:66")
|
||||
assert.Equal(t, "02:00:00:00:00:02", second, "second distinct MAC should get the next placeholder")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mac string
|
||||
}{
|
||||
{"Broadcast", "ff:ff:ff:ff:ff:ff"},
|
||||
{"IPv4 multicast", "01:00:5e:00:00:fb"},
|
||||
{"IPv6 multicast", "33:33:00:00:00:01"},
|
||||
{"All zero", "00:00:00:00:00:00"},
|
||||
{"Assigned placeholder", "02:00:00:00:00:01"},
|
||||
{"Invalid", "not-a-mac"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.mac, anonymizer.AnonymizeMAC(tc.mac), "should be preserved")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeString_MACAddresses(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "nftables ether rule",
|
||||
input: "ether saddr aa:bb:cc:dd:ee:ff drop",
|
||||
expect: "ether saddr 02:00:00:00:00:01 drop",
|
||||
},
|
||||
{
|
||||
name: "Windows dash form",
|
||||
input: "Physical Address : AA-BB-CC-DD-EE-FF",
|
||||
expect: "Physical Address : 02-00-00-00-00-01",
|
||||
},
|
||||
{
|
||||
name: "IPv6 address tail is not treated as MAC",
|
||||
input: "addr fe80:0:11:22:33:44:55:66 scope link",
|
||||
expect: "addr fe80:0:11:22:33:44:55:66 scope link",
|
||||
},
|
||||
{
|
||||
name: "broadcast MAC preserved",
|
||||
input: "dst ff:ff:ff:ff:ff:ff type ARP",
|
||||
expect: "dst ff:ff:ff:ff:ff:ff type ARP",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeString(tc.input)
|
||||
assert.Equal(t, tc.expect, result, "MAC addresses should be anonymized at every level")
|
||||
assert.Equal(t, result, anonymizer.AnonymizeString(result), "second pass should not change the result")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeWGKey(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
|
||||
|
||||
t.Run("default level preserves keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
assert.Equal(t, key, anonymizer.AnonymizeWGKey(key), "default level should not touch WireGuard keys")
|
||||
})
|
||||
|
||||
t.Run("strict level replaces keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
anon := anonymizer.AnonymizeWGKey(key)
|
||||
assert.NotEqual(t, key, anon, "strict level should replace the key")
|
||||
assert.Regexp(t, `^[A-Za-z0-9+/]{43}=$`, anon, "placeholder should keep the WireGuard key shape")
|
||||
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(key), "repeated key should map to the same placeholder")
|
||||
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(anon), "an assigned placeholder should pass through unchanged")
|
||||
|
||||
assert.Equal(t, "not-a-key", anonymizer.AnonymizeWGKey("not-a-key"), "non-key values should be preserved")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnonymizeString_WGKeys(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
|
||||
input := "peer " + key + " handshake completed"
|
||||
|
||||
t.Run("default level preserves keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
assert.Equal(t, input, anonymizer.AnonymizeString(input), "default level should not touch WireGuard keys in strings")
|
||||
})
|
||||
|
||||
t.Run("strict level replaces keys", func(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
firstPass := anonymizer.AnonymizeString(input)
|
||||
assert.NotContains(t, firstPass, key, "the key should not survive strict anonymization")
|
||||
assert.Equal(t, anonymizer.AnonymizeWGKey(key), extractKey(t, firstPass), "string replacement should be consistent with AnonymizeWGKey")
|
||||
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
|
||||
})
|
||||
}
|
||||
|
||||
func extractKey(t *testing.T, logLine string) string {
|
||||
t.Helper()
|
||||
fields := strings.Fields(logLine)
|
||||
require.Len(t, fields, 4, "log line should keep its structure")
|
||||
return fields[1]
|
||||
}
|
||||
|
||||
func TestAnonymizeDomain_StrictLevel(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
t.Run("netbird peer name", func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeDomain("my-laptop.netbird.cloud")
|
||||
assert.Regexp(t, `^peer-\d+\.netbird\.cloud$`, result, "peer name should be anonymized, suffix kept")
|
||||
assert.NotContains(t, result, "my-laptop", "the peer name should not survive")
|
||||
assert.Equal(t, result, anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), "repeated domain should map consistently")
|
||||
assert.Equal(t, result, anonymizer.AnonymizeDomain(result), "an anonymized domain should pass through unchanged")
|
||||
})
|
||||
|
||||
t.Run("bare netbird domain", func(t *testing.T) {
|
||||
assert.Equal(t, "netbird.cloud", anonymizer.AnonymizeDomain("netbird.cloud"), "the bare protected suffix should be preserved")
|
||||
})
|
||||
|
||||
t.Run("netbird infrastructure preserved", func(t *testing.T) {
|
||||
assert.Equal(t, "api.netbird.io", anonymizer.AnonymizeDomain("api.netbird.io"),
|
||||
"netbird.io hosts infrastructure, not peer names, and should stay readable")
|
||||
})
|
||||
|
||||
t.Run("leading labels of other domains", func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeDomain("host1.corp.example.com")
|
||||
assert.Regexp(t, `^host-\d+\.host-\d+\.anon-[a-zA-Z0-9]+\.domain$`, result, "every label should be anonymized")
|
||||
for _, label := range []string{"host1", "corp", "example"} {
|
||||
assert.NotContains(t, result, label, "no original label should survive")
|
||||
}
|
||||
assert.Equal(t, result, anonymizer.AnonymizeDomain("host1.corp.example.com"), "repeated domain should map consistently")
|
||||
})
|
||||
|
||||
t.Run("same label maps consistently across domains", func(t *testing.T) {
|
||||
first := anonymizer.AnonymizeDomain("shared.one.com")
|
||||
second := anonymizer.AnonymizeDomain("shared.two.com")
|
||||
assert.Equal(t, strings.Split(first, ".")[0], strings.Split(second, ".")[0], "the shared host label should get one placeholder")
|
||||
})
|
||||
|
||||
t.Run("wildcard label preserved", func(t *testing.T) {
|
||||
result := anonymizer.AnonymizeDomain("*.example.com")
|
||||
assert.Regexp(t, `^\*\.anon-[a-zA-Z0-9]+\.domain$`, result, "the wildcard label should stay a wildcard")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
|
||||
assert.Equal(t, "my-laptop.netbird.cloud", anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"),
|
||||
"default level should preserve netbird FQDNs including the peer name")
|
||||
assert.Regexp(t, `^sub\.anon-[a-zA-Z0-9]+\.domain$`, anonymizer.AnonymizeDomain("sub.example.com"),
|
||||
"default level should keep subdomain labels")
|
||||
}
|
||||
|
||||
func TestAnonymizeString_StrictPeerNames(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(anonymize.LevelStrict)
|
||||
|
||||
// Seed like the bundle generator does from the status: base first, then
|
||||
// the full FQDN, so replacement must prefer the longer mapping.
|
||||
anonBase := anonymizer.AnonymizeDomain("example.com")
|
||||
anonPeer := anonymizer.AnonymizeDomain("peer1.netbird.cloud")
|
||||
anonHost := anonymizer.AnonymizeDomain("host1.example.com")
|
||||
|
||||
logLine := "connected to peer1.netbird.cloud via host1.example.com endpoint"
|
||||
firstPass := anonymizer.AnonymizeString(logLine)
|
||||
assert.NotContains(t, firstPass, "peer1", "the peer name should not survive in logs")
|
||||
assert.NotContains(t, firstPass, "host1", "the host label should not survive in logs")
|
||||
assert.Contains(t, firstPass, anonPeer, "the seeded peer mapping should be applied")
|
||||
assert.Contains(t, firstPass, anonHost, "the seeded host mapping should be applied, not just the base mapping")
|
||||
assert.NotContains(t, firstPass, "host1."+anonBase, "the base mapping must not preempt the longer FQDN mapping")
|
||||
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
|
||||
}
|
||||
|
||||
func TestAnonymizeDNSLogLine(t *testing.T) {
|
||||
anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{})
|
||||
tests := []struct {
|
||||
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
const errCloseConnection = "Failed to close connection: %v"
|
||||
|
||||
var (
|
||||
logFileCount uint32
|
||||
systemInfoFlag bool
|
||||
logFileCount uint32
|
||||
systemInfoFlag bool
|
||||
uploadBundleFlag bool
|
||||
uploadBundleURLFlag string
|
||||
uploadBundleInsecureFlag bool
|
||||
@@ -156,6 +156,11 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
|
||||
// request. Returns an error if the RPC fails or if the daemon reports
|
||||
// an upload failure reason.
|
||||
func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -168,10 +173,11 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
request := &proto.DebugBundleRequest{
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel.String(),
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -229,6 +235,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("invalid duration format: %v", err)
|
||||
}
|
||||
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -368,10 +379,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
cmd.Println("Creating debug bundle...")
|
||||
|
||||
request := &proto.DebugBundleRequest{
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel.String(),
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
@@ -69,6 +70,7 @@ var (
|
||||
autoConnectDisabled bool
|
||||
extraIFaceBlackList []string
|
||||
anonymizeFlag bool
|
||||
anonymizeLevelFlag string
|
||||
dnsRouteInterval time.Duration
|
||||
// lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection
|
||||
// flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead).
|
||||
@@ -156,7 +158,8 @@ func init() {
|
||||
rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file")
|
||||
rootCmd.PersistentFlags().StringVar(&preSharedKey, preSharedKeyFlag, "", "Sets WireGuard PreSharedKey property. If set, then only peers that have the same key can communicate.")
|
||||
rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device")
|
||||
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output")
|
||||
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize public IP addresses, MAC addresses, and non-netbird.io domains in logs and status output; private, CGNAT, and link-local IP ranges are kept (see --anonymize-level strict)")
|
||||
rootCmd.PersistentFlags().StringVar(&anonymizeLevelFlag, "anonymize-level", "", "anonymization level: \"default\" or \"strict\"; strict also anonymizes private, CGNAT, and link-local IP ranges, peer names, and WireGuard public keys. Setting this flag implies --anonymize")
|
||||
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location")
|
||||
|
||||
rootCmd.AddCommand(upCmd)
|
||||
@@ -293,6 +296,19 @@ var CLIBackOffSettings = &backoff.ExponentialBackOff{
|
||||
Clock: backoff.SystemClock,
|
||||
}
|
||||
|
||||
// effectiveAnonymize resolves the --anonymize and --anonymize-level flags:
|
||||
// setting a level implies anonymization, and an invalid level is rejected.
|
||||
func effectiveAnonymize() (bool, anonymize.Level, error) {
|
||||
if anonymizeLevelFlag == "" {
|
||||
return anonymizeFlag, anonymize.LevelDefault, nil
|
||||
}
|
||||
level := anonymize.ParseLevel(anonymizeLevelFlag)
|
||||
if !strings.EqualFold(anonymizeLevelFlag, level.String()) {
|
||||
return false, anonymize.LevelDefault, fmt.Errorf("invalid anonymize level %q: use %q or %q", anonymizeLevelFlag, anonymize.LevelDefault.String(), anonymize.LevelStrict.String())
|
||||
}
|
||||
return true, level, nil
|
||||
}
|
||||
|
||||
func getSetupKey() (string, error) {
|
||||
if setupKeyPath != "" && setupKey == "" {
|
||||
return getSetupKeyFromFile(setupKeyPath)
|
||||
|
||||
@@ -121,8 +121,14 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
sessionExpiresAt = ts.AsTime().UTC()
|
||||
}
|
||||
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||
Anonymize: anonymizeFlag,
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel,
|
||||
DaemonVersion: resp.GetDaemonVersion(),
|
||||
DaemonStatus: nbstatus.ParseDaemonStatus(status),
|
||||
StatusFilter: statusFilter,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// its wg interface into firewalld's "trusted" zone. This is required because
|
||||
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
|
||||
// versions, which returns EPERM to any other process that tries to insert
|
||||
// rules into them. The workaround mirrors what Tailscale does: let firewalld
|
||||
// itself add the accept rules to its own chains by trusting the interface.
|
||||
// rules into them. Trusting the interface makes firewalld itself add the
|
||||
// accept rules to its own chains instead.
|
||||
package firewalld
|
||||
|
||||
// TrustedZone is the firewalld zone name used for interfaces whose traffic
|
||||
|
||||
@@ -22,6 +22,16 @@ import (
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
)
|
||||
|
||||
const (
|
||||
// wgMsgTypeHandshakeInitiation is the lowest WireGuard message type.
|
||||
wgMsgTypeHandshakeInitiation uint32 = 1
|
||||
// wgMsgTypeTransport is the highest WireGuard message type.
|
||||
wgMsgTypeTransport uint32 = 4
|
||||
// wgMinMsgSize is the smallest WireGuard message: transport data with an empty
|
||||
// payload, which is what a keepalive is.
|
||||
wgMinMsgSize = 32
|
||||
)
|
||||
|
||||
type receiverCreator struct {
|
||||
iceBind *ICEBind
|
||||
}
|
||||
@@ -216,8 +226,15 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO
|
||||
for i := 0; i < numMsgs; i++ {
|
||||
msg := &(*msgs)[i]
|
||||
|
||||
// todo: handle err
|
||||
if ok, _ := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
|
||||
if ok, err := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
|
||||
if err != nil {
|
||||
log.Debugf("failed to handle STUN packet from %s: %v", msg.Addr, err)
|
||||
}
|
||||
// WireGuard reuses sizes and eps across reads and only skips a slot
|
||||
// whose size is below the minimum message size. Leaving a consumed
|
||||
// slot untouched makes it process this buffer again under the
|
||||
// previous packet's length and endpoint.
|
||||
sizes[i] = 0
|
||||
continue
|
||||
}
|
||||
sizes[i] = msg.N
|
||||
@@ -271,11 +288,16 @@ func (s *ICEBind) createOrUpdateMux() {
|
||||
|
||||
func (s *ICEBind) filterOutStunMessages(buffers [][]byte, n int, addr net.Addr) (bool, error) {
|
||||
for i := range buffers {
|
||||
if !stun.IsMessage(buffers[i]) {
|
||||
if n > len(buffers[i]) {
|
||||
continue
|
||||
}
|
||||
pkt := buffers[i][:n]
|
||||
|
||||
if isWireGuardMsg(pkt) || !stun.IsMessage(pkt) {
|
||||
continue
|
||||
}
|
||||
|
||||
msg, err := s.parseSTUNMessage(buffers[i][:n])
|
||||
msg, err := s.parseSTUNMessage(pkt)
|
||||
if err != nil {
|
||||
buffers[i] = []byte{}
|
||||
return true, err
|
||||
@@ -347,18 +369,34 @@ func putMessages(msgs *[]ipv6.Message, msgsPool *sync.Pool) {
|
||||
msgsPool.Put(msgs)
|
||||
}
|
||||
|
||||
func isTransportPkg(buffers [][]byte, n int) bool {
|
||||
// The first buffer should contain at least 4 bytes for type
|
||||
if len(buffers[0]) < 4 {
|
||||
return true
|
||||
// isWireGuardMsg reports whether the packet carries a WireGuard message header: a
|
||||
// little-endian uint32 message type in the range 1..4, which leaves the three bytes
|
||||
// after the type byte zero, in a packet long enough to hold any WireGuard message.
|
||||
//
|
||||
// A well formed STUN message cannot take that shape. Its length field sits in the two
|
||||
// bytes the type must leave zero, and for a message of at least wgMinMsgSize bytes that
|
||||
// field holds at least 12, so the two framings do not overlap. The test has to be this
|
||||
// tight because stun.IsMessage only looks at the magic cookie, which in a WireGuard
|
||||
// message overlaps the receiver index: a session whose index happens to equal the cookie
|
||||
// would otherwise have all of its inbound data misrouted to the STUN handler until the
|
||||
// next rekey.
|
||||
func isWireGuardMsg(pkt []byte) bool {
|
||||
if len(pkt) < wgMinMsgSize {
|
||||
return false
|
||||
}
|
||||
|
||||
// WireGuard packet type is a little-endian uint32 at start
|
||||
packetType := binary.LittleEndian.Uint32(buffers[0][:4])
|
||||
|
||||
// Check if packetType matches known WireGuard message types
|
||||
if packetType == 4 && n > 32 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
msgType := binary.LittleEndian.Uint32(pkt[:4])
|
||||
return msgType >= wgMsgTypeHandshakeInitiation && msgType <= wgMsgTypeTransport
|
||||
}
|
||||
|
||||
// isTransportPkg reports whether the packet is WireGuard transport data carrying a
|
||||
// payload, which is what counts as peer activity. A keepalive holds no payload and is
|
||||
// exactly wgMinMsgSize bytes.
|
||||
func isTransportPkg(buffers [][]byte, n int) bool {
|
||||
if n < 4 || n > len(buffers[0]) {
|
||||
return false
|
||||
}
|
||||
|
||||
msgType := binary.LittleEndian.Uint32(buffers[0][:4])
|
||||
return msgType == wgMsgTypeTransport && n > wgMinMsgSize
|
||||
}
|
||||
|
||||
215
client/iface/bind/stun_filter_test.go
Normal file
215
client/iface/bind/stun_filter_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
//go:build !js
|
||||
|
||||
package bind
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/stun/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/ipv4"
|
||||
wgConn "golang.zx2c4.com/wireguard/conn"
|
||||
)
|
||||
|
||||
// magicCookieBytes is the STUN magic cookie as it appears on the wire. In a
|
||||
// WireGuard message the same offset holds the receiver (or sender) index, which is
|
||||
// a random uint32, so a session can draw exactly this value.
|
||||
var magicCookieBytes = []byte{0x21, 0x12, 0xA4, 0x42}
|
||||
|
||||
const testBufSize = 1500
|
||||
|
||||
// wgMsg builds a WireGuard message of the given type and size, with the index field
|
||||
// at bytes 4:8 set to index.
|
||||
func wgMsg(msgType uint32, size int, index []byte) []byte {
|
||||
pkt := make([]byte, size)
|
||||
binary.LittleEndian.PutUint32(pkt[:4], msgType)
|
||||
copy(pkt[4:8], index)
|
||||
return pkt
|
||||
}
|
||||
|
||||
// intoBuffer copies pkt into a full-size receive buffer, the way the kernel read
|
||||
// does, so tests see the same buffer/length split as the hot path.
|
||||
func intoBuffer(pkt []byte) [][]byte {
|
||||
buf := make([]byte, testBufSize)
|
||||
copy(buf, pkt)
|
||||
return [][]byte{buf}
|
||||
}
|
||||
|
||||
func TestFilterOutStunMessages_PassesWireGuardWithCookieShapedIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgType uint32
|
||||
size int
|
||||
}{
|
||||
{"transport data", wgMsgTypeTransport, 128},
|
||||
{"keepalive", wgMsgTypeTransport, wgMinMsgSize},
|
||||
{"handshake initiation", wgMsgTypeHandshakeInitiation, 148},
|
||||
{"handshake response", 2, 92},
|
||||
{"cookie reply", 3, 64},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pkt := wgMsg(tc.msgType, tc.size, magicCookieBytes)
|
||||
require.True(t, stun.IsMessage(pkt), "precondition: pion sees this as STUN")
|
||||
|
||||
buffers := intoBuffer(pkt)
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, tc.size, &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, filtered, "WireGuard message must be handed to WireGuard, not the STUN handler")
|
||||
assert.Len(t, buffers[0], testBufSize, "buffer must be left intact for WireGuard")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOutStunMessages_FiltersRealSTUNMessage(t *testing.T) {
|
||||
msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint)
|
||||
require.NoError(t, err)
|
||||
|
||||
buffers := intoBuffer(msg.Raw)
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, filtered, "STUN message must be consumed by the STUN handler")
|
||||
assert.Empty(t, buffers[0], "consumed buffer must be emptied so WireGuard does not see it")
|
||||
}
|
||||
|
||||
// TestIsWireGuardMsg_DisjointFromSTUN locks the invariant the filter relies on: a
|
||||
// well formed STUN message long enough to be a WireGuard message always has a
|
||||
// non-zero length field, so it cannot be mistaken for a WireGuard header.
|
||||
func TestIsWireGuardMsg_DisjointFromSTUN(t *testing.T) {
|
||||
types := []stun.MessageType{
|
||||
stun.BindingRequest,
|
||||
stun.BindingSuccess,
|
||||
stun.BindingError,
|
||||
{Method: stun.MethodBinding, Class: stun.ClassIndication},
|
||||
}
|
||||
|
||||
for _, msgType := range types {
|
||||
// Long enough that the length guard is not what makes this pass.
|
||||
msg, err := stun.Build(msgType, stun.TransactionID,
|
||||
stun.NewUsername("remoteUfrag:localUfrag"), stun.Fingerprint)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(msg.Raw), wgMinMsgSize, "precondition: %s", msgType)
|
||||
assert.False(t, isWireGuardMsg(msg.Raw),
|
||||
"%s must not look like a WireGuard message", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWireGuardMsg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
want bool
|
||||
}{
|
||||
{"transport data", wgMsg(wgMsgTypeTransport, 128, nil), true},
|
||||
{"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), true},
|
||||
{"unknown type 5", wgMsg(5, 128, nil), false},
|
||||
{"type 0", wgMsg(0, 128, nil), false},
|
||||
{"non-zero reserved byte", []byte{0x04, 0x00, 0x01, 0x00}, false},
|
||||
{"too short", []byte{0x04, 0x00, 0x00}, false},
|
||||
{"empty", nil, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isWireGuardMsg(tc.pkt), "wrong classification for %s", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterOutStunMessages_IgnoresBytesBeyondPacket guards against classifying on
|
||||
// buffer contents left over from an earlier, longer packet.
|
||||
func TestFilterOutStunMessages_IgnoresBytesBeyondPacket(t *testing.T) {
|
||||
buf := make([]byte, testBufSize)
|
||||
copy(buf[4:8], magicCookieBytes)
|
||||
buffers := [][]byte{buf}
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, 2, &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, filtered, "a 2 byte packet must not be classified from stale buffer bytes")
|
||||
}
|
||||
|
||||
// TestReceiveFn_ClearsSizeOfConsumedPacket covers the accounting WireGuard relies
|
||||
// on: sizes is reused across reads, so a slot whose packet was consumed as STUN must
|
||||
// be reported as empty. Otherwise WireGuard reprocesses the same buffer under the
|
||||
// previous packet's length, which for a WireGuard-shaped packet means it is handled
|
||||
// twice.
|
||||
func TestReceiveFn_ClearsSizeOfConsumedPacket(t *testing.T) {
|
||||
conn := listenUDP(t, "udp4", "127.0.0.1:0")
|
||||
defer conn.Close()
|
||||
|
||||
recvFn := receiverCreator{setupICEBind(t)}.CreateReceiverFn(
|
||||
ipv4.NewPacketConn(conn), conn, false, createMsgPool(),
|
||||
)
|
||||
|
||||
msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint)
|
||||
require.NoError(t, err)
|
||||
|
||||
sender := listenUDP(t, "udp4", "127.0.0.1:0")
|
||||
defer sender.Close()
|
||||
_, err = sender.WriteTo(msg.Raw, conn.LocalAddr())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(3*time.Second)))
|
||||
|
||||
bufs := [][]byte{make([]byte, 1500)}
|
||||
// A leftover size from an earlier read, which is what makes the missing reset
|
||||
// observable.
|
||||
sizes := []int{148}
|
||||
eps := make([]wgConn.Endpoint, 1)
|
||||
|
||||
n, err := recvFn(bufs, sizes, eps)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, n)
|
||||
assert.Zero(t, sizes[0], "consumed STUN packet must not leave a size behind for WireGuard")
|
||||
}
|
||||
|
||||
func TestIsTransportPkg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pkt []byte
|
||||
n int
|
||||
want bool
|
||||
}{
|
||||
{"transport data with payload", wgMsg(wgMsgTypeTransport, 128, nil), 128, true},
|
||||
{"keepalive", wgMsg(wgMsgTypeTransport, wgMinMsgSize, nil), wgMinMsgSize, false},
|
||||
{"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), 148, false},
|
||||
{"stale type bytes beyond packet", wgMsg(wgMsgTypeTransport, 128, nil), 2, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isTransportPkg(intoBuffer(tc.pkt), tc.n),
|
||||
"wrong activity classification for %s", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType covers the one STUN
|
||||
// encoding whose leading bytes collide with a WireGuard message type: method 0x080 as a
|
||||
// request encodes to 0x0200, so the type byte reads as a handshake response and the byte
|
||||
// after it is zero. Only the length check keeps such a message out of WireGuard's hands.
|
||||
// pion implements no method in that range, so this is a synthetic worst case rather than
|
||||
// traffic ICE produces.
|
||||
func TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType(t *testing.T) {
|
||||
msg, err := stun.Build(stun.NewType(stun.Method(0x080), stun.ClassRequest), stun.TransactionID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{0x02, 0x00, 0x00, 0x00}, msg.Raw[:4],
|
||||
"precondition: the leading bytes read as a WireGuard message type")
|
||||
|
||||
buffers := intoBuffer(msg.Raw)
|
||||
bind := &ICEBind{}
|
||||
|
||||
filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, filtered, "STUN message must be consumed despite its WireGuard-shaped type")
|
||||
}
|
||||
@@ -34,9 +34,8 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
)
|
||||
|
||||
const readmeContent = `Netbird debug bundle
|
||||
This debug bundle contains the following files.
|
||||
If the --anonymize flag is set, the files are anonymized to protect sensitive information.
|
||||
const readmeContent = `This debug bundle contains the following files.
|
||||
If anonymization is enabled (--anonymize / --anonymize-level), the files are anonymized to protect sensitive information.
|
||||
|
||||
status.txt: Anonymized status information of the NetBird client.
|
||||
client.log: Most recent, anonymized client log file of the NetBird client.
|
||||
@@ -70,21 +69,34 @@ capture.pcap: Packet capture in pcap format. Only present when capture was runni
|
||||
|
||||
|
||||
Anonymization Process
|
||||
The files in this bundle have been anonymized to protect sensitive information. Here's how the anonymization was applied:
|
||||
The files in this bundle have been anonymized to protect sensitive information. The level applied to this bundle is recorded at the top of this file. Here's how the anonymization was applied:
|
||||
|
||||
IP Addresses
|
||||
|
||||
IPv4 addresses are replaced with addresses starting from 198.51.100.0
|
||||
IPv6 addresses are replaced with addresses starting from 100::
|
||||
Default level:
|
||||
- Public IPv4 addresses are replaced with addresses starting from 198.51.100.0
|
||||
- Public IPv6 addresses are replaced with addresses starting from 2001:db8:ffff::
|
||||
- IPv6 unique local addresses (fc00::/7) are anonymized as well: their random global ID uniquely identifies the network.
|
||||
- IP addresses from internal IPv4 ranges and well-known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., 169.254., fe80::).
|
||||
|
||||
Strict level (--anonymize-level strict), in addition to the default level:
|
||||
- Private (RFC 1918), CGNAT (100.64.0.0/10), and link-local (169.254.0.0/16, fe80::/10) addresses are anonymized too.
|
||||
- Internal IPv4 addresses are replaced with addresses starting from 198.18.0.0 and internal IPv6 addresses with addresses starting from 2001:db8:1::, so internal addresses remain distinguishable from public ones.
|
||||
- Addresses are mapped in order of first appearance: subnet structure, allocation scheme, and gateway conventions are not preserved. Prefix lengths of networks are preserved.
|
||||
- Peer names in front of NetBird domains are replaced with numbered placeholders (e.g. peer-1.netbird.cloud), and subdomain labels of other domains with host-N placeholders.
|
||||
- WireGuard public keys are replaced with consistent placeholder keys.
|
||||
|
||||
IP addresses from non public ranges and well known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., etc.).
|
||||
Reoccuring IP addresses are replaced with the same anonymized address.
|
||||
|
||||
Note: The anonymized IP addresses in the status file do not match those in the log and routes files. However, the anonymized IP addresses are consistent within the status file and across the routes and log files.
|
||||
|
||||
MAC Addresses
|
||||
MAC addresses are replaced at every anonymization level with consistent placeholders counting up from 02:00:00:00:00:01. Broadcast, multicast, and all-zero addresses are kept. At the default level a preserved IPv6 link-local address may still embed a MAC address (EUI-64); the strict level anonymizes those addresses.
|
||||
|
||||
Domains
|
||||
All domain names (except for the netbird domains) are replaced with randomly generated strings ending in ".domain". Anonymized domains are consistent across all files in the bundle.
|
||||
Reoccuring domain names are replaced with the same anonymized domain.
|
||||
At the strict level, the peer name labels in front of netbird domains are anonymized as well.
|
||||
|
||||
Sync Response
|
||||
The network_map.json file contains the following anonymized information:
|
||||
@@ -281,6 +293,7 @@ type BundleGenerator struct {
|
||||
cliVersion string
|
||||
|
||||
anonymize bool
|
||||
anonymizeLevel anonymize.Level
|
||||
includeSystemInfo bool
|
||||
logFileCount uint32
|
||||
|
||||
@@ -288,7 +301,10 @@ type BundleGenerator struct {
|
||||
}
|
||||
|
||||
type BundleConfig struct {
|
||||
Anonymize bool
|
||||
Anonymize bool
|
||||
// AnonymizeLevel selects how much the anonymizer redacts.
|
||||
// anonymize.LevelStrict implies Anonymize.
|
||||
AnonymizeLevel anonymize.Level
|
||||
IncludeSystemInfo bool
|
||||
LogFileCount uint32
|
||||
}
|
||||
@@ -327,8 +343,11 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
uiLogOpener = openLogFile
|
||||
}
|
||||
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(cfg.AnonymizeLevel)
|
||||
|
||||
return &BundleGenerator{
|
||||
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
|
||||
anonymizer: anonymizer,
|
||||
|
||||
internalConfig: deps.InternalConfig,
|
||||
statusRecorder: deps.StatusRecorder,
|
||||
@@ -345,7 +364,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
daemonVersion: deps.DaemonVersion,
|
||||
cliVersion: deps.CliVersion,
|
||||
|
||||
anonymize: cfg.Anonymize,
|
||||
anonymize: cfg.Anonymize || cfg.AnonymizeLevel >= anonymize.LevelStrict,
|
||||
anonymizeLevel: cfg.AnonymizeLevel,
|
||||
includeSystemInfo: cfg.IncludeSystemInfo,
|
||||
logFileCount: logFileCount,
|
||||
}
|
||||
@@ -485,7 +505,13 @@ func (g *BundleGenerator) addSystemInfo() {
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addReadme() error {
|
||||
readmeReader := strings.NewReader(readmeContent)
|
||||
level := "none (anonymization disabled)"
|
||||
if g.anonymize {
|
||||
level = g.anonymizeLevel.String()
|
||||
}
|
||||
header := fmt.Sprintf("Netbird debug bundle\nAnonymization level applied to this bundle: %s\n", level)
|
||||
|
||||
readmeReader := strings.NewReader(header + readmeContent)
|
||||
if err := g.addFileToZip(readmeReader, "README.txt"); err != nil {
|
||||
return fmt.Errorf("add README file to zip: %w", err)
|
||||
}
|
||||
@@ -507,9 +533,10 @@ func (g *BundleGenerator) addStatus() error {
|
||||
fullStatus := g.statusRecorder.GetFullStatus()
|
||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
Anonymize: g.anonymize,
|
||||
AnonymizeLevel: g.anonymizeLevel,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
})
|
||||
overview.CliVersion = g.cliVersion
|
||||
statusOutput := overview.FullDetailSummary()
|
||||
@@ -662,7 +689,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
configContent.WriteString("NetBird Client Configuration:\n\n")
|
||||
|
||||
if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil {
|
||||
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", key.PublicKey().String()))
|
||||
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", g.anonymizer.AnonymizeWGKey(key.PublicKey().String())))
|
||||
}
|
||||
configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface))
|
||||
configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort))
|
||||
@@ -952,6 +979,11 @@ func (g *BundleGenerator) addUpdateLogs() error {
|
||||
}
|
||||
|
||||
baseName := filepath.Base(logFile)
|
||||
data, err = g.anonymizeBytes(data)
|
||||
if err != nil {
|
||||
log.Warnf("skipping update log file %s: %v", baseName, err)
|
||||
continue
|
||||
}
|
||||
if err := g.addFileToZip(bytes.NewReader(data), filepath.Join("update-logs", baseName)); err != nil {
|
||||
return fmt.Errorf("add update log file %s to zip: %w", baseName, err)
|
||||
}
|
||||
@@ -979,6 +1011,13 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
|
||||
}
|
||||
|
||||
fileName := filepath.Base(match)
|
||||
// Corrupted state files usually fail structured JSON anonymization,
|
||||
// so run them through the string anonymizer instead.
|
||||
data, err = g.anonymizeBytes(data)
|
||||
if err != nil {
|
||||
log.Warnf("skipping corrupted state file %s: %v", fileName, err)
|
||||
continue
|
||||
}
|
||||
if err := g.addFileToZip(bytes.NewReader(data), "corrupted_states/"+fileName); err != nil {
|
||||
log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err)
|
||||
continue
|
||||
@@ -990,6 +1029,27 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// anonymizeBytes runs raw file content through the string anonymizer line by
|
||||
// line when anonymization is enabled. It errors instead of returning partial
|
||||
// content, so a caller never adds an unanonymized fallback to the bundle.
|
||||
func (g *BundleGenerator) anonymizeBytes(data []byte) ([]byte, error) {
|
||||
if !g.anonymize {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
buf.WriteString(g.anonymizer.AnonymizeString(scanner.Text()))
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("anonymize content: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addMetrics() error {
|
||||
if g.clientMetrics == nil {
|
||||
log.Debugf("skipping metrics in debug bundle: no metrics collector")
|
||||
@@ -1462,6 +1522,7 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize.
|
||||
}
|
||||
|
||||
peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn)
|
||||
peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey)
|
||||
|
||||
anonymizeSSHConfig(peer.SshConfig)
|
||||
}
|
||||
|
||||
@@ -35,14 +35,14 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName))
|
||||
sb.WriteString(fmt.Sprintf(" public key: %s\n", s.PublicKey))
|
||||
sb.WriteString(fmt.Sprintf(" public key: %s\n", g.anonymizer.AnonymizeWGKey(s.PublicKey)))
|
||||
sb.WriteString(fmt.Sprintf(" listen port: %d\n", s.ListenPort))
|
||||
if s.FWMark != 0 {
|
||||
sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark))
|
||||
}
|
||||
|
||||
for _, peer := range s.Peers {
|
||||
sb.WriteString(fmt.Sprintf("\npeer: %s\n", peer.PublicKey))
|
||||
sb.WriteString(fmt.Sprintf("\npeer: %s\n", g.anonymizer.AnonymizeWGKey(peer.PublicKey)))
|
||||
if peer.Endpoint.IP != nil {
|
||||
if g.anonymize {
|
||||
anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint)
|
||||
@@ -54,7 +54,11 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
|
||||
if len(peer.AllowedIPs) > 0 {
|
||||
var ipStrings []string
|
||||
for _, ipnet := range peer.AllowedIPs {
|
||||
ipStrings = append(ipStrings, ipnet.String())
|
||||
ipStr := ipnet.String()
|
||||
if g.anonymize {
|
||||
ipStr = g.anonymizer.AnonymizeIPString(ipStr)
|
||||
}
|
||||
ipStrings = append(ipStrings, ipStr)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", ")))
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/firewall"
|
||||
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||
@@ -48,7 +49,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/guard"
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/signaling"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
@@ -187,7 +187,7 @@ type EngineServices struct {
|
||||
type Engine struct {
|
||||
// signal is a Signal Service client
|
||||
signal signal.Client
|
||||
signaler *signaling.Signaler
|
||||
signaler *peer.Signaler
|
||||
// mgmClient is a Management Service client
|
||||
mgmClient mgm.Client
|
||||
// peerConns is a map that holds all the peers that are known to this peer
|
||||
@@ -330,7 +330,7 @@ func NewEngine(
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
signal: services.SignalClient,
|
||||
signaler: signaling.NewSignaler(services.SignalClient, config.WgPrivateKey),
|
||||
signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey),
|
||||
mgmClient: services.MgmClient,
|
||||
relayManager: services.RelayManager,
|
||||
peerStore: peerstore.NewConnStore(),
|
||||
@@ -1386,6 +1386,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
|
||||
bundleJobParams := debug.BundleConfig{
|
||||
Anonymize: params.Anonymize,
|
||||
AnonymizeLevel: anonymize.ParseLevel(params.AnonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
LogFileCount: uint32(params.LogFileCount),
|
||||
}
|
||||
@@ -2816,7 +2817,7 @@ func createFile(path string) error {
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func convertToOfferAnswer(msg *sProto.Message) (*signaling.OfferAnswer, error) {
|
||||
func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) {
|
||||
remoteCred, err := signal.UnMarshalCredential(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2832,9 +2833,9 @@ func convertToOfferAnswer(msg *sProto.Message) (*signaling.OfferAnswer, error) {
|
||||
}
|
||||
|
||||
// Handle optional SessionID
|
||||
var sessionID *icemaker.SessionID
|
||||
var sessionID *peer.ICESessionID
|
||||
if sessionBytes := msg.GetBody().GetSessionId(); sessionBytes != nil {
|
||||
if id, err := icemaker.SessionIDFromBytes(sessionBytes); err != nil {
|
||||
if id, err := peer.ICESessionIDFromBytes(sessionBytes); err != nil {
|
||||
log.Warnf("Invalid session ID in message: %v", err)
|
||||
sessionID = nil // Set to nil if conversion fails
|
||||
} else {
|
||||
@@ -2844,8 +2845,8 @@ func convertToOfferAnswer(msg *sProto.Message) (*signaling.OfferAnswer, error) {
|
||||
|
||||
relayIP := decodeRelayIP(msg.GetBody().GetRelayServerIP())
|
||||
|
||||
offerAnswer := signaling.OfferAnswer{
|
||||
IceCredentials: signaling.IceCredentials{
|
||||
offerAnswer := peer.OfferAnswer{
|
||||
IceCredentials: peer.IceCredentials{
|
||||
UFrag: remoteCred.UFrag,
|
||||
Pwd: remoteCred.Pwd,
|
||||
},
|
||||
|
||||
@@ -4,11 +4,17 @@ package metrics
|
||||
type ConnectionType string
|
||||
|
||||
const (
|
||||
// ConnectionTypeICE represents a direct peer-to-peer connection using ICE
|
||||
ConnectionTypeICE ConnectionType = "ice"
|
||||
// ConnectionTypeICEP2P represents a direct peer-to-peer connection using ICE
|
||||
ConnectionTypeICEP2P ConnectionType = "ice_p2p"
|
||||
|
||||
// ConnectionTypeICETurn represents an ICE connection through a TURN server
|
||||
ConnectionTypeICETurn ConnectionType = "ice_turn"
|
||||
|
||||
// ConnectionTypeRelay represents a relayed connection
|
||||
ConnectionTypeRelay ConnectionType = "relay"
|
||||
|
||||
// ConnectionTypeUnknown represents a connection with no active transport. It is not pushed.
|
||||
ConnectionTypeUnknown ConnectionType = "unknown"
|
||||
)
|
||||
|
||||
// String returns the string representation of the connection type
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestInfluxDBMetrics_RecordAndExport(t *testing.T) {
|
||||
WgHandshakeSuccess: time.Now().Add(-1 * time.Second),
|
||||
}
|
||||
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := m.Export(&buf)
|
||||
@@ -60,7 +60,7 @@ func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) {
|
||||
|
||||
// Record multiple times and verify consistent field order
|
||||
for i := 0; i < 10; i++ {
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -56,14 +56,33 @@ Measurement: `netbird_peer_connection`
|
||||
|
||||
Tags:
|
||||
- `deployment_type`: "cloud" | "selfhosted" | "unknown"
|
||||
- `connection_type`: "ice" | "relay"
|
||||
- `connection_type`: "ice_p2p" | "ice_turn" | "relay" (see below)
|
||||
- `attempt_type`: "initial" | "reconnection"
|
||||
- `version`: NetBird version string
|
||||
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
|
||||
- `arch`: CPU architecture (amd64, arm64, etc.)
|
||||
- `peer_id`: anonymised peer identifier (truncated SHA-256 of the WireGuard public key)
|
||||
- `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides
|
||||
|
||||
**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.
|
||||
|
||||
#### `connection_type` values
|
||||
|
||||
Derived from the connection priority (`conntype.ConnPriority`) by `metricsConnType` in `client/internal/peer/conn.go`:
|
||||
|
||||
| Value | Priority | Traffic is |
|
||||
|-------|----------|------------|
|
||||
| `ice_p2p` | `ICEP2P` | direct peer-to-peer |
|
||||
| `ice_turn` | `ICETurn` | relayed, through a TURN server |
|
||||
| `relay` | `Relay` | relayed, through a NetBird relay |
|
||||
| `unknown` | `None` or unrecognised | no active transport — **the sample is not pushed** |
|
||||
|
||||
**Direct traffic is `ice_p2p` only.** `ice_turn` is relayed despite being negotiated by ICE, matching `Conn.isRelayed`.
|
||||
|
||||
`None` means no transport is active: not established yet, or reset after a relay drop or a peer-state reset. Such a sample cannot be attributed to a transport, so `recordConnectionMetrics` drops it instead of pushing it — `unknown` therefore never appears in the bucket. Connection counts are counts of connections whose transport was known at sampling time.
|
||||
|
||||
**Samples recorded before 0.77 used a single `ice` value** which covered `ICEP2P`, `ICETurn` *and* `None`, so historical `ice` samples overstate direct connections by an unknown amount and must not be compared with `ice_p2p`.
|
||||
|
||||
### Sync Duration
|
||||
|
||||
Measurement: `netbird_sync`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,18 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// StatusIdle indicate the peer is in disconnected state
|
||||
StatusIdle ConnStatus = iota
|
||||
// StatusConnecting indicate the peer is in connecting state
|
||||
StatusConnecting
|
||||
// StatusConnected indicate the peer is in connected state
|
||||
StatusConnected
|
||||
)
|
||||
|
||||
// connStatusInputs is the primitive-valued snapshot of the state that drives the
|
||||
// tri-state connection classification. Extracted so the decision logic can be unit-tested
|
||||
// without constructing full Worker/Handshaker objects.
|
||||
@@ -8,7 +21,24 @@ type connStatusInputs struct {
|
||||
peerUsesRelay bool // remote peer advertises relay support AND local has relay
|
||||
relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay)
|
||||
remoteSupportsICE bool // remote peer sent ICE credentials
|
||||
iceWorkerCreated bool // local ICE worker exists (false in force-relay mode)
|
||||
iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode)
|
||||
iceStatusConnecting bool // statusICE is anything other than Disconnected
|
||||
iceInProgress bool // a negotiation is currently in flight
|
||||
}
|
||||
|
||||
// ConnStatus describe the status of a peer's connection
|
||||
type ConnStatus int32
|
||||
|
||||
func (s ConnStatus) String() string {
|
||||
switch s {
|
||||
case StatusConnecting:
|
||||
return "Connecting"
|
||||
case StatusConnected:
|
||||
return "Connected"
|
||||
case StatusIdle:
|
||||
return "Idle"
|
||||
default:
|
||||
log.Errorf("unknown status: %d", s)
|
||||
return "INVALID_PEER_CONNECTION_STATUS"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package status
|
||||
package peer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -3,33 +3,30 @@ package peer
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/conntype"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/dispatcher"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/guard"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/metricsstages"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/signaling"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/status"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
var testDispatcher = dispatcher.NewConnectionDispatcher()
|
||||
|
||||
var connConf = ConnConfig{
|
||||
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
||||
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
|
||||
Timeout: time.Second,
|
||||
LocalWgPort: 51820,
|
||||
WgConfig: WgConfig{
|
||||
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
ICEConfig: ice.Config{
|
||||
InterfaceBlackList: nil,
|
||||
},
|
||||
@@ -57,37 +54,92 @@ func TestConn_GetKey(t *testing.T) {
|
||||
swWatcher := guard.NewSRWatcher(nil, nil, nil, connConf.ICEConfig)
|
||||
|
||||
sd := ServiceDependencies{
|
||||
SrWatcher: swWatcher,
|
||||
SrWatcher: swWatcher,
|
||||
PeerConnDispatcher: testDispatcher,
|
||||
}
|
||||
conn, err := NewConn(connConf, sd)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
got := conn.GetKey()
|
||||
|
||||
assert.Equal(t, got, connConf.Key, "they should be equal")
|
||||
}
|
||||
|
||||
// TestConn_DiscardMessagesWhenNotOpened: signal messages posted to a not yet
|
||||
// opened connection must be discarded without blocking or panicking.
|
||||
func TestConn_DiscardMessagesWhenNotOpened(t *testing.T) {
|
||||
func TestConn_OnRemoteOffer(t *testing.T) {
|
||||
swWatcher := guard.NewSRWatcher(nil, nil, nil, connConf.ICEConfig)
|
||||
sd := ServiceDependencies{
|
||||
StatusRecorder: status.NewRecorder("https://mgm"),
|
||||
SrWatcher: swWatcher,
|
||||
StatusRecorder: NewRecorder("https://mgm"),
|
||||
SrWatcher: swWatcher,
|
||||
PeerConnDispatcher: testDispatcher,
|
||||
}
|
||||
conn, err := NewConn(connConf, sd)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
offerAnswer := signaling.OfferAnswer{
|
||||
IceCredentials: signaling.IceCredentials{
|
||||
onNewOfferChan := make(chan struct{})
|
||||
|
||||
conn.handshaker.AddRelayListener(func(remoteOfferAnswer *OfferAnswer) {
|
||||
onNewOfferChan <- struct{}{}
|
||||
})
|
||||
|
||||
conn.OnRemoteOffer(OfferAnswer{
|
||||
IceCredentials: IceCredentials{
|
||||
UFrag: "test",
|
||||
Pwd: "test",
|
||||
},
|
||||
WgListenPort: 0,
|
||||
Version: "",
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case <-onNewOfferChan:
|
||||
// success
|
||||
case <-ctx.Done():
|
||||
t.Error("expected to receive a new offer notification, but timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_OnRemoteAnswer(t *testing.T) {
|
||||
swWatcher := guard.NewSRWatcher(nil, nil, nil, connConf.ICEConfig)
|
||||
sd := ServiceDependencies{
|
||||
StatusRecorder: NewRecorder("https://mgm"),
|
||||
SrWatcher: swWatcher,
|
||||
PeerConnDispatcher: testDispatcher,
|
||||
}
|
||||
conn, err := NewConn(connConf, sd)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
onNewOfferChan := make(chan struct{})
|
||||
|
||||
conn.handshaker.AddRelayListener(func(remoteOfferAnswer *OfferAnswer) {
|
||||
onNewOfferChan <- struct{}{}
|
||||
})
|
||||
|
||||
conn.OnRemoteAnswer(OfferAnswer{
|
||||
IceCredentials: IceCredentials{
|
||||
UFrag: "test",
|
||||
Pwd: "test",
|
||||
},
|
||||
WgListenPort: 0,
|
||||
Version: "",
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case <-onNewOfferChan:
|
||||
// success
|
||||
case <-ctx.Done():
|
||||
t.Error("expected to receive a new offer notification, but timed out")
|
||||
}
|
||||
conn.OnRemoteOffer(offerAnswer)
|
||||
conn.OnRemoteAnswer(offerAnswer)
|
||||
conn.OnRemoteCandidate(nil, nil)
|
||||
conn.Close(false)
|
||||
}
|
||||
|
||||
func TestConn_presharedKey(t *testing.T) {
|
||||
@@ -270,7 +322,7 @@ func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn {
|
||||
ctx: context.Background(),
|
||||
config: cfg,
|
||||
Log: log.WithField("peer", cfg.Key),
|
||||
metricsStages: &metricsstages.MetricsStages{},
|
||||
metricsStages: &MetricsStages{},
|
||||
}
|
||||
conn.SetOnDisconnected(func(remotePeer string) {
|
||||
*disconnected = append(*disconnected, remotePeer)
|
||||
@@ -289,20 +341,20 @@ func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) {
|
||||
conn := newWGTimeoutTestConn(true, &disconnected)
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.handleWGTimeout()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
}
|
||||
assert.Empty(t, disconnected, "escalation must not fire below the threshold")
|
||||
|
||||
conn.handleWGTimeout()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected,
|
||||
"reaching the threshold must report the peer disconnected once")
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.handleWGTimeout()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
}
|
||||
assert.Len(t, disconnected, 1, "escalation must restart counting after firing")
|
||||
|
||||
conn.handleWGTimeout()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
assert.Len(t, disconnected, 2, "continued timeouts must escalate again")
|
||||
}
|
||||
|
||||
@@ -314,12 +366,12 @@ func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
|
||||
conn := newWGTimeoutTestConn(true, &disconnected)
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.handleWGTimeout()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
}
|
||||
conn.handleWGCheckSuccess()
|
||||
conn.onWGCheckSuccess()
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
|
||||
conn.handleWGTimeout()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
}
|
||||
assert.Empty(t, disconnected, "handshake success must reset the timeout count")
|
||||
}
|
||||
@@ -327,30 +379,42 @@ func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
|
||||
// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass
|
||||
// there is no per-peer key state to reset; repeated timeouts must not report
|
||||
// disconnects.
|
||||
func TestConn_handleEvent_DropsStaleWGTimeout(t *testing.T) {
|
||||
var disconnected []string
|
||||
conn := newWGTimeoutTestConn(true, &disconnected)
|
||||
|
||||
staleCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
for i := 0; i < wgTimeoutEscalationThreshold; i++ {
|
||||
conn.handleEvent(evWGTimeout{ctx: staleCtx})
|
||||
}
|
||||
assert.Empty(t, disconnected, "timeouts from a cancelled watcher must be dropped")
|
||||
|
||||
liveCtx := context.Background()
|
||||
for i := 0; i < wgTimeoutEscalationThreshold; i++ {
|
||||
conn.handleEvent(evWGTimeout{ctx: liveCtx})
|
||||
}
|
||||
assert.Len(t, disconnected, 1, "timeouts from the live watcher must be dispatched")
|
||||
}
|
||||
|
||||
func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
|
||||
var disconnected []string
|
||||
conn := newWGTimeoutTestConn(false, &disconnected)
|
||||
|
||||
for i := 0; i < wgTimeoutEscalationThreshold*3; i++ {
|
||||
conn.handleWGTimeout()
|
||||
conn.onWGDisconnected(conn.ctx)
|
||||
}
|
||||
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
|
||||
}
|
||||
|
||||
func TestMetricsConnType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
priority conntype.ConnPriority
|
||||
expected metrics.ConnectionType
|
||||
}{
|
||||
{"relay", conntype.Relay, metrics.ConnectionTypeRelay},
|
||||
{"ice over turn is relayed, not p2p", conntype.ICETurn, metrics.ConnectionTypeICETurn},
|
||||
{"direct p2p", conntype.ICEP2P, metrics.ConnectionTypeICEP2P},
|
||||
{"unset priority is unknown, not p2p", conntype.None, metrics.ConnectionTypeUnknown},
|
||||
{"unrecognised priority is unknown", conntype.ConnPriority(99), metrics.ConnectionTypeUnknown},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, metricsConnType(tc.priority))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) {
|
||||
for _, priority := range []conntype.ConnPriority{conntype.None, conntype.Relay, conntype.ICETurn, conntype.ICEP2P} {
|
||||
conn := &Conn{currentConnPriority: priority}
|
||||
tag := metricsConnType(priority)
|
||||
relayedTag := tag == metrics.ConnectionTypeRelay || tag == metrics.ConnectionTypeICETurn
|
||||
assert.Equal(t, conn.isRelayed(), relayedTag,
|
||||
"priority %s: isRelayed and the %q metric tag must agree", priority, tag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package worker
|
||||
package conntype
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
52
client/internal/peer/dispatcher/dispatcher.go
Normal file
52
client/internal/peer/dispatcher/dispatcher.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package dispatcher
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer/id"
|
||||
)
|
||||
|
||||
type ConnectionListener struct {
|
||||
OnConnected func(peerID id.ConnID)
|
||||
OnDisconnected func(peerID id.ConnID)
|
||||
}
|
||||
|
||||
type ConnectionDispatcher struct {
|
||||
listeners map[*ConnectionListener]struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewConnectionDispatcher() *ConnectionDispatcher {
|
||||
return &ConnectionDispatcher{
|
||||
listeners: make(map[*ConnectionListener]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ConnectionDispatcher) AddListener(listener *ConnectionListener) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.listeners[listener] = struct{}{}
|
||||
}
|
||||
|
||||
func (e *ConnectionDispatcher) RemoveListener(listener *ConnectionListener) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
delete(e.listeners, listener)
|
||||
}
|
||||
|
||||
func (e *ConnectionDispatcher) NotifyConnected(peerConnID id.ConnID) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for listener := range e.listeners {
|
||||
listener.OnConnected(peerConnID)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ConnectionDispatcher) NotifyDisconnected(peerConnID id.ConnID) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for listener := range e.listeners {
|
||||
listener.OnDisconnected(peerConnID)
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pion/ice/v4"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer/signaling"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/worker"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
// event is a message processed by the Conn event loop. All mutable Conn state
|
||||
// is owned by that loop; producers deliver events through the mailbox and
|
||||
// never mutate Conn state directly.
|
||||
type event any
|
||||
|
||||
// staleableEvent is implemented by events tied to the lifetime of a transport
|
||||
// component (WG watcher, ICE agent, relay connection). Each such component runs
|
||||
// under its own context, cancelled when the component is superseded; an event
|
||||
// carrying a cancelled context is dropped at dispatch time. A cancel performed
|
||||
// by an earlier event in the same drained batch already suppresses it.
|
||||
type staleableEvent interface {
|
||||
isStale() bool
|
||||
}
|
||||
|
||||
// evClose asks the event loop to tear down the connection. done is closed
|
||||
// once the teardown finished.
|
||||
type evClose struct {
|
||||
signalToRemote bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type evRemoteOffer struct {
|
||||
offer signaling.OfferAnswer
|
||||
}
|
||||
|
||||
type evRemoteAnswer struct {
|
||||
answer signaling.OfferAnswer
|
||||
}
|
||||
|
||||
type evRemoteCandidate struct {
|
||||
candidate ice.Candidate
|
||||
haRoutes route.HAMap
|
||||
}
|
||||
|
||||
type evICEReady struct {
|
||||
priority worker.ConnPriority
|
||||
info worker.ICEConnInfo
|
||||
}
|
||||
|
||||
type evICEDown struct {
|
||||
sessionChanged bool
|
||||
}
|
||||
|
||||
type evRelayReady struct {
|
||||
info worker.RelayConnInfo
|
||||
}
|
||||
|
||||
type evRelayDown struct{}
|
||||
|
||||
// evRelayDialDone reports that the relay dial helper goroutine finished,
|
||||
// successfully or not, so the loop may dispatch a pending offer.
|
||||
type evRelayDialDone struct{}
|
||||
|
||||
type evWGTimeout struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (e evWGTimeout) isStale() bool { return e.ctx.Err() != nil }
|
||||
|
||||
// evWGHandshake reports the first WireGuard handshake of the current watcher run.
|
||||
type evWGHandshake struct {
|
||||
when time.Time
|
||||
}
|
||||
|
||||
// evWGCheckOK reports a watcher check that observed a fresh handshake,
|
||||
// including handshakes of connections that were already up.
|
||||
type evWGCheckOK struct{}
|
||||
|
||||
// evGuardTick asks the loop to send a new offer to restore connectivity.
|
||||
type evGuardTick struct{}
|
||||
@@ -21,6 +21,8 @@ const (
|
||||
)
|
||||
|
||||
type ICEMonitor struct {
|
||||
ReconnectCh chan struct{}
|
||||
|
||||
iFaceDiscover stdnet.ExternalIFaceDiscover
|
||||
iceConfig icemaker.Config
|
||||
tickerPeriod time.Duration
|
||||
@@ -32,6 +34,7 @@ type ICEMonitor struct {
|
||||
func NewICEMonitor(iFaceDiscover stdnet.ExternalIFaceDiscover, config icemaker.Config, period time.Duration) *ICEMonitor {
|
||||
log.Debugf("prepare ICE monitor with period: %s", period)
|
||||
cm := &ICEMonitor{
|
||||
ReconnectCh: make(chan struct{}, 1),
|
||||
iFaceDiscover: iFaceDiscover,
|
||||
iceConfig: config,
|
||||
tickerPeriod: period,
|
||||
|
||||
246
client/internal/peer/handshaker.go
Normal file
246
client/internal/peer/handshaker.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSignalIsNotReady = errors.New("signal is not ready")
|
||||
)
|
||||
|
||||
// IceCredentials ICE protocol credentials struct
|
||||
type IceCredentials struct {
|
||||
UFrag string
|
||||
Pwd string
|
||||
}
|
||||
|
||||
// OfferAnswer represents a session establishment offer or answer
|
||||
type OfferAnswer struct {
|
||||
IceCredentials IceCredentials
|
||||
// WgListenPort is a remote WireGuard listen port.
|
||||
// This field is used when establishing a direct WireGuard connection without any proxy.
|
||||
// We can set the remote peer's endpoint with this port.
|
||||
WgListenPort int
|
||||
|
||||
// Version of NetBird Agent
|
||||
Version string
|
||||
// RosenpassPubKey is the Rosenpass public key of the remote peer when receiving this message
|
||||
// This value is the local Rosenpass server public key when sending the message
|
||||
RosenpassPubKey []byte
|
||||
// RosenpassAddr is the Rosenpass server address (IP:port) of the remote peer when receiving this message
|
||||
// This value is the local Rosenpass server address when sending the message
|
||||
RosenpassAddr string
|
||||
|
||||
// relay server address
|
||||
RelaySrvAddress string
|
||||
// RelaySrvIP is the IP the remote peer is connected to on its
|
||||
// relay server. Used as a dial target if DNS for RelaySrvAddress
|
||||
// fails. Zero value if the peer did not advertise an IP.
|
||||
RelaySrvIP netip.Addr
|
||||
// SessionID is the unique identifier of the session, used to discard old messages
|
||||
SessionID *ICESessionID
|
||||
}
|
||||
|
||||
func (o *OfferAnswer) hasICECredentials() bool {
|
||||
return o.IceCredentials.UFrag != "" && o.IceCredentials.Pwd != ""
|
||||
}
|
||||
|
||||
type Handshaker struct {
|
||||
mu sync.Mutex
|
||||
log *log.Entry
|
||||
config ConnConfig
|
||||
signaler *Signaler
|
||||
ice *WorkerICE
|
||||
relay *WorkerRelay
|
||||
metricsStages *MetricsStages
|
||||
// relayListener is not blocking because the listener is using a goroutine to process the messages
|
||||
// and it will only keep the latest message if multiple offers are received in a short time
|
||||
// this is to avoid blocking the handshaker if the listener is doing some heavy processing
|
||||
// and also to avoid processing old offers if multiple offers are received in a short time
|
||||
// the listener will always process the latest offer
|
||||
relayListener *AsyncOfferListener
|
||||
iceListener func(remoteOfferAnswer *OfferAnswer)
|
||||
|
||||
// remoteICESupported tracks whether the remote peer includes ICE credentials in its offers/answers.
|
||||
// When false, the local side skips ICE listener dispatch and suppresses ICE credentials in responses.
|
||||
remoteICESupported atomic.Bool
|
||||
|
||||
// remoteOffersCh is a channel used to wait for remote credentials to proceed with the connection
|
||||
remoteOffersCh chan OfferAnswer
|
||||
// remoteAnswerCh is a channel used to wait for remote credentials answer (confirmation of our offer) to proceed with the connection
|
||||
remoteAnswerCh chan OfferAnswer
|
||||
}
|
||||
|
||||
func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker {
|
||||
h := &Handshaker{
|
||||
log: log,
|
||||
config: config,
|
||||
signaler: signaler,
|
||||
ice: ice,
|
||||
relay: relay,
|
||||
metricsStages: metricsStages,
|
||||
remoteOffersCh: make(chan OfferAnswer),
|
||||
remoteAnswerCh: make(chan OfferAnswer),
|
||||
}
|
||||
// assume remote supports ICE until we learn otherwise from received offers
|
||||
h.remoteICESupported.Store(ice != nil)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handshaker) RemoteICESupported() bool {
|
||||
return h.remoteICESupported.Load()
|
||||
}
|
||||
|
||||
func (h *Handshaker) AddRelayListener(offer func(remoteOfferAnswer *OfferAnswer)) {
|
||||
h.relayListener = NewAsyncOfferListener(offer)
|
||||
}
|
||||
|
||||
func (h *Handshaker) AddICEListener(offer func(remoteOfferAnswer *OfferAnswer)) {
|
||||
h.iceListener = offer
|
||||
}
|
||||
|
||||
func (h *Handshaker) Listen(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case remoteOfferAnswer := <-h.remoteOffersCh:
|
||||
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
|
||||
|
||||
// Record signaling received for reconnection attempts
|
||||
if h.metricsStages != nil {
|
||||
h.metricsStages.RecordSignalingReceived()
|
||||
}
|
||||
|
||||
h.updateRemoteICEState(&remoteOfferAnswer)
|
||||
|
||||
if h.relayListener != nil {
|
||||
h.relayListener.Notify(&remoteOfferAnswer)
|
||||
}
|
||||
|
||||
if h.iceListener != nil && h.RemoteICESupported() {
|
||||
h.iceListener(&remoteOfferAnswer)
|
||||
}
|
||||
|
||||
if err := h.sendAnswer(); err != nil {
|
||||
h.log.Errorf("failed to send remote offer confirmation: %s", err)
|
||||
continue
|
||||
}
|
||||
case remoteOfferAnswer := <-h.remoteAnswerCh:
|
||||
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
|
||||
|
||||
// Record signaling received for reconnection attempts
|
||||
if h.metricsStages != nil {
|
||||
h.metricsStages.RecordSignalingReceived()
|
||||
}
|
||||
|
||||
h.updateRemoteICEState(&remoteOfferAnswer)
|
||||
|
||||
if h.relayListener != nil {
|
||||
h.relayListener.Notify(&remoteOfferAnswer)
|
||||
}
|
||||
|
||||
if h.iceListener != nil && h.RemoteICESupported() {
|
||||
h.iceListener(&remoteOfferAnswer)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
h.log.Infof("stop listening for remote offers and answers")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handshaker) SendOffer() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.sendOffer()
|
||||
}
|
||||
|
||||
// OnRemoteOffer handles an offer from the remote peer and returns true if the message was accepted, false otherwise
|
||||
// doesn't block, discards the message if connection wasn't ready
|
||||
func (h *Handshaker) OnRemoteOffer(offer OfferAnswer) {
|
||||
select {
|
||||
case h.remoteOffersCh <- offer:
|
||||
return
|
||||
default:
|
||||
h.log.Warnf("skipping remote offer message because receiver not ready")
|
||||
// connection might not be ready yet to receive so we ignore the message
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// OnRemoteAnswer handles an offer from the remote peer and returns true if the message was accepted, false otherwise
|
||||
// doesn't block, discards the message if connection wasn't ready
|
||||
func (h *Handshaker) OnRemoteAnswer(answer OfferAnswer) {
|
||||
select {
|
||||
case h.remoteAnswerCh <- answer:
|
||||
return
|
||||
default:
|
||||
// connection might not be ready yet to receive so we ignore the message
|
||||
h.log.Warnf("skipping remote answer message because receiver not ready")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// sendOffer prepares local user credentials and signals them to the remote peer
|
||||
func (h *Handshaker) sendOffer() error {
|
||||
if !h.signaler.Ready() {
|
||||
return ErrSignalIsNotReady
|
||||
}
|
||||
|
||||
offer := h.buildOfferAnswer()
|
||||
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
|
||||
|
||||
return h.signaler.SignalOffer(offer, h.config.Key)
|
||||
}
|
||||
|
||||
func (h *Handshaker) sendAnswer() error {
|
||||
answer := h.buildOfferAnswer()
|
||||
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
|
||||
|
||||
return h.signaler.SignalAnswer(answer, h.config.Key)
|
||||
}
|
||||
|
||||
func (h *Handshaker) buildOfferAnswer() OfferAnswer {
|
||||
answer := OfferAnswer{
|
||||
WgListenPort: h.config.LocalWgPort,
|
||||
Version: version.NetbirdVersion(),
|
||||
RosenpassPubKey: h.config.RosenpassConfig.PubKey,
|
||||
RosenpassAddr: h.config.RosenpassConfig.Addr,
|
||||
}
|
||||
|
||||
if h.ice != nil && h.RemoteICESupported() {
|
||||
uFrag, pwd := h.ice.GetLocalUserCredentials()
|
||||
sid := h.ice.SessionID()
|
||||
answer.IceCredentials = IceCredentials{uFrag, pwd}
|
||||
answer.SessionID = &sid
|
||||
}
|
||||
|
||||
if addr, ip, err := h.relay.RelayInstanceAddress(); err == nil {
|
||||
answer.RelaySrvAddress = addr
|
||||
answer.RelaySrvIP = ip
|
||||
}
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
func (h *Handshaker) updateRemoteICEState(offer *OfferAnswer) {
|
||||
hasICE := offer.hasICECredentials()
|
||||
prev := h.remoteICESupported.Swap(hasICE)
|
||||
if prev != hasICE {
|
||||
if hasICE {
|
||||
h.log.Infof("remote peer started sending ICE credentials")
|
||||
} else {
|
||||
h.log.Infof("remote peer stopped sending ICE credentials")
|
||||
if h.ice != nil {
|
||||
h.ice.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
client/internal/peer/handshaker_listener.go
Normal file
62
client/internal/peer/handshaker_listener.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type callbackFunc func(remoteOfferAnswer *OfferAnswer)
|
||||
|
||||
func (oa *OfferAnswer) SessionIDString() string {
|
||||
if oa.SessionID == nil {
|
||||
return "unknown"
|
||||
}
|
||||
return oa.SessionID.String()
|
||||
}
|
||||
|
||||
type AsyncOfferListener struct {
|
||||
fn callbackFunc
|
||||
running bool
|
||||
latest *OfferAnswer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewAsyncOfferListener(fn callbackFunc) *AsyncOfferListener {
|
||||
return &AsyncOfferListener{
|
||||
fn: fn,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *AsyncOfferListener) Notify(remoteOfferAnswer *OfferAnswer) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
// Store the latest offer
|
||||
o.latest = remoteOfferAnswer
|
||||
|
||||
// If already running, the running goroutine will pick up this latest value
|
||||
if o.running {
|
||||
return
|
||||
}
|
||||
|
||||
// Start processing
|
||||
o.running = true
|
||||
|
||||
// Process in a goroutine to avoid blocking the caller
|
||||
go func(remoteOfferAnswer *OfferAnswer) {
|
||||
for {
|
||||
o.fn(remoteOfferAnswer)
|
||||
|
||||
o.mu.Lock()
|
||||
if o.latest == nil {
|
||||
// No more work to do
|
||||
o.running = false
|
||||
o.mu.Unlock()
|
||||
return
|
||||
}
|
||||
remoteOfferAnswer = o.latest
|
||||
// Clear the latest to mark it as being processed
|
||||
o.latest = nil
|
||||
o.mu.Unlock()
|
||||
}
|
||||
}(remoteOfferAnswer)
|
||||
}
|
||||
39
client/internal/peer/handshaker_listener_test.go
Normal file
39
client/internal/peer/handshaker_listener_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Test_newOfferListener(t *testing.T) {
|
||||
dummyOfferAnswer := &OfferAnswer{}
|
||||
runChan := make(chan struct{}, 10)
|
||||
|
||||
longRunningFn := func(remoteOfferAnswer *OfferAnswer) {
|
||||
time.Sleep(1 * time.Second)
|
||||
runChan <- struct{}{}
|
||||
}
|
||||
|
||||
hl := NewAsyncOfferListener(longRunningFn)
|
||||
|
||||
hl.Notify(dummyOfferAnswer)
|
||||
hl.Notify(dummyOfferAnswer)
|
||||
hl.Notify(dummyOfferAnswer)
|
||||
|
||||
// Wait for exactly 2 callbacks
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case <-runChan:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Timeout waiting for callback")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no additional callbacks happen
|
||||
select {
|
||||
case <-runChan:
|
||||
t.Fatal("Unexpected additional callback")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Log("Correctly received exactly 2 callbacks")
|
||||
}
|
||||
}
|
||||
22
client/internal/peer/iface.go
Normal file
22
client/internal/peer/iface.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/configurer"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
||||
)
|
||||
|
||||
type WGIface interface {
|
||||
UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
|
||||
RemovePeer(peerKey string) error
|
||||
GetStats() (map[string]configurer.WGStats, error)
|
||||
GetProxy() wgproxy.Proxy
|
||||
Address() wgaddr.Address
|
||||
RemoveEndpointAddress(key string) error
|
||||
}
|
||||
11
client/internal/peer/listener.go
Normal file
11
client/internal/peer/listener.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package peer
|
||||
|
||||
// Listener is a callback type about the NetBird network connection state
|
||||
type Listener interface {
|
||||
OnConnected()
|
||||
OnDisconnected()
|
||||
OnConnecting()
|
||||
OnDisconnecting()
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// maxQueuedCandidates bounds the remote candidate queue; on overflow the
|
||||
// oldest candidate is dropped. Lost candidates are recovered by the next
|
||||
// offer exchange triggered by the guard.
|
||||
const maxQueuedCandidates = 128
|
||||
|
||||
// mailbox is the coalescing inbox of the Conn event loop. Posting never
|
||||
// blocks. Per message kind either the latest value wins (offer, answer,
|
||||
// guard tick), the values queue in bounded FIFO order (candidates) or in
|
||||
// unbounded FIFO order (lifecycle and transport state changes, which are
|
||||
// low-volume and must not be lost). A new offer flushes the queued
|
||||
// candidates because they belong to the superseded session.
|
||||
type mailbox struct {
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
|
||||
lifecycle []event
|
||||
transport []event
|
||||
offer *evRemoteOffer
|
||||
answer *evRemoteAnswer
|
||||
candidates []evRemoteCandidate
|
||||
guardTick bool
|
||||
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
func newMailbox() *mailbox {
|
||||
return &mailbox{
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// post stores the event and wakes the loop. It reports false if the mailbox
|
||||
// is already closed and the event was not accepted.
|
||||
func (m *mailbox) post(ev event) bool {
|
||||
m.mu.Lock()
|
||||
if m.closed {
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
|
||||
switch e := ev.(type) {
|
||||
case evClose:
|
||||
m.lifecycle = append(m.lifecycle, e)
|
||||
case evRemoteOffer:
|
||||
m.offer = &e
|
||||
m.candidates = nil
|
||||
case evRemoteAnswer:
|
||||
m.answer = &e
|
||||
case evRemoteCandidate:
|
||||
if len(m.candidates) >= maxQueuedCandidates {
|
||||
m.candidates = m.candidates[1:]
|
||||
}
|
||||
m.candidates = append(m.candidates, e)
|
||||
case evGuardTick:
|
||||
m.guardTick = true
|
||||
default:
|
||||
m.transport = append(m.transport, ev)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
select {
|
||||
case m.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// drain returns the pending events in processing order: lifecycle first,
|
||||
// then transport state changes, the coalesced offer and answer, the queued
|
||||
// candidates and finally the guard tick.
|
||||
func (m *mailbox) drain() []event {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.drainLocked()
|
||||
}
|
||||
|
||||
// closeAndDrain marks the mailbox closed so further posts are rejected and
|
||||
// returns the events that were still pending.
|
||||
func (m *mailbox) closeAndDrain() []event {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.closed = true
|
||||
return m.drainLocked()
|
||||
}
|
||||
|
||||
func (m *mailbox) drainLocked() []event {
|
||||
evs := make([]event, 0, len(m.lifecycle)+len(m.transport)+len(m.candidates)+3)
|
||||
evs = append(evs, m.lifecycle...)
|
||||
evs = append(evs, m.transport...)
|
||||
if m.offer != nil {
|
||||
evs = append(evs, *m.offer)
|
||||
}
|
||||
if m.answer != nil {
|
||||
evs = append(evs, *m.answer)
|
||||
}
|
||||
for _, c := range m.candidates {
|
||||
evs = append(evs, c)
|
||||
}
|
||||
if m.guardTick {
|
||||
evs = append(evs, evGuardTick{})
|
||||
}
|
||||
|
||||
m.lifecycle = nil
|
||||
m.transport = nil
|
||||
m.offer = nil
|
||||
m.answer = nil
|
||||
m.candidates = nil
|
||||
m.guardTick = false
|
||||
return evs
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer/signaling"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMailbox_OfferCoalescing(t *testing.T) {
|
||||
mb := newMailbox()
|
||||
|
||||
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{WgListenPort: 1}}))
|
||||
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{WgListenPort: 2}}))
|
||||
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{WgListenPort: 3}}))
|
||||
|
||||
evs := mb.drain()
|
||||
require.Len(t, evs, 1, "consecutive offers must coalesce to a single event")
|
||||
offer, ok := evs[0].(evRemoteOffer)
|
||||
require.True(t, ok, "coalesced event must be an offer")
|
||||
assert.Equal(t, 3, offer.offer.WgListenPort, "the newest offer must win")
|
||||
}
|
||||
|
||||
func TestMailbox_OfferFlushesCandidates(t *testing.T) {
|
||||
mb := newMailbox()
|
||||
|
||||
require.True(t, mb.post(evRemoteCandidate{}))
|
||||
require.True(t, mb.post(evRemoteCandidate{}))
|
||||
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{}}))
|
||||
|
||||
evs := mb.drain()
|
||||
require.Len(t, evs, 1, "candidates of the superseded session must be flushed")
|
||||
_, ok := evs[0].(evRemoteOffer)
|
||||
assert.True(t, ok, "only the offer must remain after the flush")
|
||||
}
|
||||
|
||||
func TestMailbox_CandidatesKeepOrderAfterOffer(t *testing.T) {
|
||||
mb := newMailbox()
|
||||
|
||||
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{}}))
|
||||
require.True(t, mb.post(evRemoteCandidate{haRoutes: nil}))
|
||||
require.True(t, mb.post(evRemoteCandidate{haRoutes: nil}))
|
||||
|
||||
evs := mb.drain()
|
||||
require.Len(t, evs, 3)
|
||||
_, ok := evs[0].(evRemoteOffer)
|
||||
assert.True(t, ok, "offer must be processed before the candidates")
|
||||
for _, ev := range evs[1:] {
|
||||
_, ok := ev.(evRemoteCandidate)
|
||||
assert.True(t, ok, "candidates posted after the offer must survive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailbox_CandidateQueueBounded(t *testing.T) {
|
||||
mb := newMailbox()
|
||||
|
||||
for i := 0; i < maxQueuedCandidates+10; i++ {
|
||||
require.True(t, mb.post(evRemoteCandidate{}))
|
||||
}
|
||||
|
||||
evs := mb.drain()
|
||||
assert.Len(t, evs, maxQueuedCandidates, "candidate queue must stay bounded")
|
||||
}
|
||||
|
||||
func TestMailbox_DrainOrder(t *testing.T) {
|
||||
mb := newMailbox()
|
||||
|
||||
require.True(t, mb.post(evGuardTick{}))
|
||||
require.True(t, mb.post(evRemoteAnswer{answer: signaling.OfferAnswer{}}))
|
||||
require.True(t, mb.post(evRemoteOffer{offer: signaling.OfferAnswer{}}))
|
||||
require.True(t, mb.post(evRelayDown{}))
|
||||
require.True(t, mb.post(evICEDown{sessionChanged: true}))
|
||||
require.True(t, mb.post(evClose{}))
|
||||
|
||||
evs := mb.drain()
|
||||
require.Len(t, evs, 6)
|
||||
|
||||
_, ok := evs[0].(evClose)
|
||||
assert.True(t, ok, "lifecycle events must come first")
|
||||
_, ok = evs[1].(evRelayDown)
|
||||
assert.True(t, ok, "transport events must keep FIFO order")
|
||||
_, ok = evs[2].(evICEDown)
|
||||
assert.True(t, ok, "transport events must keep FIFO order")
|
||||
_, ok = evs[3].(evRemoteOffer)
|
||||
assert.True(t, ok, "offer must come after transport events")
|
||||
_, ok = evs[4].(evRemoteAnswer)
|
||||
assert.True(t, ok, "answer must come after the offer")
|
||||
_, ok = evs[5].(evGuardTick)
|
||||
assert.True(t, ok, "guard tick must come last")
|
||||
}
|
||||
|
||||
func TestMailbox_GuardTickCoalesced(t *testing.T) {
|
||||
mb := newMailbox()
|
||||
|
||||
require.True(t, mb.post(evGuardTick{}))
|
||||
require.True(t, mb.post(evGuardTick{}))
|
||||
require.True(t, mb.post(evGuardTick{}))
|
||||
|
||||
evs := mb.drain()
|
||||
assert.Len(t, evs, 1, "guard ticks must coalesce to a single event")
|
||||
}
|
||||
|
||||
func TestMailbox_PostAfterCloseRejected(t *testing.T) {
|
||||
mb := newMailbox()
|
||||
|
||||
require.True(t, mb.post(evRelayDown{}))
|
||||
leftovers := mb.closeAndDrain()
|
||||
assert.Len(t, leftovers, 1, "pending events must be returned on close")
|
||||
|
||||
assert.False(t, mb.post(evRelayDown{}), "posts must be rejected after close")
|
||||
assert.Empty(t, mb.drain(), "no events must remain after close")
|
||||
}
|
||||
|
||||
func TestMailbox_WakeSignal(t *testing.T) {
|
||||
mb := newMailbox()
|
||||
|
||||
require.True(t, mb.post(evRelayDown{}))
|
||||
require.True(t, mb.post(evGuardTick{}))
|
||||
|
||||
select {
|
||||
case <-mb.wake:
|
||||
default:
|
||||
t.Fatal("wake signal must be pending after posts")
|
||||
}
|
||||
|
||||
assert.Len(t, mb.drain(), 2, "a single wake must deliver all pending events")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package metricsstages
|
||||
package peer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -1,4 +1,4 @@
|
||||
package metricsstages
|
||||
package peer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,4 +1,4 @@
|
||||
package status
|
||||
package peer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -11,16 +11,6 @@ const (
|
||||
stateDisconnecting
|
||||
)
|
||||
|
||||
// Listener is a callback type about the NetBird network connection state
|
||||
type Listener interface {
|
||||
OnConnected()
|
||||
OnDisconnected()
|
||||
OnConnecting()
|
||||
OnDisconnecting()
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
type notifier struct {
|
||||
serverStateLock sync.Mutex
|
||||
listenersLock sync.Mutex
|
||||
@@ -1,4 +1,4 @@
|
||||
package status
|
||||
package peer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -1,4 +1,4 @@
|
||||
package status
|
||||
package peer
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
@@ -1,4 +1,4 @@
|
||||
package ice
|
||||
package peer
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -9,26 +9,26 @@ import (
|
||||
|
||||
const sessionIDSize = 5
|
||||
|
||||
type SessionID string
|
||||
type ICESessionID string
|
||||
|
||||
// NewSessionID generates a new session ID for distinguishing sessions
|
||||
func NewSessionID() (SessionID, error) {
|
||||
// NewICESessionID generates a new session ID for distinguishing sessions
|
||||
func NewICESessionID() (ICESessionID, error) {
|
||||
b := make([]byte, sessionIDSize)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return "", fmt.Errorf("failed to generate session ID: %w", err)
|
||||
}
|
||||
return SessionID(hex.EncodeToString(b)), nil
|
||||
return ICESessionID(hex.EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
func SessionIDFromBytes(b []byte) (SessionID, error) {
|
||||
func ICESessionIDFromBytes(b []byte) (ICESessionID, error) {
|
||||
if len(b) != sessionIDSize {
|
||||
return "", fmt.Errorf("invalid session ID length: %d", len(b))
|
||||
}
|
||||
return SessionID(hex.EncodeToString(b)), nil
|
||||
return ICESessionID(hex.EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
// Bytes returns the raw bytes of the session ID for protobuf serialization
|
||||
func (id SessionID) Bytes() ([]byte, error) {
|
||||
func (id ICESessionID) Bytes() ([]byte, error) {
|
||||
if len(id) == 0 {
|
||||
return nil, fmt.Errorf("ICE session ID is empty")
|
||||
}
|
||||
@@ -42,6 +42,6 @@ func (id SessionID) Bytes() ([]byte, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (id SessionID) String() string {
|
||||
func (id ICESessionID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package signaling
|
||||
package peer
|
||||
|
||||
import (
|
||||
"github.com/pion/ice/v4"
|
||||
@@ -1,189 +0,0 @@
|
||||
package signaling
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSignalIsNotReady = errors.New("signal is not ready")
|
||||
)
|
||||
|
||||
// IceCredentials ICE protocol credentials struct
|
||||
type IceCredentials struct {
|
||||
UFrag string
|
||||
Pwd string
|
||||
}
|
||||
|
||||
// OfferAnswer represents a session establishment offer or answer
|
||||
type OfferAnswer struct {
|
||||
IceCredentials IceCredentials
|
||||
// WgListenPort is a remote WireGuard listen port.
|
||||
// This field is used when establishing a direct WireGuard connection without any proxy.
|
||||
// We can set the remote peer's endpoint with this port.
|
||||
WgListenPort int
|
||||
|
||||
// Version of NetBird Agent
|
||||
Version string
|
||||
// RosenpassPubKey is the Rosenpass public key of the remote peer when receiving this message
|
||||
// This value is the local Rosenpass server public key when sending the message
|
||||
RosenpassPubKey []byte
|
||||
// RosenpassAddr is the Rosenpass server address (IP:port) of the remote peer when receiving this message
|
||||
// This value is the local Rosenpass server address when sending the message
|
||||
RosenpassAddr string
|
||||
|
||||
// relay server address
|
||||
RelaySrvAddress string
|
||||
// RelaySrvIP is the IP the remote peer is connected to on its
|
||||
// relay server. Used as a dial target if DNS for RelaySrvAddress
|
||||
// fails. Zero value if the peer did not advertise an IP.
|
||||
RelaySrvIP netip.Addr
|
||||
// SessionID is the unique identifier of the session, used to discard old messages
|
||||
SessionID *icemaker.SessionID
|
||||
}
|
||||
|
||||
func (o *OfferAnswer) HasICECredentials() bool {
|
||||
return o.IceCredentials.UFrag != "" && o.IceCredentials.Pwd != ""
|
||||
}
|
||||
|
||||
func (o *OfferAnswer) SessionIDString() string {
|
||||
if o.SessionID == nil {
|
||||
return "unknown"
|
||||
}
|
||||
return o.SessionID.String()
|
||||
}
|
||||
|
||||
// Config carries the peer-specific values the Handshaker embeds into offers
|
||||
// and answers.
|
||||
type Config struct {
|
||||
Key string
|
||||
LocalWgPort int
|
||||
RosenpassPubKey []byte
|
||||
RosenpassAddr string
|
||||
}
|
||||
|
||||
// Credentials are the local ICE credentials and session id the Handshaker embeds in offers.
|
||||
type Credentials struct {
|
||||
UFrag string
|
||||
Pwd string
|
||||
SessionID icemaker.SessionID
|
||||
}
|
||||
|
||||
// ICEWorker is the subset of the ICE worker the Handshaker needs to build offers.
|
||||
type ICEWorker interface {
|
||||
Credentials() Credentials
|
||||
Close()
|
||||
}
|
||||
|
||||
// Handshaker keeps the signaling protocol logic: building and sending offers
|
||||
// and answers and tracking whether the remote peer supports ICE. Incoming
|
||||
// message processing is driven by the Conn event loop.
|
||||
type Handshaker struct {
|
||||
mu sync.Mutex
|
||||
log *log.Entry
|
||||
config Config
|
||||
signaler *Signaler
|
||||
ice ICEWorker
|
||||
relayManager *relayClient.Manager
|
||||
|
||||
// remoteICESupported tracks whether the remote peer includes ICE credentials in its offers/answers.
|
||||
// When false, the local side skips ICE dispatch and suppresses ICE credentials in responses.
|
||||
remoteICESupported atomic.Bool
|
||||
}
|
||||
|
||||
func NewHandshaker(log *log.Entry, config Config, signaler *Signaler, ice ICEWorker, relayManager *relayClient.Manager) *Handshaker {
|
||||
h := &Handshaker{
|
||||
log: log,
|
||||
config: config,
|
||||
signaler: signaler,
|
||||
ice: ice,
|
||||
relayManager: relayManager,
|
||||
}
|
||||
// assume remote supports ICE until we learn otherwise from received offers
|
||||
h.remoteICESupported.Store(ice != nil)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handshaker) RemoteICESupported() bool {
|
||||
return h.remoteICESupported.Load()
|
||||
}
|
||||
|
||||
func (h *Handshaker) SendOffer() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.sendOffer()
|
||||
}
|
||||
|
||||
func (h *Handshaker) SendAnswer() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.sendAnswer()
|
||||
}
|
||||
|
||||
// sendOffer prepares local user credentials and signals them to the remote peer
|
||||
func (h *Handshaker) sendOffer() error {
|
||||
if !h.signaler.Ready() {
|
||||
return ErrSignalIsNotReady
|
||||
}
|
||||
|
||||
offer := h.buildOfferAnswer()
|
||||
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
|
||||
|
||||
return h.signaler.SignalOffer(offer, h.config.Key)
|
||||
}
|
||||
|
||||
func (h *Handshaker) sendAnswer() error {
|
||||
answer := h.buildOfferAnswer()
|
||||
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
|
||||
|
||||
return h.signaler.SignalAnswer(answer, h.config.Key)
|
||||
}
|
||||
|
||||
func (h *Handshaker) buildOfferAnswer() OfferAnswer {
|
||||
answer := OfferAnswer{
|
||||
WgListenPort: h.config.LocalWgPort,
|
||||
Version: version.NetbirdVersion(),
|
||||
RosenpassPubKey: h.config.RosenpassPubKey,
|
||||
RosenpassAddr: h.config.RosenpassAddr,
|
||||
}
|
||||
|
||||
if h.ice != nil && h.RemoteICESupported() {
|
||||
creds := h.ice.Credentials()
|
||||
answer.IceCredentials = IceCredentials{creds.UFrag, creds.Pwd}
|
||||
sid := creds.SessionID
|
||||
answer.SessionID = &sid
|
||||
}
|
||||
|
||||
if addr, ip, err := h.relayManager.RelayInstanceAddress(); err == nil {
|
||||
answer.RelaySrvAddress = addr
|
||||
answer.RelaySrvIP = ip
|
||||
}
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
// UpdateRemoteICEState refreshes the remote ICE support flag from a received
|
||||
// offer or answer and closes the ICE worker when the remote peer stopped
|
||||
// sending ICE credentials. Runs on the Conn event loop.
|
||||
func (h *Handshaker) UpdateRemoteICEState(offer *OfferAnswer) {
|
||||
hasICE := offer.HasICECredentials()
|
||||
prev := h.remoteICESupported.Swap(hasICE)
|
||||
if prev != hasICE {
|
||||
if hasICE {
|
||||
h.log.Infof("remote peer started sending ICE credentials")
|
||||
} else {
|
||||
h.log.Infof("remote peer stopped sending ICE credentials")
|
||||
if h.ice != nil {
|
||||
h.ice.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package state_dump
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,13 +6,11 @@ import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer/status"
|
||||
)
|
||||
|
||||
type StateDump struct {
|
||||
type stateDump struct {
|
||||
log *log.Entry
|
||||
status *status.Recorder
|
||||
status *Status
|
||||
key string
|
||||
|
||||
sentOffer int
|
||||
@@ -28,15 +26,15 @@ type StateDump struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStateDump(key string, log *log.Entry, statusRecorder *status.Recorder) *StateDump {
|
||||
return &StateDump{
|
||||
func newStateDump(key string, log *log.Entry, statusRecorder *Status) *stateDump {
|
||||
return &stateDump{
|
||||
log: log,
|
||||
status: statusRecorder,
|
||||
key: key,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StateDump) Start(ctx context.Context) {
|
||||
func (s *stateDump) Start(ctx context.Context) {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -50,25 +48,25 @@ func (s *StateDump) Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StateDump) RemoteOffer() {
|
||||
func (s *stateDump) RemoteOffer() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.remoteOffer++
|
||||
}
|
||||
|
||||
func (s *StateDump) RemoteCandidate() {
|
||||
func (s *stateDump) RemoteCandidate() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.remoteCandidate++
|
||||
}
|
||||
|
||||
func (s *StateDump) SendOffer() {
|
||||
func (s *stateDump) SendOffer() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sentOffer++
|
||||
}
|
||||
|
||||
func (s *StateDump) dumpState() {
|
||||
func (s *stateDump) dumpState() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -82,41 +80,41 @@ func (s *StateDump) dumpState() {
|
||||
status, s.sentOffer, s.remoteOffer, s.remoteAnswer, s.remoteCandidate, s.p2pConnected, s.switchToRelay, s.wgCheckSuccess, s.relayConnected, s.localProxies)
|
||||
}
|
||||
|
||||
func (s *StateDump) RemoteAnswer() {
|
||||
func (s *stateDump) RemoteAnswer() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.remoteAnswer++
|
||||
}
|
||||
|
||||
func (s *StateDump) P2PConnected() {
|
||||
func (s *stateDump) P2PConnected() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.p2pConnected++
|
||||
}
|
||||
|
||||
func (s *StateDump) SwitchToRelay() {
|
||||
func (s *stateDump) SwitchToRelay() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.switchToRelay++
|
||||
}
|
||||
|
||||
func (s *StateDump) WGcheckSuccess() {
|
||||
func (s *stateDump) WGcheckSuccess() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.wgCheckSuccess++
|
||||
}
|
||||
|
||||
func (s *StateDump) RelayConnected() {
|
||||
func (s *stateDump) RelayConnected() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.relayConnected++
|
||||
}
|
||||
|
||||
func (s *StateDump) NewLocalProxy() {
|
||||
func (s *stateDump) NewLocalProxy() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// StatusIdle indicate the peer is in disconnected state
|
||||
StatusIdle ConnStatus = iota
|
||||
// StatusConnecting indicate the peer is in connecting state
|
||||
StatusConnecting
|
||||
// StatusConnected indicate the peer is in connected state
|
||||
StatusConnected
|
||||
)
|
||||
|
||||
// ConnStatus describe the status of a peer's connection
|
||||
type ConnStatus int32
|
||||
|
||||
func (s ConnStatus) String() string {
|
||||
switch s {
|
||||
case StatusConnecting:
|
||||
return "Connecting"
|
||||
case StatusConnected:
|
||||
return "Connected"
|
||||
case StatusIdle:
|
||||
return "Idle"
|
||||
default:
|
||||
log.Errorf("unknown status: %d", s)
|
||||
return "INVALID_PEER_CONNECTION_STATUS"
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
type EventQueue struct {
|
||||
maxSize int
|
||||
events []*proto.SystemEvent
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
func NewEventQueue(size int) *EventQueue {
|
||||
return &EventQueue{
|
||||
maxSize: size,
|
||||
events: make([]*proto.SystemEvent, 0, size),
|
||||
}
|
||||
}
|
||||
|
||||
func (q *EventQueue) Add(event *proto.SystemEvent) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
q.events = append(q.events, event)
|
||||
|
||||
if len(q.events) > q.maxSize {
|
||||
q.events = q.events[len(q.events)-q.maxSize:]
|
||||
}
|
||||
}
|
||||
|
||||
func (q *EventQueue) GetAll() []*proto.SystemEvent {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
return slices.Clone(q.events)
|
||||
}
|
||||
|
||||
type EventSubscription struct {
|
||||
id string
|
||||
events chan *proto.SystemEvent
|
||||
}
|
||||
|
||||
func (s *EventSubscription) Events() <-chan *proto.SystemEvent {
|
||||
return s.events
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"golang.org/x/exp/maps"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/relay"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// FullStatus contains the full state held by the Recorder instance
|
||||
type FullStatus struct {
|
||||
Peers []State
|
||||
ManagementState ManagementState
|
||||
SignalState SignalState
|
||||
LocalPeerState LocalPeerState
|
||||
RosenpassState RosenpassState
|
||||
Relays []relay.ProbeResult
|
||||
NSGroupStates []NSGroupState
|
||||
NumOfForwardingRules int
|
||||
LazyConnectionEnabled bool
|
||||
Events []*proto.SystemEvent
|
||||
}
|
||||
|
||||
// ToProto converts FullStatus to proto.FullStatus.
|
||||
func (fs FullStatus) ToProto() *proto.FullStatus {
|
||||
pbFullStatus := proto.FullStatus{
|
||||
ManagementState: &proto.ManagementState{},
|
||||
SignalState: &proto.SignalState{},
|
||||
LocalPeerState: &proto.LocalPeerState{},
|
||||
Peers: []*proto.PeerState{},
|
||||
}
|
||||
|
||||
pbFullStatus.ManagementState.URL = fs.ManagementState.URL
|
||||
pbFullStatus.ManagementState.Connected = fs.ManagementState.Connected
|
||||
if err := fs.ManagementState.Error; err != nil {
|
||||
pbFullStatus.ManagementState.Error = err.Error()
|
||||
}
|
||||
|
||||
pbFullStatus.SignalState.URL = fs.SignalState.URL
|
||||
pbFullStatus.SignalState.Connected = fs.SignalState.Connected
|
||||
if err := fs.SignalState.Error; err != nil {
|
||||
pbFullStatus.SignalState.Error = err.Error()
|
||||
}
|
||||
|
||||
pbFullStatus.LocalPeerState.IP = fs.LocalPeerState.IP
|
||||
pbFullStatus.LocalPeerState.Ipv6 = fs.LocalPeerState.IPv6
|
||||
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
|
||||
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
|
||||
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
|
||||
pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
|
||||
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
|
||||
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
|
||||
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
|
||||
pbFullStatus.LazyConnectionEnabled = fs.LazyConnectionEnabled
|
||||
|
||||
pbFullStatus.LocalPeerState.Networks = maps.Keys(fs.LocalPeerState.Routes)
|
||||
|
||||
for _, peerState := range fs.Peers {
|
||||
networks := maps.Keys(peerState.GetRoutes())
|
||||
|
||||
pbPeerState := &proto.PeerState{
|
||||
IP: peerState.IP,
|
||||
Ipv6: peerState.IPv6,
|
||||
PubKey: peerState.PubKey,
|
||||
ConnStatus: peerState.ConnStatus.String(),
|
||||
ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate),
|
||||
Relayed: peerState.Relayed,
|
||||
LocalIceCandidateType: peerState.LocalIceCandidateType,
|
||||
RemoteIceCandidateType: peerState.RemoteIceCandidateType,
|
||||
LocalIceCandidateEndpoint: peerState.LocalIceCandidateEndpoint,
|
||||
RemoteIceCandidateEndpoint: peerState.RemoteIceCandidateEndpoint,
|
||||
RelayAddress: peerState.RelayServerAddress,
|
||||
Fqdn: peerState.FQDN,
|
||||
LastWireguardHandshake: timestamppb.New(peerState.LastWireguardHandshake),
|
||||
BytesRx: peerState.BytesRx,
|
||||
BytesTx: peerState.BytesTx,
|
||||
RosenpassEnabled: peerState.RosenpassEnabled,
|
||||
Networks: networks,
|
||||
Latency: durationpb.New(peerState.Latency),
|
||||
SshHostKey: peerState.SSHHostKey,
|
||||
}
|
||||
pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState)
|
||||
}
|
||||
|
||||
for _, relayState := range fs.Relays {
|
||||
pbRelayState := &proto.RelayState{
|
||||
URI: relayState.URI,
|
||||
Available: relayState.Err == nil,
|
||||
Transport: relayState.Transport,
|
||||
}
|
||||
if err := relayState.Err; err != nil {
|
||||
pbRelayState.Error = err.Error()
|
||||
}
|
||||
pbFullStatus.Relays = append(pbFullStatus.Relays, pbRelayState)
|
||||
}
|
||||
|
||||
for _, dnsState := range fs.NSGroupStates {
|
||||
var err string
|
||||
if dnsState.Error != nil {
|
||||
err = dnsState.Error.Error()
|
||||
}
|
||||
|
||||
var servers []string
|
||||
for _, server := range dnsState.Servers {
|
||||
servers = append(servers, server.String())
|
||||
}
|
||||
|
||||
pbDnsState := &proto.NSGroupState{
|
||||
Servers: servers,
|
||||
Domains: dnsState.Domains,
|
||||
Enabled: dnsState.Enabled,
|
||||
Error: err,
|
||||
}
|
||||
pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
|
||||
}
|
||||
|
||||
pbFullStatus.Events = fs.Events
|
||||
|
||||
return &pbFullStatus
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
// State contains the latest state of a peer
|
||||
type State struct {
|
||||
Mux *sync.RWMutex
|
||||
IP string
|
||||
IPv6 string
|
||||
PubKey string
|
||||
FQDN string
|
||||
ConnStatus ConnStatus
|
||||
ConnStatusUpdate time.Time
|
||||
Relayed bool
|
||||
LocalIceCandidateType string
|
||||
RemoteIceCandidateType string
|
||||
LocalIceCandidateEndpoint string
|
||||
RemoteIceCandidateEndpoint string
|
||||
RelayServerAddress string
|
||||
LastWireguardHandshake time.Time
|
||||
BytesTx int64
|
||||
BytesRx int64
|
||||
Latency time.Duration
|
||||
RosenpassEnabled bool
|
||||
SSHHostKey []byte
|
||||
routes map[string]struct{}
|
||||
}
|
||||
|
||||
// AddRoute add a single route to routes map
|
||||
func (s *State) AddRoute(network string) {
|
||||
s.Mux.Lock()
|
||||
defer s.Mux.Unlock()
|
||||
if s.routes == nil {
|
||||
s.routes = make(map[string]struct{})
|
||||
}
|
||||
s.routes[network] = struct{}{}
|
||||
}
|
||||
|
||||
// SetRoutes set state routes
|
||||
func (s *State) SetRoutes(routes map[string]struct{}) {
|
||||
s.Mux.Lock()
|
||||
defer s.Mux.Unlock()
|
||||
s.routes = routes
|
||||
}
|
||||
|
||||
// DeleteRoute removes a route from the network amp
|
||||
func (s *State) DeleteRoute(network string) {
|
||||
s.Mux.Lock()
|
||||
defer s.Mux.Unlock()
|
||||
delete(s.routes, network)
|
||||
}
|
||||
|
||||
// GetRoutes return routes map
|
||||
func (s *State) GetRoutes() map[string]struct{} {
|
||||
s.Mux.RLock()
|
||||
defer s.Mux.RUnlock()
|
||||
return maps.Clone(s.routes)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package peer
|
||||
|
||||
import "github.com/netbirdio/netbird/client/internal/peer/status"
|
||||
|
||||
// Transitional aliases re-exporting the peer status recorder from its own
|
||||
// package. Callers are being migrated to reference the status package
|
||||
// directly; these aliases will be removed once the migration completes.
|
||||
type (
|
||||
Status = status.Recorder
|
||||
State = status.State
|
||||
ConnStatus = status.ConnStatus
|
||||
FullStatus = status.FullStatus
|
||||
RouterState = status.RouterState
|
||||
LocalPeerState = status.LocalPeerState
|
||||
SignalState = status.SignalState
|
||||
ManagementState = status.ManagementState
|
||||
RosenpassState = status.RosenpassState
|
||||
NSGroupState = status.NSGroupState
|
||||
ResolvedDomainInfo = status.ResolvedDomainInfo
|
||||
StatusChangeSubscription = status.StatusChangeSubscription
|
||||
EventQueue = status.EventQueue
|
||||
EventSubscription = status.EventSubscription
|
||||
WGIfaceStatus = status.WGIfaceStatus
|
||||
Listener = status.Listener
|
||||
EventListener = status.EventListener
|
||||
)
|
||||
|
||||
const (
|
||||
StatusIdle = status.StatusIdle
|
||||
StatusConnecting = status.StatusConnecting
|
||||
StatusConnected = status.StatusConnected
|
||||
)
|
||||
|
||||
var (
|
||||
NewRecorder = status.NewRecorder
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package status
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package wg_watcher
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/configurer"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/state_dump"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -30,7 +29,7 @@ type WGWatcher struct {
|
||||
log *log.Entry
|
||||
wgIfaceStater WGInterfaceStater
|
||||
peerKey string
|
||||
stateDump *state_dump.StateDump
|
||||
stateDump *stateDump
|
||||
|
||||
// initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
|
||||
initialHandshake time.Time
|
||||
@@ -38,7 +37,7 @@ type WGWatcher struct {
|
||||
resetCh chan struct{}
|
||||
}
|
||||
|
||||
func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey string, stateDump *state_dump.StateDump) *WGWatcher {
|
||||
func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey string, stateDump *stateDump) *WGWatcher {
|
||||
return &WGWatcher{
|
||||
log: log,
|
||||
wgIfaceStater: wgIfaceStater,
|
||||
@@ -1,4 +1,4 @@
|
||||
package wg_watcher
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/configurer"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/state_dump"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/status"
|
||||
)
|
||||
|
||||
type MocWgIface struct {
|
||||
@@ -58,7 +56,7 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
|
||||
// platforms with coarse clock resolution (Windows), where two time.Now() calls
|
||||
// microseconds apart can return the same instant and read as a timed-out handshake.
|
||||
stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)}
|
||||
watcher := NewWGWatcher(mlog, stats, "", state_dump.NewStateDump("peer", mlog, &status.Recorder{}))
|
||||
watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{}))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -67,18 +65,14 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
|
||||
|
||||
firstHandshake := make(chan struct{}, 1)
|
||||
checkSuccess := make(chan struct{}, 1)
|
||||
watcherDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(watcherDone)
|
||||
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {
|
||||
firstHandshake <- struct{}{}
|
||||
}, func() {
|
||||
select {
|
||||
case checkSuccess <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
}()
|
||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {
|
||||
firstHandshake <- struct{}{}
|
||||
}, func() {
|
||||
select {
|
||||
case checkSuccess <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
stats.advance()
|
||||
|
||||
@@ -93,11 +87,6 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
|
||||
t.Errorf("first-handshake callback must not fire for a non-zero baseline")
|
||||
default:
|
||||
}
|
||||
|
||||
// Wait for the watcher goroutine to exit so it cannot race with other
|
||||
// tests mutating the package-level check timing variables.
|
||||
cancel()
|
||||
<-watcherDone
|
||||
}
|
||||
|
||||
func TestWGWatcher_EnableWgWatcher(t *testing.T) {
|
||||
@@ -106,7 +95,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
|
||||
|
||||
mlog := log.WithField("peer", "tet")
|
||||
mocWgIface := &MocWgIface{}
|
||||
watcher := NewWGWatcher(mlog, mocWgIface, "", state_dump.NewStateDump("peer", mlog, &status.Recorder{}))
|
||||
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -138,7 +127,7 @@ func TestWGWatcher_ReEnable(t *testing.T) {
|
||||
|
||||
mlog := log.WithField("peer", "tet")
|
||||
mocWgIface := &MocWgIface{}
|
||||
watcher := NewWGWatcher(mlog, mocWgIface, "", state_dump.NewStateDump("peer", mlog, &status.Recorder{}))
|
||||
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
watcher.PrepareInitialHandshake()
|
||||
@@ -1,4 +1,4 @@
|
||||
package peer
|
||||
package worker
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
@@ -7,17 +7,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
WorkerStatusDisconnected WorkerStatus = iota
|
||||
WorkerStatusConnected
|
||||
StatusDisconnected Status = iota
|
||||
StatusConnected
|
||||
)
|
||||
|
||||
type WorkerStatus int32
|
||||
type Status int32
|
||||
|
||||
func (s WorkerStatus) String() string {
|
||||
func (s Status) String() string {
|
||||
switch s {
|
||||
case WorkerStatusDisconnected:
|
||||
case StatusDisconnected:
|
||||
return "Disconnected"
|
||||
case WorkerStatusConnected:
|
||||
case StatusConnected:
|
||||
return "Connected"
|
||||
default:
|
||||
log.Errorf("unknown status: %d", s)
|
||||
@@ -37,16 +37,16 @@ func NewAtomicStatus() *AtomicWorkerStatus {
|
||||
}
|
||||
|
||||
// Get returns the current connection status
|
||||
func (acs *AtomicWorkerStatus) Get() WorkerStatus {
|
||||
return WorkerStatus(acs.status.Load())
|
||||
func (acs *AtomicWorkerStatus) Get() Status {
|
||||
return Status(acs.status.Load())
|
||||
}
|
||||
|
||||
func (acs *AtomicWorkerStatus) SetConnected() {
|
||||
acs.status.Store(int32(WorkerStatusConnected))
|
||||
acs.status.Store(int32(StatusConnected))
|
||||
}
|
||||
|
||||
func (acs *AtomicWorkerStatus) SetDisconnected() {
|
||||
acs.status.Store(int32(WorkerStatusDisconnected))
|
||||
acs.status.Store(int32(StatusDisconnected))
|
||||
}
|
||||
|
||||
// String returns the string representation of the current status
|
||||
@@ -1,4 +1,4 @@
|
||||
package worker
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -13,9 +13,8 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/conntype"
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/signaling"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/status"
|
||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -33,68 +32,57 @@ type ICEConnInfo struct {
|
||||
RelayedOnLocal bool
|
||||
}
|
||||
|
||||
type ICEDependencies struct {
|
||||
Signaler *signaling.Signaler
|
||||
IFaceDiscover stdnet.ExternalIFaceDiscover
|
||||
StatusRecorder *status.Recorder
|
||||
PortForwardManager *portforward.Manager
|
||||
}
|
||||
|
||||
type ICE struct {
|
||||
log *log.Entry
|
||||
key string
|
||||
iceConfig icemaker.Config
|
||||
isController bool
|
||||
onConnReady func(priority ConnPriority, iceConnInfo ICEConnInfo)
|
||||
onStatusDisconnect func(sessionChanged bool)
|
||||
signaler *signaling.Signaler
|
||||
iFaceDiscover stdnet.ExternalIFaceDiscover
|
||||
statusRecorder *status.Recorder
|
||||
portForwardManager *portforward.Manager
|
||||
hasRelayOnLocally bool
|
||||
type WorkerICE struct {
|
||||
ctx context.Context
|
||||
log *log.Entry
|
||||
config ConnConfig
|
||||
conn *Conn
|
||||
signaler *Signaler
|
||||
iFaceDiscover stdnet.ExternalIFaceDiscover
|
||||
statusRecorder *Status
|
||||
hasRelayOnLocally bool
|
||||
|
||||
agent *icemaker.ThreadSafeAgent
|
||||
agentDialerCancel context.CancelFunc
|
||||
agentConnecting bool // while it is true, drop all incoming offers
|
||||
lastSuccess time.Time // with this avoid the too frequent ICE agent recreation
|
||||
// connectedAgent is the agent whose connection was last reported ready; guarded by muxAgent
|
||||
connectedAgent *icemaker.ThreadSafeAgent
|
||||
// remoteSessionID represents the peer's session identifier from the latest remote offer.
|
||||
remoteSessionID icemaker.SessionID
|
||||
remoteSessionID ICESessionID
|
||||
// sessionID is used to track the current session ID of the ICE agent
|
||||
// increase by one when disconnecting the agent
|
||||
// with it the remote peer can discard the already deprecated offer/answer
|
||||
// Without it the remote peer may recreate a workable ICE connection
|
||||
sessionID icemaker.SessionID
|
||||
sessionID ICESessionID
|
||||
remoteSessionChanged bool
|
||||
muxAgent sync.Mutex
|
||||
|
||||
localUfrag string
|
||||
localPwd string
|
||||
|
||||
// we record the last known state of the ICE agent to avoid duplicate on disconnected events
|
||||
lastKnownState ice.ConnectionState
|
||||
|
||||
// portForwardAttempted tracks if we've already tried port forwarding this session
|
||||
portForwardAttempted bool
|
||||
}
|
||||
|
||||
func NewICE(log *log.Entry, key string, iceConfig icemaker.Config, isController bool, onConnReady func(ConnPriority, ICEConnInfo), onStatusDisconnect func(bool), services ICEDependencies, hasRelayOnLocally bool) (*ICE, error) {
|
||||
sessionID, err := icemaker.NewSessionID()
|
||||
func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) {
|
||||
sessionID, err := NewICESessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &ICE{
|
||||
log: log,
|
||||
key: key,
|
||||
iceConfig: iceConfig,
|
||||
isController: isController,
|
||||
onConnReady: onConnReady,
|
||||
onStatusDisconnect: onStatusDisconnect,
|
||||
signaler: services.Signaler,
|
||||
iFaceDiscover: services.IFaceDiscover,
|
||||
statusRecorder: services.StatusRecorder,
|
||||
portForwardManager: services.PortForwardManager,
|
||||
hasRelayOnLocally: hasRelayOnLocally,
|
||||
sessionID: sessionID,
|
||||
w := &WorkerICE{
|
||||
ctx: ctx,
|
||||
log: log,
|
||||
config: config,
|
||||
conn: conn,
|
||||
signaler: signaler,
|
||||
iFaceDiscover: ifaceDiscover,
|
||||
statusRecorder: statusRecorder,
|
||||
hasRelayOnLocally: hasRelayOnLocally,
|
||||
lastKnownState: ice.ConnectionStateDisconnected,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
|
||||
localUfrag, localPwd, err := icemaker.GenerateICECredentials()
|
||||
@@ -106,7 +94,7 @@ func NewICE(log *log.Entry, key string, iceConfig icemaker.Config, isController
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *ICE) OnNewOffer(ctx context.Context, remoteOfferAnswer *signaling.OfferAnswer) {
|
||||
func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
|
||||
w.log.Debugf("OnNewOffer for ICE, serial: %s", remoteOfferAnswer.SessionIDString())
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
@@ -130,7 +118,7 @@ func (w *ICE) OnNewOffer(ctx context.Context, remoteOfferAnswer *signaling.Offer
|
||||
}
|
||||
}
|
||||
|
||||
sessionID, err := icemaker.NewSessionID()
|
||||
sessionID, err := NewICESessionID()
|
||||
if err != nil {
|
||||
w.log.Errorf("failed to create new session ID: %s", err)
|
||||
}
|
||||
@@ -148,8 +136,8 @@ func (w *ICE) OnNewOffer(ctx context.Context, remoteOfferAnswer *signaling.Offer
|
||||
if remoteOfferAnswer.SessionID != nil {
|
||||
w.log.Debugf("recreate ICE agent: %s / %s", w.sessionID, *remoteOfferAnswer.SessionID)
|
||||
}
|
||||
dialerCtx, dialerCancel := context.WithCancel(ctx)
|
||||
agent, err := w.reCreateAgent(ctx, dialerCancel, preferredCandidateTypes)
|
||||
dialerCtx, dialerCancel := context.WithCancel(w.ctx)
|
||||
agent, err := w.reCreateAgent(dialerCancel, preferredCandidateTypes)
|
||||
if err != nil {
|
||||
w.log.Errorf("failed to recreate ICE Agent: %s", err)
|
||||
return
|
||||
@@ -163,14 +151,14 @@ func (w *ICE) OnNewOffer(ctx context.Context, remoteOfferAnswer *signaling.Offer
|
||||
w.remoteSessionID = ""
|
||||
}
|
||||
|
||||
go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer)
|
||||
go w.connect(dialerCtx, agent, remoteOfferAnswer)
|
||||
}
|
||||
|
||||
// OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer.
|
||||
func (w *ICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HAMap) {
|
||||
func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HAMap) {
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
w.log.Debugf("OnRemoteCandidate from peer %s -> %s", w.key, candidate.String())
|
||||
w.log.Debugf("OnRemoteCandidate from peer %s -> %s", w.config.Key, candidate.String())
|
||||
if w.agent == nil {
|
||||
w.log.Warnf("ICE Agent is not initialized yet")
|
||||
return
|
||||
@@ -197,24 +185,18 @@ func (w *ICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HAMap) {
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ICE) Credentials() signaling.Credentials {
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
return signaling.Credentials{
|
||||
UFrag: w.localUfrag,
|
||||
Pwd: w.localPwd,
|
||||
SessionID: w.sessionID,
|
||||
}
|
||||
func (w *WorkerICE) GetLocalUserCredentials() (frag string, pwd string) {
|
||||
return w.localUfrag, w.localPwd
|
||||
}
|
||||
|
||||
func (w *ICE) InProgress() bool {
|
||||
func (w *WorkerICE) InProgress() bool {
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
|
||||
return w.agentConnecting
|
||||
}
|
||||
|
||||
func (w *ICE) Close() {
|
||||
func (w *WorkerICE) Close() {
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
|
||||
@@ -230,10 +212,10 @@ func (w *ICE) Close() {
|
||||
w.agent = nil
|
||||
}
|
||||
|
||||
func (w *ICE) reCreateAgent(ctx context.Context, dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) {
|
||||
func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) {
|
||||
w.portForwardAttempted = false
|
||||
|
||||
agent, err := icemaker.NewAgent(ctx, w.iFaceDiscover, w.iceConfig, candidates, w.localUfrag, w.localPwd)
|
||||
agent, err := icemaker.NewAgent(w.ctx, w.iFaceDiscover, w.config.ICEConfig, candidates, w.localUfrag, w.localPwd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create agent: %w", err)
|
||||
}
|
||||
@@ -255,7 +237,7 @@ func (w *ICE) reCreateAgent(ctx context.Context, dialerCancel context.CancelFunc
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
func (w *ICE) getSessionID() icemaker.SessionID {
|
||||
func (w *WorkerICE) SessionID() ICESessionID {
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
|
||||
@@ -265,11 +247,11 @@ func (w *ICE) getSessionID() icemaker.SessionID {
|
||||
// will block until connection succeeded
|
||||
// but it won't release if ICE Agent went into Disconnected or Failed state,
|
||||
// so we have to cancel it with the provided context once agent detected a broken connection
|
||||
func (w *ICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *signaling.OfferAnswer) {
|
||||
func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) {
|
||||
w.log.Debugf("gather candidates")
|
||||
if err := agent.GatherCandidates(); err != nil {
|
||||
w.log.Warnf("failed to gather candidates: %s", err)
|
||||
w.closeAgent(agent, dialerCancel)
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -277,19 +259,19 @@ func (w *ICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agen
|
||||
remoteConn, err := w.turnAgentDial(ctx, agent, remoteOfferAnswer)
|
||||
if err != nil {
|
||||
w.log.Debugf("failed to dial the remote peer: %s", err)
|
||||
w.closeAgent(agent, dialerCancel)
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
return
|
||||
}
|
||||
w.log.Debugf("agent dial succeeded")
|
||||
|
||||
pair, err := agent.GetSelectedCandidatePair()
|
||||
if err != nil {
|
||||
w.closeAgent(agent, dialerCancel)
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
return
|
||||
}
|
||||
if pair == nil {
|
||||
w.log.Warnf("selected candidate pair is nil, cannot proceed")
|
||||
w.closeAgent(agent, dialerCancel)
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -317,22 +299,17 @@ func (w *ICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agen
|
||||
}
|
||||
w.log.Debugf("on ICE conn is ready to use")
|
||||
|
||||
w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString())
|
||||
w.muxAgent.Lock()
|
||||
if w.agent != agent {
|
||||
w.muxAgent.Unlock()
|
||||
w.log.Debugf("agent has been replaced during connect, dropping obsolete connection")
|
||||
return
|
||||
}
|
||||
w.agentConnecting = false
|
||||
w.lastSuccess = time.Now()
|
||||
w.connectedAgent = agent
|
||||
w.muxAgent.Unlock()
|
||||
|
||||
w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString())
|
||||
w.onConnReady(selectedPriority(pair), ci)
|
||||
// todo: the potential problem is a race between the onConnectionStateChange
|
||||
w.conn.onICEConnectionIsReady(selectedPriority(pair), ci)
|
||||
}
|
||||
|
||||
func (w *ICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelFunc) bool {
|
||||
func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelFunc) bool {
|
||||
cancel()
|
||||
if err := agent.Close(); err != nil {
|
||||
w.log.Warnf("failed to close ICE agent: %s", err)
|
||||
@@ -346,7 +323,7 @@ func (w *ICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelF
|
||||
|
||||
if w.agent == agent {
|
||||
// consider to remove from here and move to the OnNewOffer
|
||||
sessionID, err := icemaker.NewSessionID()
|
||||
sessionID, err := NewICESessionID()
|
||||
if err != nil {
|
||||
w.log.Errorf("failed to create new session ID: %s", err)
|
||||
}
|
||||
@@ -358,7 +335,7 @@ func (w *ICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelF
|
||||
return sessionChanged
|
||||
}
|
||||
|
||||
func (w *ICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) {
|
||||
func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) {
|
||||
// wait local endpoint configuration
|
||||
time.Sleep(time.Second)
|
||||
addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(pair.Remote.Address(), strconv.Itoa(remoteWgPort)))
|
||||
@@ -367,7 +344,7 @@ func (w *ICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) {
|
||||
return
|
||||
}
|
||||
|
||||
mux, ok := w.iceConfig.UDPMuxSrflx.(*udpmux.UniversalUDPMuxDefault)
|
||||
mux, ok := w.config.ICEConfig.UDPMuxSrflx.(*udpmux.UniversalUDPMuxDefault)
|
||||
if !ok {
|
||||
w.log.Warn("invalid udp mux conversion")
|
||||
return
|
||||
@@ -380,7 +357,7 @@ func (w *ICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) {
|
||||
|
||||
// onICECandidate is a callback attached to an ICE Agent to receive new local connection candidates
|
||||
// and then signals them to the remote peer
|
||||
func (w *ICE) onICECandidate(candidate ice.Candidate) {
|
||||
func (w *WorkerICE) onICECandidate(candidate ice.Candidate) {
|
||||
// nil means candidate gathering has been ended
|
||||
if candidate == nil {
|
||||
return
|
||||
@@ -389,9 +366,9 @@ func (w *ICE) onICECandidate(candidate ice.Candidate) {
|
||||
// TODO: reported port is incorrect for CandidateTypeHost, makes understanding ICE use via logs confusing as port is ignored
|
||||
w.log.Debugf("discovered local candidate %s", candidate.String())
|
||||
go func() {
|
||||
err := w.signaler.SignalICECandidate(candidate, w.key)
|
||||
err := w.signaler.SignalICECandidate(candidate, w.config.Key)
|
||||
if err != nil {
|
||||
w.log.Errorf("failed signaling candidate to the remote peer %s %s", w.key, err)
|
||||
w.log.Errorf("failed signaling candidate to the remote peer %s %s", w.config.Key, err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -401,8 +378,8 @@ func (w *ICE) onICECandidate(candidate ice.Candidate) {
|
||||
}
|
||||
|
||||
// injectPortForwardedCandidate signals an additional candidate using the pre-created port mapping.
|
||||
func (w *ICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
|
||||
pfManager := w.portForwardManager
|
||||
func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
|
||||
pfManager := w.conn.portForwardManager
|
||||
if pfManager == nil {
|
||||
return
|
||||
}
|
||||
@@ -430,7 +407,7 @@ func (w *ICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
|
||||
forwardedCandidate.String(), mapping.InternalPort, mapping.ExternalPort, mapping.NATType, forwardedCandidate.Priority())
|
||||
|
||||
go func() {
|
||||
if err := w.signaler.SignalICECandidate(forwardedCandidate, w.key); err != nil {
|
||||
if err := w.signaler.SignalICECandidate(forwardedCandidate, w.config.Key); err != nil {
|
||||
w.log.Errorf("signal port-forwarded candidate: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -438,7 +415,7 @@ func (w *ICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
|
||||
|
||||
// createForwardedCandidate creates a new server reflexive candidate with the forwarded port.
|
||||
// It uses the NAT gateway's external IP with the forwarded port.
|
||||
func (w *ICE) createForwardedCandidate(srflxCandidate ice.Candidate, mapping *portforward.Mapping) (ice.Candidate, error) {
|
||||
func (w *WorkerICE) createForwardedCandidate(srflxCandidate ice.Candidate, mapping *portforward.Mapping) (ice.Candidate, error) {
|
||||
var externalIP string
|
||||
if mapping.ExternalIP != nil && !mapping.ExternalIP.IsUnspecified() {
|
||||
externalIP = mapping.ExternalIP.String()
|
||||
@@ -483,9 +460,9 @@ func (w *ICE) createForwardedCandidate(srflxCandidate ice.Candidate, mapping *po
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func (w *ICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent, c1, c2 ice.Candidate) {
|
||||
func (w *WorkerICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent, c1, c2 ice.Candidate) {
|
||||
w.log.Debugf("selected candidate pair [local <-> remote] -> [%s <-> %s], peer %s", c1.String(), c2.String(),
|
||||
w.key)
|
||||
w.config.Key)
|
||||
|
||||
pairStat, ok := agent.GetSelectedCandidatePairStats()
|
||||
if !ok {
|
||||
@@ -494,14 +471,14 @@ func (w *ICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent, c1, c2
|
||||
}
|
||||
|
||||
duration := time.Duration(pairStat.CurrentRoundTripTime * float64(time.Second))
|
||||
if err := w.statusRecorder.UpdateLatency(w.key, duration); err != nil {
|
||||
if err := w.statusRecorder.UpdateLatency(w.config.Key, duration); err != nil {
|
||||
w.log.Debugf("failed to update latency for peer: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ICE) logSuccessfulPaths(agent *icemaker.ThreadSafeAgent) {
|
||||
sessionID := w.getSessionID()
|
||||
func (w *WorkerICE) logSuccessfulPaths(agent *icemaker.ThreadSafeAgent) {
|
||||
sessionID := w.SessionID()
|
||||
stats := agent.GetCandidatePairsStats()
|
||||
localCandidates, _ := agent.GetLocalCandidates()
|
||||
remoteCandidates, _ := agent.GetRemoteCandidates()
|
||||
@@ -531,44 +508,32 @@ func (w *ICE) logSuccessfulPaths(agent *icemaker.ThreadSafeAgent) {
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dialerCancel context.CancelFunc) func(ice.ConnectionState) {
|
||||
// per-agent state; pion delivers callbacks of one agent sequentially
|
||||
var connected bool
|
||||
func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dialerCancel context.CancelFunc) func(ice.ConnectionState) {
|
||||
return func(state ice.ConnectionState) {
|
||||
w.log.Debugf("ICE ConnectionState has changed to %s", state.String())
|
||||
switch state {
|
||||
case ice.ConnectionStateConnected:
|
||||
connected = true
|
||||
w.lastKnownState = ice.ConnectionStateConnected
|
||||
w.logSuccessfulPaths(agent)
|
||||
return
|
||||
case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed:
|
||||
// ice.ConnectionStateClosed happens when we recreate the agent. For the P2P to TURN switch important to
|
||||
// notify the conn.onICEStateDisconnected changes to update the current used priority
|
||||
|
||||
sessionChanged := w.closeAgent(agent, dialerCancel)
|
||||
|
||||
if !connected {
|
||||
return
|
||||
if w.lastKnownState == ice.ConnectionStateConnected {
|
||||
w.lastKnownState = ice.ConnectionStateDisconnected
|
||||
w.conn.onICEStateDisconnected(sessionChanged)
|
||||
}
|
||||
connected = false
|
||||
|
||||
w.muxAgent.Lock()
|
||||
stale := w.connectedAgent != agent
|
||||
if !stale {
|
||||
w.connectedAgent = nil
|
||||
}
|
||||
w.muxAgent.Unlock()
|
||||
|
||||
if stale {
|
||||
w.log.Debugf("suppress disconnected event of replaced ICE agent")
|
||||
return
|
||||
}
|
||||
w.onStatusDisconnect(sessionChanged)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *signaling.OfferAnswer) (*ice.Conn, error) {
|
||||
if w.isController {
|
||||
func (w *WorkerICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
|
||||
if isController(w.config) {
|
||||
return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd)
|
||||
} else {
|
||||
return agent.Accept(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd)
|
||||
@@ -630,10 +595,10 @@ func isRelayed(pair *ice.CandidatePair) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func selectedPriority(pair *ice.CandidatePair) ConnPriority {
|
||||
func selectedPriority(pair *ice.CandidatePair) conntype.ConnPriority {
|
||||
if isRelayed(pair) {
|
||||
return ICETurn
|
||||
return conntype.ICETurn
|
||||
} else {
|
||||
return ICEP2P
|
||||
return conntype.ICEP2P
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package worker
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,23 +10,22 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer/signaling"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
)
|
||||
|
||||
type RelayConnInfo struct {
|
||||
RelayedConn net.Conn
|
||||
RosenpassPubKey []byte
|
||||
RosenpassAddr string
|
||||
relayedConn net.Conn
|
||||
rosenpassPubKey []byte
|
||||
rosenpassAddr string
|
||||
}
|
||||
|
||||
type WorkerRelay struct {
|
||||
log *log.Entry
|
||||
key string
|
||||
isController bool
|
||||
onConnReady func(RelayConnInfo)
|
||||
onDisconnected func()
|
||||
relayManager *relayClient.Manager
|
||||
peerCtx context.Context
|
||||
log *log.Entry
|
||||
isController bool
|
||||
config ConnConfig
|
||||
conn *Conn
|
||||
relayManager *relayClient.Manager
|
||||
|
||||
relayedConn net.Conn
|
||||
relayLock sync.Mutex
|
||||
@@ -34,19 +33,19 @@ type WorkerRelay struct {
|
||||
relaySupportedOnRemotePeer atomic.Bool
|
||||
}
|
||||
|
||||
func NewWorkerRelay(log *log.Entry, key string, isController bool, onConnReady func(RelayConnInfo), onDisconnected func(), relayManager *relayClient.Manager) *WorkerRelay {
|
||||
func NewWorkerRelay(ctx context.Context, log *log.Entry, ctrl bool, config ConnConfig, conn *Conn, relayManager *relayClient.Manager) *WorkerRelay {
|
||||
r := &WorkerRelay{
|
||||
log: log,
|
||||
key: key,
|
||||
isController: isController,
|
||||
onConnReady: onConnReady,
|
||||
onDisconnected: onDisconnected,
|
||||
relayManager: relayManager,
|
||||
peerCtx: ctx,
|
||||
log: log,
|
||||
isController: ctrl,
|
||||
config: config,
|
||||
conn: conn,
|
||||
relayManager: relayManager,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (w *WorkerRelay) OnNewOffer(ctx context.Context, remoteOfferAnswer *signaling.OfferAnswer) {
|
||||
func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
|
||||
if !w.isRelaySupported(remoteOfferAnswer) {
|
||||
w.log.Infof("Relay is not supported by remote peer")
|
||||
w.relaySupportedOnRemotePeer.Store(false)
|
||||
@@ -67,7 +66,7 @@ func (w *WorkerRelay) OnNewOffer(ctx context.Context, remoteOfferAnswer *signali
|
||||
serverIP = remoteOfferAnswer.RelaySrvIP
|
||||
}
|
||||
|
||||
relayedConn, err := w.relayManager.OpenConn(ctx, srv, w.key, serverIP)
|
||||
relayedConn, err := w.relayManager.OpenConn(w.peerCtx, srv, w.config.Key, serverIP)
|
||||
if err != nil {
|
||||
if errors.Is(err, relayClient.ErrConnAlreadyExists) {
|
||||
w.log.Debugf("handled offer by reusing existing relay connection")
|
||||
@@ -89,10 +88,10 @@ func (w *WorkerRelay) OnNewOffer(ctx context.Context, remoteOfferAnswer *signali
|
||||
}
|
||||
|
||||
w.log.Debugf("peer conn opened via Relay: %s", srv)
|
||||
w.onConnReady(RelayConnInfo{
|
||||
RelayedConn: relayedConn,
|
||||
RosenpassPubKey: remoteOfferAnswer.RosenpassPubKey,
|
||||
RosenpassAddr: remoteOfferAnswer.RosenpassAddr,
|
||||
go w.conn.onRelayConnectionIsReady(RelayConnInfo{
|
||||
relayedConn: relayedConn,
|
||||
rosenpassPubKey: remoteOfferAnswer.RosenpassPubKey,
|
||||
rosenpassAddr: remoteOfferAnswer.RosenpassAddr,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,7 +119,7 @@ func (w *WorkerRelay) CloseConn() {
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WorkerRelay) isRelaySupported(answer *signaling.OfferAnswer) bool {
|
||||
func (w *WorkerRelay) isRelaySupported(answer *OfferAnswer) bool {
|
||||
if !w.relayManager.HasRelayAddress() {
|
||||
return false
|
||||
}
|
||||
@@ -135,5 +134,5 @@ func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress st
|
||||
}
|
||||
|
||||
func (w *WorkerRelay) onRelayClientDisconnected() {
|
||||
w.onDisconnected()
|
||||
go w.conn.onRelayDisconnected()
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
@@ -28,6 +29,13 @@ import (
|
||||
types "github.com/netbirdio/netbird/upload-server/types"
|
||||
)
|
||||
|
||||
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
|
||||
// anonymizeLevel values for DebugBundle.
|
||||
const (
|
||||
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
@@ -200,8 +208,10 @@ func (c *Client) Stop() {
|
||||
// DebugBundle generates a debug bundle, uploads it and returns the upload key.
|
||||
// It works with or without a running engine: when the engine is up it reuses
|
||||
// the live config, sync response and client metrics; otherwise it loads the
|
||||
// config from disk (or the preloaded tvOS config).
|
||||
func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
||||
// config from disk (or the preloaded tvOS config). anonymizeLevel is "default"
|
||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys, and implies anonymize.
|
||||
func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, error) {
|
||||
cfg, cc := c.stateSnapshot()
|
||||
|
||||
// If the engine hasn't been started, load config so we can reach management.
|
||||
@@ -251,6 +261,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
||||
deps,
|
||||
debug.BundleConfig{
|
||||
Anonymize: anonymize,
|
||||
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2781,6 +2781,11 @@ type DebugBundleRequest struct {
|
||||
// untrusted TLS certificate. Restricted to privileged callers; for
|
||||
// self-hosted upload servers.
|
||||
UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"`
|
||||
// anonymizeLevel selects how much the anonymizer redacts: "default"
|
||||
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
// Unknown values are treated as "strict". Only meaningful with anonymize;
|
||||
// "strict" implies it.
|
||||
AnonymizeLevel string `protobuf:"bytes,8,opt,name=anonymizeLevel,proto3" json:"anonymizeLevel,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2857,6 +2862,13 @@ func (x *DebugBundleRequest) GetUploadInsecure() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *DebugBundleRequest) GetAnonymizeLevel() string {
|
||||
if x != nil {
|
||||
return x.AnonymizeLevel
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DebugBundleResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||
@@ -7253,7 +7265,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
|
||||
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
|
||||
"\x17ForwardingRulesResponse\x12,\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xdc\x01\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" +
|
||||
"\x12DebugBundleRequest\x12\x1c\n" +
|
||||
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
|
||||
"\n" +
|
||||
@@ -7264,7 +7276,8 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"cliVersion\x18\x06 \x01(\tR\n" +
|
||||
"cliVersion\x12&\n" +
|
||||
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\"}\n" +
|
||||
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\x12&\n" +
|
||||
"\x0eanonymizeLevel\x18\b \x01(\tR\x0eanonymizeLevel\"}\n" +
|
||||
"\x13DebugBundleResponse\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
|
||||
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
|
||||
|
||||
@@ -540,6 +540,11 @@ message DebugBundleRequest {
|
||||
// untrusted TLS certificate. Restricted to privileged callers; for
|
||||
// self-hosted upload servers.
|
||||
bool uploadInsecure = 7;
|
||||
// anonymizeLevel selects how much the anonymizer redacts: "default"
|
||||
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
// Unknown values are treated as "strict". Only meaningful with anonymize;
|
||||
// "strict" implies it.
|
||||
string anonymizeLevel = 8;
|
||||
}
|
||||
|
||||
message DebugBundleResponse {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
@@ -122,6 +123,7 @@ func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener deb
|
||||
},
|
||||
debug.BundleConfig{
|
||||
Anonymize: req.GetAnonymize(),
|
||||
AnonymizeLevel: anonymize.ParseLevel(req.GetAnonymizeLevel()),
|
||||
IncludeSystemInfo: req.GetSystemInfo(),
|
||||
LogFileCount: req.GetLogFileCount(),
|
||||
},
|
||||
|
||||
@@ -46,7 +46,10 @@ func ParseDaemonStatus(s string) DaemonStatus {
|
||||
|
||||
// ConvertOptions holds parameters for ConvertToStatusOutputOverview.
|
||||
type ConvertOptions struct {
|
||||
Anonymize bool
|
||||
Anonymize bool
|
||||
// AnonymizeLevel selects how much the anonymizer redacts. Only
|
||||
// meaningful when Anonymize is set.
|
||||
AnonymizeLevel anonymize.Level
|
||||
DaemonVersion string
|
||||
DaemonStatus DaemonStatus
|
||||
StatusFilter string
|
||||
@@ -217,6 +220,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
|
||||
if opts.Anonymize {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(opts.AnonymizeLevel)
|
||||
anonymizeOverview(anonymizer, &overview)
|
||||
}
|
||||
|
||||
@@ -976,6 +980,7 @@ func timeAgo(t time.Time) string {
|
||||
|
||||
func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) {
|
||||
peer.FQDN = a.AnonymizeDomain(peer.FQDN)
|
||||
peer.PubKey = a.AnonymizeWGKey(peer.PubKey)
|
||||
if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil {
|
||||
peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port)
|
||||
}
|
||||
@@ -1007,6 +1012,7 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL)
|
||||
overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error)
|
||||
|
||||
overview.PubKey = a.AnonymizeWGKey(overview.PubKey)
|
||||
overview.IP = a.AnonymizeIPString(overview.IP)
|
||||
overview.IPv6 = a.AnonymizeIPString(overview.IPv6)
|
||||
for i, detail := range overview.Relays.Details {
|
||||
|
||||
@@ -71,10 +71,12 @@ type BundleOptions = {
|
||||
hasWindow: boolean;
|
||||
totalSec: number;
|
||||
uploadUrl: string;
|
||||
anonymize: boolean;
|
||||
anonymizeLevel: AnonymizeLevel;
|
||||
systemInfo: boolean;
|
||||
};
|
||||
|
||||
export type AnonymizeLevel = "none" | "default" | "strict";
|
||||
|
||||
const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
|
||||
try {
|
||||
// Mirror the CLI's safety margin: window + 30s, server caps at 10m.
|
||||
@@ -187,7 +189,10 @@ const runBundleFlow = async (
|
||||
|
||||
if (opts.uploadUrl) setStage({ kind: "uploading" });
|
||||
const result = await DebugSvc.Bundle({
|
||||
anonymize: opts.anonymize,
|
||||
anonymize: opts.anonymizeLevel !== "none",
|
||||
// The daemon only knows "default" and "strict"; "none" is expressed
|
||||
// through the anonymize flag being off.
|
||||
anonymizeLevel: opts.anonymizeLevel === "strict" ? "strict" : "default",
|
||||
systemInfo: opts.systemInfo,
|
||||
uploadUrl: opts.uploadUrl,
|
||||
logFileCount,
|
||||
@@ -198,7 +203,7 @@ const runBundleFlow = async (
|
||||
};
|
||||
|
||||
const useDebugBundle = () => {
|
||||
const [anonymize, setAnonymize] = useState(false);
|
||||
const [anonymizeLevel, setAnonymizeLevel] = useState<AnonymizeLevel>("none");
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
const [upload, setUpload] = useState(true);
|
||||
const [trace, setTrace] = useState(true);
|
||||
@@ -240,7 +245,7 @@ const useDebugBundle = () => {
|
||||
hasWindow: capture && totalSec > 0,
|
||||
totalSec,
|
||||
uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
|
||||
anonymize,
|
||||
anonymizeLevel,
|
||||
systemInfo,
|
||||
};
|
||||
|
||||
@@ -272,8 +277,8 @@ const useDebugBundle = () => {
|
||||
};
|
||||
|
||||
return {
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
anonymizeLevel,
|
||||
setAnonymizeLevel,
|
||||
systemInfo,
|
||||
setSystemInfo,
|
||||
upload,
|
||||
|
||||
35
client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts
Normal file
35
client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Preferences } from "@bindings/services";
|
||||
|
||||
export const useKeepConnectedOnQuit = () => {
|
||||
const [keepConnected, setKeepConnected] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Preferences.Get()
|
||||
.then((prefs) => {
|
||||
if (cancelled) return;
|
||||
setKeepConnected(prefs?.keepConnectedOnQuit ?? false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
console.warn("[useKeepConnectedOnQuit] load preferences failed", err);
|
||||
setKeepConnected(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setKeepConnectedOnQuit = useCallback(async (keep: boolean) => {
|
||||
setKeepConnected(keep);
|
||||
try {
|
||||
await Preferences.SetKeepConnectedOnQuit(keep);
|
||||
} catch (err: unknown) {
|
||||
setKeepConnected(!keep);
|
||||
console.error("[useKeepConnectedOnQuit] SetKeepConnectedOnQuit failed", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { keepConnected, setKeepConnectedOnQuit };
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"
|
||||
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,6 +20,7 @@ export function SettingsGeneral() {
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
|
||||
useManagementUrl();
|
||||
const { mdm, features } = useRestrictions();
|
||||
const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const managementUrlId = useId();
|
||||
@@ -57,6 +59,15 @@ export function SettingsGeneral() {
|
||||
helpText={t("settings.general.autostart.help")}
|
||||
/>
|
||||
)}
|
||||
<FancyToggleSwitch
|
||||
value={keepConnected ?? false}
|
||||
onChange={(v) => {
|
||||
void setKeepConnectedOnQuit(v);
|
||||
}}
|
||||
loading={keepConnected === null}
|
||||
label={t("settings.general.keepConnectedOnQuit.label")}
|
||||
helpText={t("settings.general.keepConnectedOnQuit.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
{!mdm.managementURL && !features.disableUpdateSettings && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
|
||||
import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
@@ -8,13 +8,22 @@ import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import HelpText from "@/components/typography/HelpText.tsx";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
import type { DebugStage } from "@/contexts/DebugBundleContext";
|
||||
import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext";
|
||||
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
|
||||
@@ -24,8 +33,8 @@ export function SettingsTroubleshooting() {
|
||||
const { t } = useTranslation();
|
||||
const durationId = useId();
|
||||
const {
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
anonymizeLevel,
|
||||
setAnonymizeLevel,
|
||||
systemInfo,
|
||||
setSystemInfo,
|
||||
upload,
|
||||
@@ -55,12 +64,71 @@ export function SettingsTroubleshooting() {
|
||||
|
||||
return (
|
||||
<SectionGroup title={t("settings.troubleshooting.section.title")}>
|
||||
<FancyToggleSwitch
|
||||
value={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label={t("settings.troubleshooting.anonymize.label")}
|
||||
helpText={t("settings.troubleshooting.anonymize.help")}
|
||||
/>
|
||||
<div className={"flex items-center justify-between gap-6"}>
|
||||
<div className={"max-w-md flex-1"}>
|
||||
<Label as={"div"}>
|
||||
<span className={"inline-flex items-center gap-1.5"}>
|
||||
{t("settings.troubleshooting.anonymize.label")}
|
||||
<Tooltip
|
||||
content={
|
||||
<div className={"max-w-xs whitespace-normal leading-relaxed"}>
|
||||
{t("settings.troubleshooting.anonymize.info")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Info
|
||||
size={14}
|
||||
aria-label={t("settings.troubleshooting.anonymize.label")}
|
||||
className={"shrink-0 cursor-default text-nb-gray-400"}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
{t("settings.troubleshooting.anonymize.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"shrink-0"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("settings.troubleshooting.anonymize.label")}
|
||||
className={cn(
|
||||
"inline-flex h-[40px] min-w-[160px] items-center justify-between gap-2 px-3",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
|
||||
)}
|
||||
>
|
||||
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[160px]"}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={anonymizeLevel}
|
||||
onValueChange={(v) => setAnonymizeLevel(v as AnonymizeLevel)}
|
||||
>
|
||||
<DropdownMenuRadioItem value={"none"}>
|
||||
{t("settings.troubleshooting.anonymize.none")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value={"default"}>
|
||||
{t("settings.troubleshooting.anonymize.default")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value={"strict"}>
|
||||
{t("settings.troubleshooting.anonymize.strict")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<FancyToggleSwitch
|
||||
value={systemInfo}
|
||||
onChange={setSystemInfo}
|
||||
|
||||
@@ -551,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Ändern des Autostarts fehlgeschlagen"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Nach dem Beenden verbunden bleiben",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Anzeigesprache"
|
||||
},
|
||||
|
||||
@@ -735,6 +735,14 @@
|
||||
"message": "Autostart Change Failed",
|
||||
"description": "Error-dialog title when changing the autostart setting fails."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Stay Connected After Quitting",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "The connection stays up in the background after you close NetBird. It only stops when you disconnect it yourself.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Display Language",
|
||||
"description": "Label for the display-language picker."
|
||||
@@ -1005,11 +1013,27 @@
|
||||
},
|
||||
"settings.troubleshooting.anonymize.label": {
|
||||
"message": "Anonymize Sensitive Information",
|
||||
"description": "Toggle label: anonymize sensitive information in the bundle."
|
||||
"description": "Label for the anonymization level dropdown (None, Default, Strict)."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Hides public IP addresses and non-NetBird domains from logs.",
|
||||
"description": "Helper text for anonymizing logs (hides public IPs and non-NetBird domains)."
|
||||
"message": "Hides IP addresses, domains, and other sensitive values.",
|
||||
"description": "Helper text under the anonymization dropdown. The level details live in the info tooltip."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "Default keeps internal IPv4 addresses and peer names readable for support. Strict additionally anonymizes private (RFC 1918), CGNAT, and link-local IP addresses, peer names, and WireGuard public keys. Recurring values map to the same placeholder, so peers stay distinguishable. Use Strict when sharing the bundle outside your organization.",
|
||||
"description": "Info tooltip explaining the anonymization levels. 'RFC 1918', 'CGNAT', 'link-local', and 'WireGuard' are technical terms — keep them."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "None",
|
||||
"description": "Dropdown option: no anonymization."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Default",
|
||||
"description": "Dropdown option: default anonymization level."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Strict",
|
||||
"description": "Dropdown option: strict anonymization level."
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Include System Information",
|
||||
|
||||
@@ -551,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Error al cambiar el inicio automático"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Permanecer conectado al salir",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Idioma de la interfaz"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Échec de la modification du démarrage automatique"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Rester connecté après la fermeture",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Langue d’affichage"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Az automatikus indítás módosítása sikertelen"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Kapcsolat megtartása kilépéskor",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Megjelenítési nyelv"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Modifica avvio automatico non riuscita"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Resta connesso dopo la chiusura",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Lingua dell'interfaccia"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "自動起動の変更に失敗しました"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "終了後も接続を維持",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "表示言語"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Falha ao alterar o início automático"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Permanecer conectado ao sair",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Idioma de exibição"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Не удалось изменить автозапуск"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Оставаться подключённым после выхода",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Язык интерфейса"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "更改自启动设置失败"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "退出后保持连接",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "显示语言"
|
||||
},
|
||||
|
||||
@@ -180,6 +180,7 @@ func main() {
|
||||
WindowManager: windowManager,
|
||||
Session: authSession,
|
||||
Localizer: localizer,
|
||||
Preferences: prefStore,
|
||||
})
|
||||
listenForShowSignal(context.Background(), tray)
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ type UIPreferences struct {
|
||||
// decision has run for this OS user. It only ever transitions to true
|
||||
// and is never reset, so the default-on flow runs at most once, ever.
|
||||
AutostartInitialized bool `json:"autostartInitialized"`
|
||||
// KeepConnectedOnQuit leaves the daemon connected when the GUI quits.
|
||||
// Its false zero value preserves the historical disconnect-on-quit
|
||||
// behaviour for preference files written before the field existed.
|
||||
KeepConnectedOnQuit bool `json:"keepConnectedOnQuit"`
|
||||
}
|
||||
|
||||
// LanguageValidator rejects SetLanguage inputs with no shipped bundle.
|
||||
@@ -183,6 +187,26 @@ func (s *Store) SetAutostartInitialized(done bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetKeepConnectedOnQuit persists the disconnect-on-quit opt-out. No-op if unchanged.
|
||||
func (s *Store) SetKeepConnectedOnQuit(keep bool) error {
|
||||
s.mu.Lock()
|
||||
if s.current.KeepConnectedOnQuit == keep {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.KeepConnectedOnQuit = keep
|
||||
if err := s.persistLocked(next); err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("persist preferences: %w", err)
|
||||
}
|
||||
s.current = next
|
||||
s.mu.Unlock()
|
||||
|
||||
s.broadcast(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLanguage validates, persists, and broadcasts. No-op if unchanged.
|
||||
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
|
||||
if lang == "" {
|
||||
|
||||
@@ -238,6 +238,42 @@ func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) {
|
||||
assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk")
|
||||
}
|
||||
|
||||
func TestStore_SetKeepConnectedOnQuitPersistsAcrossReload(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
emitter := &recordingEmitter{}
|
||||
s, err := NewStore(nil, emitter)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, s.Get().KeepConnectedOnQuit, "quitting must disconnect by default")
|
||||
|
||||
require.NoError(t, s.SetKeepConnectedOnQuit(true))
|
||||
assert.True(t, s.Get().KeepConnectedOnQuit, "Get should reflect the persisted opt-out")
|
||||
require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first write should broadcast")
|
||||
|
||||
require.NoError(t, s.SetKeepConnectedOnQuit(true))
|
||||
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent write should not broadcast again")
|
||||
|
||||
reloaded, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reloaded.Get().KeepConnectedOnQuit, "opt-out must survive a reload from disk")
|
||||
}
|
||||
|
||||
func TestStore_KeepConnectedOnQuitDefaultsFalseForPreExistingFile(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
|
||||
// A preferences file written before the field existed must keep the
|
||||
// historical disconnect-on-quit behaviour rather than silently opting out.
|
||||
path, err := preferencesPath()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"language":"en","viewMode":"default"}`), 0o600))
|
||||
|
||||
s, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, s.Get().KeepConnectedOnQuit, "a file predating the field must not opt out of disconnect-on-quit")
|
||||
assert.True(t, s.ExistedAtLoad(), "the pre-existing file must be seen on disk")
|
||||
}
|
||||
|
||||
func TestStore_ExistedAtLoad(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
|
||||
|
||||
@@ -49,5 +49,11 @@ func getCursorPosition(app *application.App) (application.Point, bool) {
|
||||
if app == nil || app.Screen == nil {
|
||||
return p, true
|
||||
}
|
||||
// The wails GTK3 backend caches screens from the active window; a tray app
|
||||
// has none at startup, so the cache is empty and PhysicalToDipPoint would
|
||||
// dereference a nil nearest screen. Raw pixels are correct there anyway.
|
||||
if app.Screen.ScreenNearestPhysicalPoint(p) == nil {
|
||||
return p, true
|
||||
}
|
||||
return app.Screen.PhysicalToDipPoint(p), true
|
||||
}
|
||||
|
||||
@@ -15,10 +15,13 @@ import (
|
||||
)
|
||||
|
||||
type DebugBundleParams struct {
|
||||
Anonymize bool `json:"anonymize"`
|
||||
SystemInfo bool `json:"systemInfo"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
LogFileCount uint32 `json:"logFileCount"`
|
||||
Anonymize bool `json:"anonymize"`
|
||||
// AnonymizeLevel is "default" or "strict"; strict also anonymizes
|
||||
// private IP ranges, peer names, and WireGuard public keys.
|
||||
AnonymizeLevel string `json:"anonymizeLevel"`
|
||||
SystemInfo bool `json:"systemInfo"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
LogFileCount uint32 `json:"logFileCount"`
|
||||
}
|
||||
|
||||
// DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload
|
||||
@@ -48,11 +51,12 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
|
||||
return DebugBundleResult{}, err
|
||||
}
|
||||
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
|
||||
Anonymize: p.Anonymize,
|
||||
SystemInfo: p.SystemInfo,
|
||||
UploadURL: p.UploadURL,
|
||||
LogFileCount: p.LogFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: p.Anonymize,
|
||||
AnonymizeLevel: p.AnonymizeLevel,
|
||||
SystemInfo: p.SystemInfo,
|
||||
UploadURL: p.UploadURL,
|
||||
LogFileCount: p.LogFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
})
|
||||
if err != nil {
|
||||
return DebugBundleResult{}, err
|
||||
|
||||
@@ -34,3 +34,7 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
|
||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||
return s.store.SetOnboardingCompleted(done)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetKeepConnectedOnQuit(_ context.Context, keep bool) error {
|
||||
return s.store.SetKeepConnectedOnQuit(keep)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
@@ -50,8 +51,9 @@ type TrayServices struct {
|
||||
WindowManager *services.WindowManager
|
||||
// Session is bound to authsession directly because the services wrapper
|
||||
// only re-exposes the React subset.
|
||||
Session *authsession.Session
|
||||
Localizer *Localizer
|
||||
Session *authsession.Session
|
||||
Localizer *Localizer
|
||||
Preferences *preferences.Store
|
||||
}
|
||||
|
||||
type Tray struct {
|
||||
@@ -461,10 +463,12 @@ func (t *Tray) handleQuit() {
|
||||
t.profileMu.Unlock()
|
||||
t.svc.DaemonFeed.CancelProfileSwitch()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
||||
defer cancel()
|
||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||
log.Errorf("disconnect on quit: %v", err)
|
||||
if t.svc.Preferences == nil || !t.svc.Preferences.Get().KeepConnectedOnQuit {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
||||
defer cancel()
|
||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||
log.Errorf("disconnect on quit: %v", err)
|
||||
}
|
||||
}
|
||||
t.app.Quit()
|
||||
}
|
||||
|
||||
40
client/ui/xembed_host_gtk3_linux.go
Normal file
40
client/ui/xembed_host_gtk3_linux.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build linux && gtk3 && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// The legacy GTK3 / WebKit2GTK 4.1 build (-tags gtk3) drops the in-process
|
||||
// XEmbed StatusNotifierWatcher entirely. The real implementation
|
||||
// (xembed_host_linux.go + xembed_tray_linux.c) links GTK4 and uses GTK4-only
|
||||
// popup-menu APIs that have no drop-in GTK3 equivalent, so rather than port the
|
||||
// C layer we stub the host out on gtk3 builds. The tray still works on every
|
||||
// desktop that ships its own StatusNotifierWatcher (KDE, GNOME+AppIndicator,
|
||||
// Cinnamon/xapp, XFCE, …); only the minimal-WM fallback (Fluxbox/OpenBox/i3/
|
||||
// dwm/vanilla GNOME) is unavailable on gtk3 packages. See LINUX-TRAY.md.
|
||||
|
||||
// xembedHost is a placeholder so the package compiles on gtk3 builds; the real
|
||||
// type (with X11/GTK4 state) lives in xembed_host_linux.go. It is never
|
||||
// instantiated here because xembedTrayAvailable always reports false.
|
||||
type xembedHost struct{}
|
||||
|
||||
// run satisfies the call in tray_watcher_linux.go; unreachable on gtk3 because
|
||||
// newXembedHost never returns a non-nil host.
|
||||
func (*xembedHost) run() {}
|
||||
|
||||
// xembedTrayAvailable always reports false on gtk3 builds, so the watcher probe
|
||||
// loop in startStatusNotifierWatcher exits immediately and newXembedHost is
|
||||
// never reached. recenter_linux.go's predicate becomes a harmless no-op too.
|
||||
func xembedTrayAvailable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// newXembedHost exists only to satisfy the reference in tray_watcher_linux.go;
|
||||
// it is unreachable because xembedTrayAvailable returns false on gtk3.
|
||||
func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) {
|
||||
return nil, errors.New("xembed host unsupported on gtk3 build")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build linux && !(linux && 386)
|
||||
//go:build linux && !gtk3 && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build linux && !gtk3 && !(linux && 386)
|
||||
|
||||
#include "xembed_tray_linux.h"
|
||||
|
||||
#include <X11/Xatom.h>
|
||||
|
||||
@@ -438,14 +438,10 @@ func TestProvidersMatrix(t *testing.T) {
|
||||
// Create every provider, all enabled, each with a unique model string so the
|
||||
// proxy's connect-time snapshot carries them all and model→provider routing
|
||||
// is unambiguous (provider toggles after connect don't reconcile to the
|
||||
// proxy, so we enable everything up front). The first create bootstraps the
|
||||
// cluster.
|
||||
// proxy, so we enable everything up front).
|
||||
ids := make([]string, 0, len(matrix))
|
||||
for i, pc := range matrix {
|
||||
for _, pc := range matrix {
|
||||
req := providerRequest(pc)
|
||||
if i == 0 {
|
||||
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
}
|
||||
prov, perr := srv.CreateProvider(ctx, req)
|
||||
require.NoError(t, perr, "create provider %s", pc.name)
|
||||
ids = append(ids, prov.Id)
|
||||
|
||||
@@ -82,13 +82,12 @@ func provisionPricedProvider(t *testing.T, ctx context.Context, name string, mod
|
||||
// need NOT be in the catalog — the operator names it and prices it here.
|
||||
dummyKey := "sk-price-e2e"
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: name,
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Models: &models,
|
||||
Name: name,
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
Models: &models,
|
||||
})
|
||||
require.NoError(t, err, "create provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
@@ -113,15 +113,14 @@ func runPathRoutedGuardrailCase(t *testing.T, tc pathRoutedGuardrailCase) {
|
||||
|
||||
// Catch-all provider (no models) so the router forwards any model; a static
|
||||
// bearer key means the router injects a static auth header instead of minting
|
||||
// a GCP token. Bootstraps the cluster if it isn't already.
|
||||
// a GCP token.
|
||||
staticKey := "static-e2e-token"
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: tc.name,
|
||||
ProviderId: tc.catalogID,
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Name: tc.name,
|
||||
ProviderId: tc.catalogID,
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
})
|
||||
require.NoError(t, err, "create %s provider", tc.name)
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
@@ -73,7 +73,6 @@ func TestGuardrailGroupSwitchTakesEffectAfterTTL(t *testing.T) {
|
||||
{Id: modelA, InputPer1k: 0.001, OutputPer1k: 0.001},
|
||||
{Id: modelB, InputPer1k: 0.001, OutputPer1k: 0.001},
|
||||
},
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
})
|
||||
require.NoError(t, err, "create provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
|
||||
@@ -61,15 +61,14 @@ func TestGuardrailMultiPolicyModelAllowlist(t *testing.T) {
|
||||
}
|
||||
|
||||
// pRestricted declares the two guardrailed models so routing is deterministic
|
||||
// (model -> provider). Created first, so it carries the bootstrap cluster.
|
||||
// (model -> provider).
|
||||
pRestricted, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "restricted",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
Models: models(modelSelected, modelOther),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Name: "restricted",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
Models: models(modelSelected, modelOther),
|
||||
})
|
||||
require.NoError(t, err, "create restricted provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pRestricted.Id) })
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) {
|
||||
staticKey := "static-e2e-token"
|
||||
enabled := true
|
||||
|
||||
for i, c := range cases {
|
||||
for _, c := range cases {
|
||||
req := api.AgentNetworkProviderRequest{
|
||||
Name: "e2e-pergroup-" + c.name,
|
||||
ProviderId: c.catalogID,
|
||||
@@ -124,9 +124,6 @@ func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) {
|
||||
Enabled: ptr(true),
|
||||
Models: c.models,
|
||||
}
|
||||
if i == 0 {
|
||||
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
}
|
||||
prov, perr := srv.CreateProvider(ctx, req)
|
||||
require.NoError(t, perr, "create provider %s", c.name)
|
||||
c.providerID = prov.Id
|
||||
@@ -283,13 +280,12 @@ func TestGuardrailMultiGroupUser(t *testing.T) {
|
||||
|
||||
// P1 — union scenario: two restricting policies, one per group.
|
||||
p1, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "e2e-mg-union",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
Models: priced(unionA, unionB, unionC),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Name: "e2e-mg-union",
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &staticKey,
|
||||
Enabled: ptr(true),
|
||||
Models: priced(unionA, unionB, unionC),
|
||||
})
|
||||
require.NoError(t, err, "create union provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p1.Id) })
|
||||
|
||||
@@ -115,14 +115,11 @@ func TestModelAllowlistEnforced(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
|
||||
// Providers with their configured (allowed) models; the first bootstraps the cluster.
|
||||
// Providers with their configured (allowed) models
|
||||
ids := make([]string, 0, len(providers))
|
||||
allowed := make([]string, 0, len(providers))
|
||||
for i, pc := range providers {
|
||||
for _, pc := range providers {
|
||||
req := providerRequest(pc)
|
||||
if i == 0 {
|
||||
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
}
|
||||
prov, perr := srv.CreateProvider(ctx, req)
|
||||
require.NoError(t, perr, "create provider %s", pc.name)
|
||||
id := prov.Id
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// srv is the shared combined server for the package, ready (PAT-authenticated)
|
||||
@@ -42,5 +43,14 @@ func run(m *testing.M) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Bootstrap the account's agent-network endpoint once for the package:
|
||||
// providers no longer have settings side effects, and every data-plane
|
||||
// test expects the shared account pinned to the combined proxy cluster.
|
||||
cluster := harness.AgentNetworkCluster
|
||||
if _, err := srv.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ProxyAddress: &cluster}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: bootstrap agent-network endpoint: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
return m.Run()
|
||||
}
|
||||
|
||||
@@ -21,11 +21,10 @@ func ptr[T any](v T) *T { return &v }
|
||||
func newProvider(t *testing.T, ctx context.Context, name string) api.AgentNetworkProvider {
|
||||
t.Helper()
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: name,
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: ptr("sk-dummy-e2e-key"),
|
||||
BootstrapCluster: ptr("eu.proxy.netbird.test"),
|
||||
Name: name,
|
||||
ProviderId: "openai_api",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: ptr("sk-dummy-e2e-key"),
|
||||
})
|
||||
require.NoError(t, err, "create provider %q", name)
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
@@ -57,17 +56,11 @@ func TestProviderLifecycle(t *testing.T) {
|
||||
}}
|
||||
}
|
||||
|
||||
for i, pc := range cases {
|
||||
i, pc := i, pc
|
||||
for _, pc := range cases {
|
||||
pc := pc
|
||||
t.Run(pc.name, func(t *testing.T) {
|
||||
req := providerRequest(pc)
|
||||
req.Name = "lc-" + pc.name
|
||||
// Bootstrap the cluster on the first create in case the matrix has
|
||||
// not run (e.g. no provider keys → settings not yet bootstrapped).
|
||||
if i == 0 {
|
||||
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
}
|
||||
|
||||
prov, err := srv.CreateProvider(ctx, req)
|
||||
require.NoError(t, err, "create %s provider", pc.name)
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
||||
@@ -137,45 +130,65 @@ func TestProviderValidation(t *testing.T) {
|
||||
requireClientError(t, err)
|
||||
}
|
||||
|
||||
// TestSettingsRoundTrip flips the collection toggles and confirms cluster /
|
||||
// subdomain stay immutable, then restores the original state.
|
||||
// TestSettingsRoundTrip flips the collection toggles and confirms the
|
||||
// endpoint and proxy address stay immutable, then restores the original
|
||||
// state. A second bootstrap attempt must be rejected as a conflict.
|
||||
func TestSettingsRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Settings are bootstrapped on first provider create.
|
||||
newProvider(t, ctx, "Settings Bootstrap")
|
||||
|
||||
// The package's TestMain bootstrapped the shared account's endpoint.
|
||||
before, err := srv.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings")
|
||||
require.NotEmpty(t, before.Cluster, "settings must carry an assigned cluster")
|
||||
require.NotEmpty(t, before.Endpoint, "settings must carry the bootstrapped endpoint")
|
||||
require.NotEmpty(t, before.ProxyAddress, "settings must carry the bootstrapped proxy address")
|
||||
|
||||
require.NotNil(t, before.AccessLogRetentionDays, "bootstrapped settings must carry a retention")
|
||||
beforeRetention := *before.AccessLogRetentionDays
|
||||
|
||||
flipped, err := srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Endpoint: before.Endpoint,
|
||||
ProxyAddress: before.ProxyAddress,
|
||||
EnableLogCollection: !before.EnableLogCollection,
|
||||
EnablePromptCollection: !before.EnablePromptCollection,
|
||||
RedactPii: !before.RedactPii,
|
||||
AccessLogRetentionDays: beforeRetention,
|
||||
})
|
||||
require.NoError(t, err, "update settings")
|
||||
assert.Equal(t, !before.EnableLogCollection, flipped.EnableLogCollection, "log collection toggle must flip")
|
||||
assert.Equal(t, !before.EnablePromptCollection, flipped.EnablePromptCollection, "prompt collection toggle must flip")
|
||||
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
|
||||
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
|
||||
require.NotNil(t, flipped.AccessLogRetentionDays)
|
||||
assert.Equal(t, beforeRetention, *flipped.AccessLogRetentionDays,
|
||||
"retention sent unchanged must round-trip, not reset to the zero value")
|
||||
assert.Equal(t, before.Endpoint, flipped.Endpoint, "endpoint must be immutable across updates")
|
||||
assert.Equal(t, before.ProxyAddress, flipped.ProxyAddress, "proxy address must be immutable across updates")
|
||||
|
||||
// A cluster different from the pinned one must be rejected; echoing the
|
||||
// pinned one back is valid.
|
||||
// The account is already bootstrapped: a second bootstrap is a conflict,
|
||||
// whatever shape it asks for.
|
||||
_, err = srv.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
Endpoint: ptr("attacker.cluster.invalid"),
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
// The identity fields ride along on the PUT as a required echo: a request
|
||||
// carrying a different endpoint is rejected without applying anything.
|
||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr("attacker.cluster.invalid"),
|
||||
Endpoint: "other.cluster.invalid",
|
||||
ProxyAddress: before.ProxyAddress,
|
||||
EnableLogCollection: before.EnableLogCollection,
|
||||
EnablePromptCollection: before.EnablePromptCollection,
|
||||
RedactPii: before.RedactPii,
|
||||
AccessLogRetentionDays: beforeRetention,
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
// Restore the original toggles.
|
||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr(before.Cluster),
|
||||
Endpoint: before.Endpoint,
|
||||
ProxyAddress: before.ProxyAddress,
|
||||
EnableLogCollection: before.EnableLogCollection,
|
||||
EnablePromptCollection: before.EnablePromptCollection,
|
||||
RedactPii: before.RedactPii,
|
||||
AccessLogRetentionDays: beforeRetention,
|
||||
})
|
||||
require.NoError(t, err, "restore settings")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -14,7 +15,8 @@ import (
|
||||
)
|
||||
|
||||
// harnessStartFresh boots a dedicated combined server with its own fresh
|
||||
// account and registers its teardown on t.
|
||||
// account and registers its teardown on t. Unlike the shared srv, the fresh
|
||||
// account has NOT had its agent-network endpoint bootstrapped.
|
||||
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
|
||||
t.Helper()
|
||||
fresh, err := harness.StartCombined(ctx)
|
||||
@@ -28,16 +30,16 @@ func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, er
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
|
||||
// TestSettingsBootstrapViaPost covers the explicit bootstrap contract on an
|
||||
// account that has never been bootstrapped: the GET reads as the defaults
|
||||
// with an empty cluster/subdomain/endpoint, a PUT without a cluster has
|
||||
// nothing to pin and fails, and a PUT carrying a cluster creates the row and
|
||||
// pins it immutably. The shared srv cannot provide that starting state (any
|
||||
// provider-creating test bootstraps it, and test order is deliberately not
|
||||
// relied on), so this boots a dedicated combined server — the image is
|
||||
// already built and cached by TestMain's StartCombined, so the extra cost is
|
||||
// one container start.
|
||||
func TestSettingsBootstrapViaPut(t *testing.T) {
|
||||
// with an empty endpoint/proxy_address, a PUT has no row to update and fails,
|
||||
// and a POST creates the row and assigns the immutable endpoint — labeled
|
||||
// beneath a proxy address here, with the toggle overrides from the same
|
||||
// request applied. The shared srv cannot provide that starting state
|
||||
// (TestMain bootstraps it), so this boots a dedicated combined server — the
|
||||
// image is already built and cached by TestMain's StartCombined, so the extra
|
||||
// cost is one container start.
|
||||
func TestSettingsBootstrapViaPost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
fresh, err := harnessStartFresh(ctx, t)
|
||||
@@ -47,32 +49,35 @@ func TestSettingsBootstrapViaPut(t *testing.T) {
|
||||
// as an error and not as a null body.
|
||||
before, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings on a fresh account must succeed")
|
||||
assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
|
||||
assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
|
||||
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
|
||||
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap")
|
||||
assert.Empty(t, before.ProxyAddress, "proxy address must be empty before bootstrap")
|
||||
assert.False(t, before.Dedicated, "an unbootstrapped account has no serving shape")
|
||||
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
|
||||
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
|
||||
|
||||
// A PUT without a cluster has nothing to pin the account to.
|
||||
// A PUT has no row to update yet — bootstrap is the explicit POST.
|
||||
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
EnableLogCollection: true,
|
||||
EnableLogCollection: true,
|
||||
AccessLogRetentionDays: 30,
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
// A PUT carrying a cluster bootstraps the account and applies the
|
||||
// mutable fields from the same request. Every toggle is set away from
|
||||
// its bootstrap default so each assertion can actually fail.
|
||||
// A POST with a proxy address bootstraps a labeled endpoint and applies
|
||||
// the toggles from the same request. Every toggle is set away from its
|
||||
// bootstrap default so each assertion can actually fail.
|
||||
const cluster = "e2e.bootstrap.netbird.selfhosted"
|
||||
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr(cluster),
|
||||
EnableLogCollection: false,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
ProxyAddress: ptr(cluster),
|
||||
EnableLogCollection: ptr(false),
|
||||
EnablePromptCollection: ptr(true),
|
||||
RedactPii: ptr(true),
|
||||
})
|
||||
require.NoError(t, err, "bootstrap settings via PUT must succeed")
|
||||
assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
|
||||
require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
|
||||
assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
|
||||
require.NoError(t, err, "bootstrap settings via POST must succeed")
|
||||
assert.Equal(t, cluster, bootstrapped.ProxyAddress, "proxy address must be pinned from the request")
|
||||
require.NotEmpty(t, bootstrapped.Endpoint, "endpoint must be assigned at bootstrap")
|
||||
assert.True(t, strings.HasSuffix(bootstrapped.Endpoint, "."+cluster),
|
||||
"labeled endpoint must hang one label beneath the proxy address: %s", bootstrapped.Endpoint)
|
||||
assert.False(t, bootstrapped.Dedicated, "a labeled pin is not dedicated")
|
||||
assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
|
||||
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
|
||||
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
|
||||
@@ -85,30 +90,90 @@ func TestSettingsBootstrapViaPut(t *testing.T) {
|
||||
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
|
||||
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
|
||||
|
||||
// Once bootstrapped, later updates may omit the cluster entirely.
|
||||
// Once bootstrapped, PUT updates the toggles. The identity fields ride
|
||||
// along as a required echo of the assigned values; a matching echo is
|
||||
// accepted and never written.
|
||||
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Endpoint: bootstrapped.Endpoint,
|
||||
ProxyAddress: bootstrapped.ProxyAddress,
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: false,
|
||||
RedactPii: true,
|
||||
AccessLogRetentionDays: 21,
|
||||
})
|
||||
require.NoError(t, err, "post-bootstrap update without cluster must succeed")
|
||||
assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
|
||||
require.NoError(t, err, "post-bootstrap update must succeed")
|
||||
require.NotNil(t, persisted.AccessLogRetentionDays)
|
||||
assert.Equal(t, 21, *persisted.AccessLogRetentionDays, "retention from the update must apply")
|
||||
assert.Equal(t, bootstrapped.Endpoint, persisted.Endpoint, "endpoint must survive updates untouched")
|
||||
assert.Equal(t, cluster, persisted.ProxyAddress, "proxy address must survive updates untouched")
|
||||
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
|
||||
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
|
||||
|
||||
// The cluster is immutable: a different value is rejected rather than
|
||||
// silently ignored, and the rejected update must not disturb anything.
|
||||
// The endpoint is immutable: a PUT carrying a different endpoint is
|
||||
// rejected, and a second bootstrap is rejected as a conflict. Neither
|
||||
// rejected write may disturb anything.
|
||||
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr("other.cluster.invalid"),
|
||||
EnableLogCollection: false,
|
||||
Endpoint: "other.cluster.invalid",
|
||||
ProxyAddress: persisted.ProxyAddress,
|
||||
EnableLogCollection: persisted.EnableLogCollection,
|
||||
EnablePromptCollection: persisted.EnablePromptCollection,
|
||||
RedactPii: persisted.RedactPii,
|
||||
AccessLogRetentionDays: 21,
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
_, err = fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
Endpoint: ptr("other.cluster.invalid"),
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
final, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings after the rejected cluster change must succeed")
|
||||
assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
|
||||
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
|
||||
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
|
||||
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
|
||||
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
|
||||
require.NoError(t, err, "get settings after the rejected bootstrap must succeed")
|
||||
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected bootstrap must not change the endpoint")
|
||||
assert.Equal(t, persisted.ProxyAddress, final.ProxyAddress, "rejected bootstrap must not change the proxy address")
|
||||
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected bootstrap must not apply its toggles")
|
||||
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected bootstrap must not apply its toggles")
|
||||
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected bootstrap must not apply its toggles")
|
||||
}
|
||||
|
||||
// TestSettingsBootstrapSelfAddressed covers the dedicated shape end to end:
|
||||
// a POST carrying an endpoint claims the hostname verbatim, the proxy address
|
||||
// equals it, and the pin reads as dedicated — the address-first flow a
|
||||
// self-hosted operator uses before deploying the proxy that will declare it.
|
||||
// The tail covers the recovery path the guarded DELETE exists for: with no
|
||||
// providers and no proxy at the address, the claim can be released and a
|
||||
// fresh bootstrap succeeds — the fix for a typo'd immutable endpoint.
|
||||
func TestSettingsBootstrapSelfAddressed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
fresh, err := harnessStartFresh(ctx, t)
|
||||
require.NoError(t, err, "start dedicated combined server")
|
||||
|
||||
created, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
Endpoint: ptr("gw.e2e.netbird.selfhosted"),
|
||||
})
|
||||
require.NoError(t, err, "self-addressed bootstrap must succeed")
|
||||
assert.Equal(t, "gw.e2e.netbird.selfhosted", created.Endpoint, "endpoint must be claimed verbatim")
|
||||
assert.Equal(t, created.Endpoint, created.ProxyAddress, "self-addressed: proxy address is the endpoint")
|
||||
assert.True(t, created.Dedicated, "a self-addressed pin is dedicated")
|
||||
|
||||
// No providers exist and no proxy declares the address, so both delete
|
||||
// guards are clear: the delete releases the claim and the account reads
|
||||
// as unbootstrapped defaults again.
|
||||
require.NoError(t, fresh.DeleteSettings(ctx), "guarded delete with both guards clear must succeed")
|
||||
|
||||
after, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings after delete must succeed")
|
||||
assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped")
|
||||
|
||||
// A second delete has nothing to remove.
|
||||
requireClientError(t, fresh.DeleteSettings(ctx))
|
||||
|
||||
// Re-creating is a fresh bootstrap — the released hostname is free to be
|
||||
// claimed again, or a different one chosen.
|
||||
recreated, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
Endpoint: ptr("gw2.e2e.netbird.selfhosted"),
|
||||
})
|
||||
require.NoError(t, err, "bootstrap after delete must succeed")
|
||||
assert.Equal(t, "gw2.e2e.netbird.selfhosted", recreated.Endpoint, "the fresh bootstrap claims the new hostname")
|
||||
}
|
||||
|
||||
@@ -66,9 +66,7 @@ func TestProviderSkipTLSVerification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// First create bootstraps the account cluster.
|
||||
insecureReq := newReq("skip-tls", insecureModel, true)
|
||||
insecureReq.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||||
insecureProv, err := srv.CreateProvider(ctx, insecureReq)
|
||||
require.NoError(t, err, "create skip-tls provider")
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), insecureProv.Id) })
|
||||
|
||||
@@ -57,12 +57,11 @@ func TestVLLMProvider(t *testing.T) {
|
||||
// is enumerated so the router dispatches this model string to this provider.
|
||||
dummyKey := "sk-vllm-e2e"
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: "vllm",
|
||||
ProviderId: "vllm",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
BootstrapCluster: ptr(harness.AgentNetworkCluster),
|
||||
Name: "vllm",
|
||||
ProviderId: "vllm",
|
||||
UpstreamUrl: vllm.URL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
Models: &[]api.AgentNetworkProviderModel{
|
||||
{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
},
|
||||
|
||||
@@ -126,17 +126,33 @@ func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error {
|
||||
return anDelete(ctx, c, "/api/agent-network/guardrails/"+id)
|
||||
}
|
||||
|
||||
// GetSettings returns the account's agent-network settings row. It exists only
|
||||
// after the first provider create bootstraps it.
|
||||
// CreateSettings bootstraps the account's agent-network settings row,
|
||||
// assigning the immutable endpoint. Exactly one of req.ProxyAddress (labeled
|
||||
// endpoint beneath that cluster) and req.Endpoint (self-addressed dedicated
|
||||
// endpoint) must be set; a second bootstrap returns a conflict.
|
||||
func (c *Combined) CreateSettings(ctx context.Context, req api.AgentNetworkSettingsCreateRequest) (api.AgentNetworkSettings, error) {
|
||||
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPost, "/api/agent-network/settings", req)
|
||||
}
|
||||
|
||||
// GetSettings returns the account's agent-network settings row. Before the
|
||||
// CreateSettings bootstrap it reads as the defaults with an empty endpoint.
|
||||
func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) {
|
||||
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodGet, "/api/agent-network/settings", nil)
|
||||
}
|
||||
|
||||
// UpdateSettings applies the mutable collection toggles.
|
||||
// UpdateSettings applies the mutable collection toggles. The request must
|
||||
// echo the assigned endpoint and proxy address unchanged — the server rejects
|
||||
// a PUT that tries to change them.
|
||||
func (c *Combined) UpdateSettings(ctx context.Context, req api.AgentNetworkSettingsRequest) (api.AgentNetworkSettings, error) {
|
||||
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPut, "/api/agent-network/settings", req)
|
||||
}
|
||||
|
||||
// DeleteSettings removes the account's settings row, releasing the endpoint.
|
||||
// Refused while providers exist or a proxy is actively serving the endpoint.
|
||||
func (c *Combined) DeleteSettings(ctx context.Context) error {
|
||||
return anDelete(ctx, c, "/api/agent-network/settings")
|
||||
}
|
||||
|
||||
// ListConsumption returns the account's consumption rows (possibly empty).
|
||||
func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) {
|
||||
return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil)
|
||||
|
||||
5
go.mod
5
go.mod
@@ -99,7 +99,7 @@ require (
|
||||
github.com/pires/go-proxyproto v0.11.0
|
||||
github.com/pkg/sftp v1.13.9
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/quic-go/quic-go v0.55.0
|
||||
github.com/quic-go/quic-go v0.59.1
|
||||
github.com/redis/go-redis/v9 v9.7.3
|
||||
github.com/rs/xid v1.3.0
|
||||
github.com/shirou/gopsutil/v4 v4.25.8
|
||||
@@ -239,7 +239,6 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
@@ -340,4 +339,4 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2
|
||||
|
||||
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
||||
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db
|
||||
|
||||
11
go.sum
11
go.sum
@@ -349,8 +349,6 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
@@ -490,8 +488,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
|
||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4 h1:UKztc3QjWvzU5DZk+uYaOWN0x62NSe/pkxuPvzqZIy4=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db h1:gBOE2r4AW1soSmpYJC5/n9/1L8UQ8+HLjed8CY/TzZY=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db/go.mod h1:bsdahLwBQxXjlmdPPeQyrTcDJfcqAr/ymFj0RXhwtWI=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
@@ -582,8 +580,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -793,7 +791,6 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user