Compare commits

..

3 Commits

Author SHA1 Message Date
Viktor Liu
c4f89e0994 Pass the Android state path to the bundle generator 2026-08-07 16:57:42 +02:00
Viktor Liu
fe960bd654 Address review comments 2026-08-07 16:55:42 +02:00
Viktor Liu
d0bc7d9310 Add strict anonymization level for debug bundles and always anonymize MAC addresses 2026-08-07 16:49:46 +02:00
59 changed files with 2236 additions and 2541 deletions

View File

@@ -475,132 +475,6 @@ 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:
@@ -814,7 +688,7 @@ jobs:
comment_release_artifacts:
name: Comment release artifacts
runs-on: ubuntu-latest
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin]
needs: [release, release_ui, release_ui_darwin]
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
permissions:
contents: read
@@ -826,14 +700,12 @@ 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:
@@ -856,7 +728,6 @@ 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],
];
@@ -913,7 +784,7 @@ jobs:
trigger_signer:
runs-on: ubuntu-latest
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin, test_windows_installer]
needs: [release, release_ui, release_ui_darwin, test_windows_installer]
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Trigger binaries sign pipelines

View File

@@ -257,15 +257,6 @@ 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

View File

@@ -1,131 +0,0 @@
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
- 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)
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.
- 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
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
method: PUT

317
AGENTS.md
View File

@@ -14,22 +14,20 @@ in this file, not duplicated there.
## 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)
- [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)
- [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
@@ -159,125 +157,11 @@ 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 linter warnings.** Fix what `golangci-lint` reports on code
you touch, and delete imports, helpers, and parameters your refactor orphaned.
2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings 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
@@ -291,12 +175,9 @@ reviewer can jump straight to the rule.
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 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.
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.
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.
@@ -304,93 +185,6 @@ reviewer can jump straight to the rule.
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`:
@@ -454,45 +248,6 @@ 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
@@ -514,14 +269,10 @@ checksum = updateChecksum(checksum, oldPort, newPort)
### Length budget
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.
- **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.
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
@@ -578,19 +329,6 @@ 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()
@@ -642,8 +380,7 @@ 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 — the docs gate in CI reads
that section and fails when it is missing.
verified, and do not delete rows that do not apply.
- **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;
@@ -702,12 +439,6 @@ 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.

View File

@@ -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"
@@ -278,8 +279,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 +301,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 +325,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
deps,
debug.BundleConfig{
Anonymize: anonymize,
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
IncludeSystemInfo: true,
},
)

View File

@@ -2,6 +2,7 @@ package anonymize
import (
"crypto/rand"
"encoding/base64"
"fmt"
"math/big"
"net"
@@ -15,13 +16,81 @@ 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
)
// 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 "", LevelDefault.String():
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 "strict"
}
return "default"
}
// 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 +101,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 +153,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 +265,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 +294,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 +320,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 +401,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 +513,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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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, ", ")))
}

View File

@@ -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"
@@ -1385,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),
}

View File

@@ -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"
@@ -200,8 +201,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 +254,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
deps,
debug.BundleConfig{
Anonymize: anonymize,
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
IncludeSystemInfo: true,
},
)

View File

@@ -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" +

View File

@@ -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 {

View File

@@ -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(),
},

View File

@@ -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 {

View File

@@ -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,

View File

@@ -1,35 +0,0 @@
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 };
};

View File

@@ -11,7 +11,6 @@ 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();
@@ -20,7 +19,6 @@ 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();
@@ -59,15 +57,6 @@ 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 && (

View File

@@ -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}

View File

@@ -551,14 +551,6 @@
"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"
},

View File

@@ -735,14 +735,6 @@
"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."
@@ -1013,11 +1005,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",

View File

@@ -551,14 +551,6 @@
"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"
},

View File

@@ -551,14 +551,6 @@
"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 daffichage"
},

View File

@@ -551,14 +551,6 @@
"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"
},

View File

@@ -551,14 +551,6 @@
"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"
},

View File

@@ -551,14 +551,6 @@
"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": "表示言語"
},

View File

@@ -551,14 +551,6 @@
"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"
},

View File

@@ -551,14 +551,6 @@
"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": "Язык интерфейса"
},

View File

@@ -551,14 +551,6 @@
"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": "显示语言"
},

View File

@@ -180,7 +180,6 @@ func main() {
WindowManager: windowManager,
Session: authSession,
Localizer: localizer,
Preferences: prefStore,
})
listenForShowSignal(context.Background(), tray)

View File

@@ -58,10 +58,6 @@ 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.
@@ -187,26 +183,6 @@ 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 == "" {

View File

@@ -238,42 +238,6 @@ 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)

View File

@@ -49,11 +49,5 @@ 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
}

View File

@@ -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

View File

@@ -34,7 +34,3 @@ 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)
}

View File

@@ -16,7 +16,6 @@ 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"
)
@@ -51,9 +50,8 @@ 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
Preferences *preferences.Store
Session *authsession.Session
Localizer *Localizer
}
type Tray struct {
@@ -463,12 +461,10 @@ func (t *Tray) handleQuit() {
t.profileMu.Unlock()
t.svc.DaemonFeed.CancelProfileSwitch()
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)
}
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()
}

View File

@@ -1,40 +0,0 @@
//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")
}

View File

@@ -1,4 +1,4 @@
//go:build linux && !gtk3 && !(linux && 386)
//go:build linux && !(linux && 386)
package main

View File

@@ -1,5 +1,3 @@
//go:build linux && !gtk3 && !(linux && 386)
#include "xembed_tray_linux.h"
#include <X11/Xatom.h>

View File

@@ -262,7 +262,6 @@ init_environment() {
POSTGRES_DB="netbird"
POSTGRES_PASSWORD=$(rand_secret)
NETBIRD_ENCRYPTION_KEY=$(rand_b64_key)
NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)
NETBIRD_RELAY_AUTH_SECRET=$(rand_secret)
POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC"
@@ -697,7 +696,6 @@ server:
issuer: "https://${NETBIRD_DOMAIN}/oauth2"
localAuthDisabled: false
signKeyRefreshEnabled: false
sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"
dashboardRedirectURIs:
- "https://${NETBIRD_DOMAIN}/nb-auth"
- "https://${NETBIRD_DOMAIN}/nb-silent-auth"

View File

@@ -348,7 +348,6 @@ initialize_default_values() {
NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
# Note: DataStoreEncryptionKey must keep base64 padding (=) for Go's base64.StdEncoding
DATASTORE_ENCRYPTION_KEY=$(openssl rand -base64 32)
SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)
NETBIRD_STUN_PORT=3478
# Docker images
@@ -528,8 +527,7 @@ generate_configuration_files() {
# Common files for all configurations
render_dashboard_env > dashboard.env
install -m 600 /dev/null config.yaml
render_combined_yaml >> config.yaml
render_combined_yaml > config.yaml
return 0
}
@@ -913,7 +911,6 @@ server:
auth:
issuer: "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2"
signKeyRefreshEnabled: true
sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"
dashboardRedirectURIs:
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth"
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth"

View File

@@ -7,6 +7,7 @@ import (
"testing"
"time"
cachestore "github.com/eko/gocache/lib/v4/store"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -30,7 +31,7 @@ import (
"github.com/netbirdio/netbird/shared/management/status"
)
func testCacheStore(t *testing.T) nbcache.Store {
func testCacheStore(t *testing.T) cachestore.StoreInterface {
t.Helper()
s, err := nbcache.NewStore(context.Background(), 30*time.Minute, 10*time.Minute, 100)
require.NoError(t, err)
@@ -294,7 +295,6 @@ func TestPersistNewService(t *testing.T) {
assert.Equal(t, status.AlreadyExists, sErr.Type())
})
}
func TestPreserveExistingAuthSecrets(t *testing.T) {
mgr := &Manager{}

View File

@@ -20,6 +20,8 @@ import (
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
cachestore "github.com/eko/gocache/lib/v4/store"
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/formatter/hook"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
@@ -68,8 +70,8 @@ func (s *BaseServer) Metrics() telemetry.AppMetrics {
// CacheStore returns a shared cache store backed by Redis or in-memory depending on the environment.
// All consumers should reuse this store to avoid creating multiple Redis connections.
func (s *BaseServer) CacheStore() nbcache.Store {
return Create(s, func() nbcache.Store {
func (s *BaseServer) CacheStore() cachestore.StoreInterface {
return Create(s, func() cachestore.StoreInterface {
cs, err := nbcache.NewStore(context.Background(), nbcache.DefaultStoreMaxTimeout, nbcache.DefaultStoreCleanupInterval, nbcache.DefaultStoreMaxConn)
if err != nil {
log.Fatalf("failed to create shared cache store: %v", err)

View File

@@ -5,23 +5,22 @@ import (
"fmt"
"time"
"github.com/eko/gocache/lib/v4/cache"
"github.com/eko/gocache/lib/v4/store"
log "github.com/sirupsen/logrus"
nbcache "github.com/netbirdio/netbird/management/server/cache"
)
// PKCEVerifierStore manages PKCE verifiers for OAuth flows.
// Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var.
type PKCEVerifierStore struct {
cache nbcache.Store
cache *cache.Cache[string]
ctx context.Context
}
// NewPKCEVerifierStore creates a PKCE verifier store using the provided shared cache store.
func NewPKCEVerifierStore(ctx context.Context, cacheStore nbcache.Store) *PKCEVerifierStore {
func NewPKCEVerifierStore(ctx context.Context, cacheStore store.StoreInterface) *PKCEVerifierStore {
return &PKCEVerifierStore{
cache: cacheStore,
cache: cache.New[string](cacheStore),
ctx: ctx,
}
}
@@ -41,14 +40,14 @@ func (s *PKCEVerifierStore) Store(state, verifier string, ttl time.Duration) err
// Returns the verifier and true if found, or empty string and false if not found.
// This enforces single-use semantics for PKCE verifiers.
func (s *PKCEVerifierStore) LoadAndDelete(state string) (string, bool) {
verifier, found, err := s.cache.GetDel(s.ctx, state)
verifier, err := s.cache.Get(s.ctx, state)
if err != nil {
log.Warnf("Failed to consume PKCE verifier: %v", err)
log.Debugf("PKCE verifier not found for state")
return "", false
}
if !found {
log.Debug("PKCE verifier not found for state")
return "", false
if err := s.cache.Delete(s.ctx, state); err != nil {
log.Warnf("Failed to delete PKCE verifier for state: %v", err)
}
return verifier, true

View File

@@ -1,85 +0,0 @@
package grpc
import (
"context"
"testing"
"time"
)
func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) {
const (
state = "state"
verifier = "verifier"
attempts = 64
)
t.Run("exactly one concurrent caller consumes the verifier", func(t *testing.T) {
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
if err := store.Store(state, verifier, time.Minute); err != nil {
t.Fatalf("couldn't store PKCE verifier: %s", err)
}
start := make(chan struct{})
type result struct {
verifier string
found bool
}
results := make(chan result, attempts)
for range attempts {
go func() {
<-start
verifier, found := store.LoadAndDelete(state)
results <- result{verifier: verifier, found: found}
}()
}
close(start)
winners := 0
for range attempts {
result := <-results
if result.found {
winners++
if result.verifier != verifier {
t.Fatalf("unexpected verifier: got %q, expected %q", result.verifier, verifier)
}
}
}
if winners != 1 {
t.Fatalf("expected exactly one PKCE verifier consumer, got %d", winners)
}
})
t.Run("replayed state is rejected", func(t *testing.T) {
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
if err := store.Store(state, verifier, time.Minute); err != nil {
t.Fatalf("couldn't store PKCE verifier: %s", err)
}
if got, found := store.LoadAndDelete(state); !found || got != verifier {
t.Fatalf("first load should return the verifier, got %q, found %t", got, found)
}
if got, found := store.LoadAndDelete(state); found {
t.Fatalf("replayed state should not resolve, got %q", got)
}
})
t.Run("unknown state is rejected", func(t *testing.T) {
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
if got, found := store.LoadAndDelete("never-stored"); found {
t.Fatalf("unknown state should not resolve, got %q", got)
}
})
t.Run("expired verifier is rejected", func(t *testing.T) {
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
if err := store.Store(state, verifier, 50*time.Millisecond); err != nil {
t.Fatalf("couldn't store PKCE verifier: %s", err)
}
time.Sleep(100 * time.Millisecond)
if got, found := store.LoadAndDelete(state); found {
t.Fatalf("expired verifier should not resolve, got %q", got)
}
})
}

View File

@@ -9,6 +9,7 @@ import (
"testing"
"time"
cachestore "github.com/eko/gocache/lib/v4/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
@@ -20,7 +21,7 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
)
func testCacheStore(t *testing.T) nbcache.Store {
func testCacheStore(t *testing.T) cachestore.StoreInterface {
t.Helper()
s, err := nbcache.NewStore(context.Background(), 30*time.Minute, 10*time.Minute, 100)
require.NoError(t, err)

View File

@@ -7,6 +7,9 @@ import (
"errors"
"fmt"
"time"
"github.com/eko/gocache/lib/v4/cache"
"github.com/eko/gocache/lib/v4/store"
)
const (
@@ -19,17 +22,12 @@ var (
ErrTokenExpired = errors.New("JWT expired")
)
// TokenCache atomically records used JWTs until their expiration.
type TokenCache interface {
SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error)
}
type SessionStore struct {
cache TokenCache
cache *cache.Cache[string]
}
func NewSessionStore(cacheStore TokenCache) *SessionStore {
return &SessionStore{cache: cacheStore}
func NewSessionStore(cacheStore store.StoreInterface) *SessionStore {
return &SessionStore{cache: cache.New[string](cacheStore)}
}
// RegisterToken records a JWT until its exp time and rejects reuse.
@@ -40,14 +38,20 @@ func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresA
}
key := usedTokenKeyPrefix + hashToken(token)
created, err := s.cache.SetNX(ctx, key, usedTokenMarker, ttl)
if err != nil {
return fmt.Errorf("store used token entry: %w", err)
}
if !created {
_, err := s.cache.Get(ctx, key)
if err == nil {
return ErrTokenAlreadyUsed
}
var notFound *store.NotFound
if !errors.As(err, &notFound) {
return fmt.Errorf("failed to lookup used token entry: %w", err)
}
if err := s.cache.Set(ctx, key, usedTokenMarker, store.WithExpiration(ttl)); err != nil {
return fmt.Errorf("failed to store used token entry: %w", err)
}
return nil
}

View File

@@ -2,7 +2,6 @@ package auth
import (
"context"
"errors"
"testing"
"time"
@@ -39,39 +38,6 @@ func TestSessionStore_RegisterSameTokenTwiceIsRejected(t *testing.T) {
assert.ErrorIs(t, err, ErrTokenAlreadyUsed)
}
func TestSessionStore_ConcurrentRegistrationAllowsOneCaller(t *testing.T) {
s := newTestSessionStore(t)
ctx := context.Background()
const attempts = 100
start := make(chan struct{})
results := make(chan error, attempts)
for range attempts {
go func() {
<-start
results <- s.RegisterToken(ctx, "token", time.Now().Add(time.Hour))
}()
}
close(start)
succeeded := 0
alreadyUsed := 0
for range attempts {
err := <-results
switch {
case err == nil:
succeeded++
case errors.Is(err, ErrTokenAlreadyUsed):
alreadyUsed++
default:
require.NoError(t, err, "concurrent registration returned an unexpected error")
}
}
assert.Equal(t, 1, succeeded, "exactly one concurrent caller should register the token")
assert.Equal(t, attempts-1, alreadyUsed, "every other caller should be rejected as already used")
}
func TestSessionStore_RegisterDifferentTokensAreIndependent(t *testing.T) {
s := newTestSessionStore(t)
ctx := context.Background()
@@ -106,23 +72,6 @@ func TestSessionStore_EntryEvictsAtTTLAndAllowsReRegistration(t *testing.T) {
require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(time.Hour)))
}
type failingTokenCache struct {
err error
}
func (f failingTokenCache) SetNX(context.Context, string, string, time.Duration) (bool, error) {
return false, f.err
}
func TestSessionStore_CacheErrorIsReturned(t *testing.T) {
cacheErr := errors.New("cache unavailable")
s := NewSessionStore(failingTokenCache{err: cacheErr})
err := s.RegisterToken(context.Background(), "token", time.Now().Add(time.Hour))
require.Error(t, err, "cache failure should be surfaced to the caller")
assert.ErrorIs(t, err, cacheErr, "cache error should be wrapped, not replaced")
}
func TestHashToken_StableAndDoesNotLeak(t *testing.T) {
a := hashToken("tokenA")
b := hashToken("tokenB")

View File

@@ -1,57 +0,0 @@
package cache
import (
"context"
"fmt"
"sync"
"time"
"github.com/eko/gocache/lib/v4/store"
gocachestore "github.com/eko/gocache/store/go_cache/v4"
gocache "github.com/patrickmn/go-cache"
)
type goCacheStore struct {
store.StoreInterface
client *gocache.Cache
mu sync.Mutex
}
func newMemoryStore(maxTimeout, cleanupInterval time.Duration) Store {
client := gocache.New(maxTimeout, cleanupInterval)
return &goCacheStore{
StoreInterface: gocachestore.NewGoCache(client),
client: client,
}
}
func (s *goCacheStore) SetNX(_ context.Context, key, value string, ttl time.Duration) (bool, error) {
// Add only returns an error when a non-expired entry already exists.
if err := s.client.Add(key, value, ttl); err != nil {
return false, nil //nolint:nilerr
}
return true, nil
}
// GetDel reads the value under key and removes it. go-cache has no native read-and-delete
// and releases its own lock between the two calls, so mu holds the pair together and no
// value is consumed twice.
//
// Writes do not take mu: a Set landing mid-pair is lost, since GetDel returns the prior
// value and deletes the new one. Callers must write a consumed key only once.
func (s *goCacheStore) GetDel(_ context.Context, key string) (string, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
value, found := s.client.Get(key)
if !found {
return "", false, nil
}
s.client.Delete(key)
str, ok := value.(string)
if !ok {
return "", false, fmt.Errorf("cached value is %T, not a string", value)
}
return str, true, nil
}

View File

@@ -1,76 +0,0 @@
package cache_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/cache"
)
func TestMemoryStore(t *testing.T) {
memStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
require.NoError(t, err, "couldn't create memory store")
ctx := context.Background()
key, value := "testing", "tested"
err = memStore.Set(ctx, key, value)
assert.NoError(t, err, "couldn't set testing data")
result, err := memStore.Get(ctx, key)
assert.NoError(t, err, "couldn't get testing data")
assert.Equal(t, value, result, "value returned doesn't match testing data")
created, err := memStore.SetNX(ctx, "conditional", value, 100*time.Millisecond)
require.NoError(t, err, "couldn't conditionally set testing data")
require.True(t, created, "first conditional set should create the entry")
created, err = memStore.SetNX(ctx, "conditional", value, 100*time.Millisecond)
require.NoError(t, err, "couldn't conditionally check testing data")
require.False(t, created, "second conditional set should not replace the entry")
// test expiration
time.Sleep(300 * time.Millisecond)
_, err = memStore.Get(ctx, key)
assert.Error(t, err, "value should not be found")
}
func TestMemoryStoreGetDel(t *testing.T) {
ctx := context.Background()
newStore := func(t *testing.T) cache.Store {
t.Helper()
memStore, err := cache.NewStore(ctx, time.Minute, time.Minute, 100)
require.NoError(t, err, "couldn't create memory store")
return memStore
}
const (
key = "consume"
value = "verifier"
)
t.Run("exactly one concurrent caller consumes the key", func(t *testing.T) {
memStore := newStore(t)
require.NoError(t, memStore.Set(ctx, key, value), "couldn't set testing data")
assertGetDelConsumedOnce(ctx, t, []cache.Store{memStore}, key, value)
assertGetDelMisses(ctx, t, memStore, key)
})
t.Run("missing key is not an error", func(t *testing.T) {
assertGetDelMisses(ctx, t, newStore(t), "never-set")
})
t.Run("expired key is not found", func(t *testing.T) {
memStore := newStore(t)
_, err := memStore.SetNX(ctx, key, value, 50*time.Millisecond)
require.NoError(t, err, "couldn't set testing data")
time.Sleep(100 * time.Millisecond)
assertGetDelMisses(ctx, t, memStore, key)
})
}

View File

@@ -1,63 +0,0 @@
package cache
import (
"context"
"errors"
"fmt"
"math"
"time"
"github.com/eko/gocache/lib/v4/store"
redisstore "github.com/eko/gocache/store/redis/v4"
"github.com/redis/go-redis/v9"
log "github.com/sirupsen/logrus"
)
type redisStore struct {
store.StoreInterface
client *redis.Client
}
func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (Store, error) {
options, err := redis.ParseURL(redisEnvAddr)
if err != nil {
return nil, fmt.Errorf("parsing redis cache url: %s", err)
}
options.MaxIdleConns = int(math.Ceil(float64(maxConn) * 0.5)) // 50% of max conns
options.MinIdleConns = int(math.Ceil(float64(maxConn) * 0.1)) // 10% of max conns
options.MaxActiveConns = maxConn
options.ConnMaxIdleTime = 30 * time.Minute
options.ConnMaxLifetime = 0
options.PoolTimeout = 10 * time.Second
redisClient := redis.NewClient(options)
subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, err = redisClient.Ping(subCtx).Result()
if err != nil {
return nil, err
}
log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr)
return &redisStore{
StoreInterface: redisstore.NewRedis(redisClient),
client: redisClient,
}, nil
}
func (s *redisStore) SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error) {
return s.client.SetNX(ctx, key, value, ttl).Result()
}
func (s *redisStore) GetDel(ctx context.Context, key string) (string, bool, error) {
value, err := s.client.GetDel(ctx, key).Result()
if errors.Is(err, redis.Nil) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return value, true, nil
}

View File

@@ -1,153 +0,0 @@
package cache_test
import (
"context"
"testing"
"time"
"github.com/eko/gocache/lib/v4/store"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis"
"github.com/netbirdio/netbird/management/server/cache"
)
func startRedis(t *testing.T) string {
t.Helper()
ctx := context.Background()
redisContainer, err := testcontainersredis.Run(ctx, "redis:7")
require.NoError(t, err, "couldn't start redis container")
t.Cleanup(func() {
if err := redisContainer.Terminate(ctx); err != nil {
t.Logf("failed to terminate container: %s", err)
}
})
redisURL, err := redisContainer.ConnectionString(ctx)
require.NoError(t, err, "couldn't get connection string")
t.Setenv(cache.RedisStoreEnvVar, redisURL)
return redisURL
}
func newRedisStore(t *testing.T) cache.Store {
t.Helper()
redisStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
require.NoError(t, err)
return redisStore
}
func TestRedisStoreConnectionFailure(t *testing.T) {
t.Setenv(cache.RedisStoreEnvVar, "redis://127.0.0.1:6379")
_, err := cache.NewStore(context.Background(), 10*time.Millisecond, 30*time.Millisecond, 100)
require.Error(t, err, "getting redis cache store should return error")
}
func TestRedisStoreConnectionSuccess(t *testing.T) {
ctx := context.Background()
redisURL := startRedis(t)
redisStore := newRedisStore(t)
key, value := "testing", "tested"
err := redisStore.Set(ctx, key, value, store.WithExpiration(100*time.Millisecond))
assert.NoError(t, err, "couldn't set testing data")
result, err := redisStore.Get(ctx, key)
assert.NoError(t, err, "couldn't get testing data")
assert.Equal(t, value, result, "value returned doesn't match testing data")
options, err := redis.ParseURL(redisURL)
require.NoError(t, err, "parsing redis cache url")
redisClient := redis.NewClient(options)
r, err := redisClient.Get(ctx, key).Result()
assert.NoError(t, err, "couldn't get testing data from redis")
assert.Equal(t, value, r, "value returned from redis doesn't match testing data")
// test expiration
time.Sleep(300 * time.Millisecond)
_, err = redisStore.Get(ctx, key)
assert.Error(t, err, "value should not be found")
}
func TestRedisStoreSetNX(t *testing.T) {
ctx := context.Background()
redisURL := startRedis(t)
redisStore, secondRedisStore := newRedisStore(t), newRedisStore(t)
const (
key = "conditional"
value = "tested"
)
start := make(chan struct{})
type setResult struct {
created bool
err error
}
results := make(chan setResult, 2)
for _, cacheStore := range []cache.Store{redisStore, secondRedisStore} {
go func() {
<-start
created, err := cacheStore.SetNX(ctx, key, value, time.Minute)
results <- setResult{created: created, err: err}
}()
}
close(start)
created := 0
for range 2 {
result := <-results
require.NoError(t, result.err, "conditional redis set failed")
if result.created {
created++
}
}
require.Equal(t, 1, created, "expected exactly one redis client to create the entry")
options, err := redis.ParseURL(redisURL)
require.NoError(t, err, "parsing redis cache url")
ttl, err := redis.NewClient(options).PTTL(ctx, key).Result()
require.NoError(t, err, "couldn't read entry TTL")
require.Positive(t, ttl, "created entry should have a positive TTL")
}
func TestRedisStoreGetDel(t *testing.T) {
ctx := context.Background()
startRedis(t)
redisStore, secondRedisStore := newRedisStore(t), newRedisStore(t)
const (
key = "consume"
value = "verifier"
)
t.Run("exactly one caller across independent clients consumes the key", func(t *testing.T) {
// A generous TTL: the key is consumed explicitly, so expiry racing the
// concurrent callers would only make the test flaky on a loaded runner.
err := redisStore.Set(ctx, key, value, store.WithExpiration(time.Minute))
require.NoError(t, err, "couldn't set value to consume")
assertGetDelConsumedOnce(ctx, t, []cache.Store{redisStore, secondRedisStore}, key, value)
assertGetDelMisses(ctx, t, secondRedisStore, key)
})
t.Run("missing key is not an error", func(t *testing.T) {
assertGetDelMisses(ctx, t, redisStore, "never-set")
})
t.Run("expired key is not found", func(t *testing.T) {
err := redisStore.Set(ctx, key, value, store.WithExpiration(50*time.Millisecond))
require.NoError(t, err, "couldn't set value to consume")
time.Sleep(100 * time.Millisecond)
assertGetDelMisses(ctx, t, redisStore, key)
})
}

View File

@@ -2,10 +2,17 @@ package cache
import (
"context"
"fmt"
"math"
"os"
"time"
"github.com/eko/gocache/lib/v4/store"
gocache_store "github.com/eko/gocache/store/go_cache/v4"
redis_store "github.com/eko/gocache/store/redis/v4"
gocache "github.com/patrickmn/go-cache"
"github.com/redis/go-redis/v9"
log "github.com/sirupsen/logrus"
)
// RedisStoreEnvVar is the environment variable that determines if a redis store should be used.
@@ -24,23 +31,15 @@ const (
DefaultStoreMaxConn = 1000
)
// Store extends the shared cache interface with conditional and consuming operations.
type Store interface {
store.StoreInterface
// SetNX stores a value with a TTL only when the key does not exist.
SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error)
// GetDel reads a value and removes it, so only one caller can consume a key.
GetDel(ctx context.Context, key string) (value string, found bool, err error)
}
// NewStore creates a new cache store with the given max timeout and cleanup interval. It checks for the environment Variable RedisStoreEnvVar
// to determine if a redis store should be used. If the environment variable is set, it will attempt to connect to the redis store.
func NewStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (Store, error) {
func NewStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (store.StoreInterface, error) {
redisAddr := GetAddrFromEnv()
if redisAddr != "" {
return getRedisStore(ctx, redisAddr, maxConn)
}
return newMemoryStore(maxTimeout, cleanupInterval), nil
goc := gocache.New(maxTimeout, cleanupInterval)
return gocache_store.NewGoCache(goc), nil
}
// GetAddrFromEnv returns the redis address from the environment variable RedisStoreEnvVar or its legacy counterpart.
@@ -51,3 +50,29 @@ func GetAddrFromEnv() string {
}
return addr
}
func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (store.StoreInterface, error) {
options, err := redis.ParseURL(redisEnvAddr)
if err != nil {
return nil, fmt.Errorf("parsing redis cache url: %s", err)
}
options.MaxIdleConns = int(math.Ceil(float64(maxConn) * 0.5)) // 50% of max conns
options.MinIdleConns = int(math.Ceil(float64(maxConn) * 0.1)) // 10% of max conns
options.MaxActiveConns = maxConn
options.ConnMaxIdleTime = 30 * time.Minute
options.ConnMaxLifetime = 0
options.PoolTimeout = 10 * time.Second
redisClient := redis.NewClient(options)
subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, err = redisClient.Ping(subCtx).Result()
if err != nil {
return nil, err
}
log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr)
return redis_store.NewRedis(redisClient), nil
}

View File

@@ -3,53 +3,101 @@ package cache_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/eko/gocache/lib/v4/store"
"github.com/redis/go-redis/v9"
testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis"
"github.com/netbirdio/netbird/management/server/cache"
)
func assertGetDelConsumedOnce(ctx context.Context, t *testing.T, stores []cache.Store, key, value string) {
t.Helper()
const getDelAttempts = 64
type getDelResult struct {
value string
found bool
err error
func TestMemoryStore(t *testing.T) {
memStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
if err != nil {
t.Fatalf("couldn't create memory store: %s", err)
}
start := make(chan struct{})
results := make(chan getDelResult, getDelAttempts)
for i := range getDelAttempts {
cacheStore := stores[i%len(stores)]
go func() {
<-start
value, found, err := cacheStore.GetDel(ctx, key)
results <- getDelResult{value: value, found: found, err: err}
}()
ctx := context.Background()
key, value := "testing", "tested"
err = memStore.Set(ctx, key, value)
if err != nil {
t.Errorf("couldn't set testing data: %s", err)
}
close(start)
result, err := memStore.Get(ctx, key)
if err != nil {
t.Errorf("couldn't get testing data: %s", err)
}
if value != result.(string) {
t.Errorf("value returned doesn't match testing data, got %s, expected %s", result, value)
}
// test expiration
time.Sleep(300 * time.Millisecond)
_, err = memStore.Get(ctx, key)
if err == nil {
t.Error("value should not be found")
}
}
consumers := 0
for range getDelAttempts {
result := <-results
require.NoError(t, result.err, "concurrent GetDel failed")
if !result.found {
continue
func TestRedisStoreConnectionFailure(t *testing.T) {
t.Setenv(cache.RedisStoreEnvVar, "redis://127.0.0.1:6379")
_, err := cache.NewStore(context.Background(), 10*time.Millisecond, 30*time.Millisecond, 100)
if err == nil {
t.Fatal("getting redis cache store should return error")
}
}
func TestRedisStoreConnectionSuccess(t *testing.T) {
ctx := context.Background()
redisContainer, err := testcontainersredis.Run(ctx, "redis:7")
if err != nil {
t.Fatalf("couldn't start redis container: %s", err)
}
defer func() {
if err := redisContainer.Terminate(ctx); err != nil {
t.Logf("failed to terminate container: %s", err)
}
consumers++
require.Equal(t, value, result.value, "consumed value doesn't match testing data")
}()
redisURL, err := redisContainer.ConnectionString(ctx)
if err != nil {
t.Fatalf("couldn't get connection string: %s", err)
}
require.Equal(t, 1, consumers, "expected exactly one consumer")
}
func assertGetDelMisses(ctx context.Context, t *testing.T, cacheStore cache.Store, key string) {
t.Helper()
t.Setenv(cache.RedisStoreEnvVar, redisURL)
redisStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
if err != nil {
t.Fatalf("couldn't create redis store: %s", err)
}
value, found, err := cacheStore.GetDel(ctx, key)
require.NoError(t, err, "GetDel on a missing key should not error")
require.False(t, found, "GetDel should not find key %q, got value %q", key, value)
require.Empty(t, value, "GetDel should return an empty value when not found")
key, value := "testing", "tested"
err = redisStore.Set(ctx, key, value, store.WithExpiration(100*time.Millisecond))
if err != nil {
t.Errorf("couldn't set testing data: %s", err)
}
result, err := redisStore.Get(ctx, key)
if err != nil {
t.Errorf("couldn't get testing data: %s", err)
}
if value != result.(string) {
t.Errorf("value returned doesn't match testing data, got %s, expected %s", result, value)
}
options, err := redis.ParseURL(redisURL)
if err != nil {
t.Errorf("parsing redis cache url: %s", err)
}
redisClient := redis.NewClient(options)
r, e := redisClient.Get(ctx, key).Result()
if e != nil {
t.Errorf("couldn't get testing data from redis: %s", e)
}
if value != r {
t.Errorf("value returned from redis doesn't match testing data, got %s, expected %s", r, value)
}
// test expiration
time.Sleep(300 * time.Millisecond)
_, err = redisStore.Get(ctx, key)
if err == nil {
t.Error("value should not be found")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -110,6 +110,10 @@ message BundleParameters {
int64 bundle_for_time = 2;
int32 log_file_count = 3;
bool anonymize = 4;
// anonymize_level selects how much the anonymizer redacts: "default"
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
// Unknown values are treated as "strict".
string anonymize_level = 5;
}
message BundleResult {