Compare commits

..

12 Commits

Author SHA1 Message Date
Zoltán Papp
906fdf4bb5 Merge remote-tracking branch 'origin/main' into android/gui-integration
# Conflicts:
#	client/android/profile_state_test.go
2026-08-04 21:53:44 +02:00
Zoltán Papp
4263315527 [client] Read the extend flow's config and hint path in one lock
extendAuthSession took the config from stateSnapshot and the config path from a
second call, each acquiring the lock on its own. A profile switch landing
between the two swaps every field, which would authenticate with one profile's
config while reading the login hint from another profile's account file.

Replace configPathSnapshot with authSnapshot, which returns both from a single
critical section.
2026-07-30 21:00:44 +02:00
Zoltán Papp
56ff5237dd [client] Report the login's profile ID only when it is one
LoginResult.ProfileID was filled from the request's ProfileName, which is a
handle: a display name or an ID prefix resolve just as well. waitSSOLogin names
the state file after it, so a handle would have written the account email to a
file no reader looks for — the email silently lost, plus a stray file.

Fill it only on the branch where the daemon supplied the ID, and leave it empty
otherwise; waitSSOLogin then falls back to the active profile, as it did before
the field existed.
2026-07-30 20:51:40 +02:00
Zoltán Papp
3a17d0381c [client] Clear the removed profile's email by its resolved ID
RemoveProfile takes a handle — a display name or an ID prefix resolve just as
well as a full ID — but the state file holding the account email is named after
the ID. Passing the request handle straight through therefore named a
different file, or none, leaving the email behind for a recreated profile to
inherit.

The daemon already echoes back the ID it resolved for exactly this purpose;
use it.
2026-07-30 20:46:32 +02:00
Zoltán Papp
6155c94b05 [client] Reuse the profile's account for Android SSO logins
The Android binding never recorded which account a profile belongs to, so
every interactive login and every session extend went to the IdP with no
login_hint. With nothing to go on the IdP picks an account itself, which on a
session extend means re-authenticating an account the profile is already
signed in with.

Store the email the PKCE flow already parses out of the ID token, and pass it
back as the hint on later flows. An empty hint stays meaningful: a fresh
profile, or one that was logged out, deliberately leaves the choice to the
IdP, which is how a profile changes accounts. Logout clears the stored email
for that reason — while it is on disk it would steer the next login straight
back into the account just logged out of.

The email is keyed off the profile's config path rather than the active
profile: Auth.login runs in a goroutine, so the active profile can change
under a flow already in flight. It lands in <profile>.account.json, not the
<profile>.state.json desktop uses for the same data — there the email and the
engine's state manager sit in different directories, but on Android both
resolve under files/, and the state manager rewrites the whole file from its
own keys.
2026-07-30 20:34:08 +02:00
Zoltán Papp
09f7fb6510 [client] Drop the initial GetNetworkMap fetch on Android startup
Android startup opened a throwaway Sync stream to management before
creating the TUN device, only to learn the initial routes, DNS config
and the DNS feature flag. Server side this computed a full network map
and broadcast a false connect/disconnect pair to every peer in the
account on every Android start; client side it put a blocking network
round trip on the critical startup path and failed the whole engine
start when management was unreachable.

None of its outputs are needed upfront anymore: the TUN is created
empty and the first sync triggers a rebuild that pulls the fresh route
and search domain state, the permanent DNS server starts with an empty
config that the first sync populates, and the fake IP manager is
created lazily when the DNS feature flag turns on.

Remove readInitialSettings and its plumbing: the InitialRoutes and
DNSFeatureFlag manager config fields, the android construction-time
route setup, the initial-route bookkeeping in the notifiers and the
now-unused GetNetworkMap client method.
2026-07-30 20:19:41 +02:00
Zoltán Papp
4475819f38 [client] Pull fresh TUN settings on Android rebuild instead of pushing state
The Android TUN rebuild consumed state pushed through notifications and
a Java-side snapshot, and both sources were unreliable. The DNS
search-domain notifier fired OnNetworkChanged with an empty string,
which the rebuild handler treated as the new route list, so any search
domain change rebuilt the TUN with zero routes and cut all tunnel
traffic. The rebuild also reused the search domains cached at the last
establish, so search domain updates never reached the TUN at runtime.

Make the notification a pure trigger and let the Java side pull a fresh
snapshot instead. Expose GetTunSettings on the Android SDK client: it
returns the current TUN route ranges, derived on demand by the route
manager from the client routes, the exit-node selection and the fake IP
blocks, together with the DNS search domains. The route notifier keeps
only its last-announced baseline to suppress triggers for unchanged
syncs; the TUN route state is owned by the route manager. SearchDomains
now locks the DNS server mutex since the pull arrives from a Java
thread.

Requires the matching android-client change that switches recreateTUN
to the pull API.
2026-07-30 20:19:41 +02:00
Zoltán Papp
c8adaa45da [client] Serialize Android tunnel reconfiguration callbacks
The Android route notifier and the DNS search-domain notifier both
delivered OnNetworkChanged from a fire-and-forget goroutine per update.
Two updates in quick succession could reach the Java side reordered:
the TUN rebuild handler applies them in arrival order and compares
against the last applied parameters, so a stale route set delivered
last won as the final TUN state. This is the same reordering hazard
fixed for iOS in #6454.

Wrap the Android network change listener into the shared tunnelnotifier
FIFO introduced in #6870, the same way RunOniOS does, and deliver both
notifiers synchronously into it. Enqueueing is non-blocking, a single
delivery goroutine preserves order, and calls into Java never overlap.

Also stop hasRouteDiff from sorting the notifier's shared route slices
in place; compare sorted copies instead.
2026-07-30 20:19:41 +02:00
Zoltán Papp
e970daaf5f [client] Create the Android fake IP manager lazily on DNS flag enable
The fake IP manager was only created at route manager construction,
from the DNS feature flag fetched by the initial GetNetworkMap call.
When the flag flipped to true mid-session, UpdateRoutes set
useNewDNSRoute but never created the manager, so domain routes added
after the flip got a DNS interceptor with a nil fake IP manager.

internalDnatFw only checked for a firewall and GOOS, so the interceptor
took the DNAT path and called GetFakeIP/AllocateFakeIP on the nil
*fakeip.Manager. These methods lock m.mu first, which is a nil pointer
dereference: the first DNS answer for such a route panicked and crashed
the VPN service. The fake IP blocks (240.0.0.0/8 and its v6 pair) also
never reached the TUN, since only the constructor registered them.

Create the manager and its TUN routes from UpdateRoutes when the flag
turns on, notify so the fake IP blocks get into the TUN without a
client route change, and treat a nil manager as no internal DNAT.

This is groundwork for removing the initial GetNetworkMap fetch, after
which every startup goes through the flag-off-to-on transition.
2026-07-30 20:19:41 +02:00
Zoltán Papp
5ae323a555 [client] Delete the account email when a profile is removed
Removing a profile left its state file behind: the daemon deletes what it
owns, but the file holding the account email is user-owned and out of reach
for a root daemon, which is why Connection.Logout already clears it from the
UI side.

Beyond the stray file, legacy profiles are keyed by name rather than by a
generated ID, so recreating a profile under a removed one's name inherited
its email — shown as the account in the profile list and sent as the
login_hint on the next login.
2026-07-30 20:19:41 +02:00
Zoltán Papp
19337dc056 [client] File the account email against the profile the login ran for
SetActiveProfileState resolves the target itself, so it writes to whichever
profile is active when it is called. A GUI SSO login spans seconds of user
interaction in the browser, and the tray stays clickable throughout: switching
profiles in that window left the email filed under the profile that happened
to be active when the flow returned. The wrong profile then advertised an
account it does not own, and offered it as the login_hint next time.

Add SetProfileState(id, state), the write-side counterpart of the existing
GetProfileState(id), and keep SetActiveProfileState as a wrapper for callers
with no particular profile in mind. Login now reports the profile it resolved
so the frontend can hand it back with the SSO wait, which closes the window.
2026-07-30 20:19:41 +02:00
Zoltán Papp
fd06d9a3d5 [client] Store the account email after a GUI SSO login
The daemon returns the authenticated user's email from WaitSSOLogin but
cannot persist it: it runs as root while the per-profile state file is
user-owned. The CLI's handleSSOLogin writes it after its own WaitSSOLogin;
the GUI path read the value and dropped it.

The profile was therefore left with no email, so Profiles.List showed no
account for it, and later logins and session extends went out with no
login_hint — leaving the IdP to pick an account instead of reusing the one
the profile belongs to. Mirror the CLI and store it, next to the Logout
path that already clears the same file for the same reason.
2026-07-30 20:19:41 +02:00
154 changed files with 2533 additions and 19415 deletions

View File

@@ -475,132 +475,6 @@ jobs:
path: dist/ path: dist/
retention-days: 3 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: release_ui_darwin:
runs-on: macos-latest runs-on: macos-latest
outputs: outputs:
@@ -814,7 +688,7 @@ jobs:
comment_release_artifacts: comment_release_artifacts:
name: Comment release artifacts name: Comment release artifacts
runs-on: ubuntu-latest 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 }} if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
permissions: permissions:
contents: read contents: read
@@ -826,14 +700,12 @@ jobs:
env: env:
RELEASE_RESULT: ${{ needs.release.result }} RELEASE_RESULT: ${{ needs.release.result }}
RELEASE_UI_RESULT: ${{ needs.release_ui.result }} RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
RELEASE_UI_GTK3_RESULT: ${{ needs.release_ui_gtk3.result }}
RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }} RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }}
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }} RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }} LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_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 }} 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_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 }} RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }}
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }} GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
with: with:
@@ -856,7 +728,6 @@ jobs:
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT], ['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
['macOS packages', process.env.MACOS_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 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], ['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT],
]; ];
@@ -913,7 +784,7 @@ jobs:
trigger_signer: trigger_signer:
runs-on: ubuntu-latest 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/') if: startsWith(github.ref, 'refs/tags/')
steps: steps:
- name: Trigger binaries sign pipelines - name: Trigger binaries sign pipelines

View File

@@ -257,15 +257,6 @@ jobs:
with: with:
persist-credentials: false 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 - name: Verify Dex retirement notice
run: | run: |
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then

View File

@@ -96,7 +96,6 @@ nfpms:
- netbird (>= 0.75.0) - netbird (>= 0.75.0)
- libgtk-4-1 (>= 4.14) - libgtk-4-1 (>= 4.14)
- libwebkitgtk-6.0-4 - libwebkitgtk-6.0-4
- xdg-utils
- maintainer: Netbird <dev@netbird.io> - maintainer: Netbird <dev@netbird.io>
description: Netbird client UI. description: Netbird client UI.
@@ -120,7 +119,6 @@ nfpms:
- netbird >= 0.75.0 - netbird >= 0.75.0
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14) - (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
- (webkitgtk6.0 or libwebkitgtk-6_0-4) - (webkitgtk6.0 or libwebkitgtk-6_0-4)
- xdg-utils
rpm: rpm:
signature: signature:

View File

@@ -1,136 +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:
# Mutually-exclusive alternative to the GTK4 netbird-ui package -- both
# ship the same /usr/bin/netbird-ui from the shared stable/yum repos, so
# this one carries its own name and conflicts with the GTK4 package.
- 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-gtk3
file_name_template: "{{ .PackageName }}_{{ .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
conflicts:
- netbird-ui
replaces:
- netbird-ui
dependencies:
- netbird (>= 0.75.0)
- libgtk-3-0
- libwebkit2gtk-4.1-0
- xdg-utils
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_rpm_gtk3
package_name: netbird-ui-gtk3
file_name_template: "{{ .PackageName }}_{{ .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
# No `replaces` here: nfpm maps it to rpm Obsoletes, which would make
# dnf swap installed GTK4 netbird-ui packages for this one on upgrade.
conflicts:
- netbird-ui
dependencies:
- netbird >= 0.75.0
- (gtk3 or libgtk-3-0)
- (webkit2gtk4.1 or libwebkit2gtk-4_1-0)
- xdg-utils
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
# The GTK4 UI job shares project_name, so the default checksum file name would
# collide with it on the shared GitHub release.
checksum:
name_template: "{{ .ProjectName }}_gtk3_checksums.txt"
changelog:
disable: true
uploads:
- name: debian
skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_deb_gtk3
mode: archive
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
username: dev@wiretrustee.com
method: PUT
- name: yum
skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_rpm_gtk3
mode: archive
target: https://pkgs.wiretrustee.com/yum/{{ .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 ## Contents
- [STOP and ask the user before](#stop-and-ask-the-user-before) - [NetBird Agent Guidelines](#netbird-agent-guidelines)
- [Quick reference](#quick-reference) - [Contents](#contents)
- [Structure](#structure) - [STOP and ask the user before](#stop-and-ask-the-user-before)
- [Where to look](#where-to-look) - [Quick reference](#quick-reference)
- [Security](#security) - [Structure](#structure)
- [Agent conventions](#agent-conventions) - [Where to look](#where-to-look)
- [Repo-wide principles](#repo-wide-principles) - [Repo-wide principles](#repo-wide-principles)
- [Type safety](#type-safety) - [Error handling](#error-handling)
- [Concurrency and lifecycle](#concurrency-and-lifecycle) - [Comments](#comments)
- [Error handling](#error-handling) - [Testing](#testing)
- [Comments](#comments) - [Pitfalls](#pitfalls)
- [Testing](#testing) - [Commits, PRs, releases](#commits-prs-releases)
- [Pitfalls](#pitfalls) - [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
- [Commits, PRs, releases](#commits-prs-releases) - [Discussion and support](#discussion-and-support)
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
- [Discussion and support](#discussion-and-support)
## STOP and ask the user before ## STOP and ask the user before
@@ -159,125 +157,11 @@ netbird/
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` | | LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
| End-to-end tests | `e2e/` | | 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 ## Repo-wide principles
1. **Run `go fmt` on every modified Go file.** Formatting is not optional. 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 2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings on code you
you touch, and delete imports, helpers, and parameters your refactor orphaned. touch, and delete imports, helpers, and parameters your refactor orphaned.
Exception: unused parameters in shared code may be consumed by builds outside Exception: unused parameters in shared code may be consumed by builds outside
this repository — do not remove them, ask instead. this repository — do not remove them, ask instead.
3. **Function comments are mandatory for exported functions**, written as full 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 7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
prose, trailing summaries. Defaults, not absolute bans. Applies to code, prose, trailing summaries. Defaults, not absolute bans. Applies to code,
comments, commit messages, and PR descriptions alike. comments, commit messages, and PR descriptions alike.
8. **Concurrency: do a two-pass race analysis after every change** that touches 8. **Concurrency: do a two-pass race analysis after every change** that adds
shared state, including reads of existing maps and slices. Guard them with a shared state. Guard maps and slices with a mutex, keep critical sections
mutex (or an atomic or channel where that fits better), keep critical short, and run `go test -race` on the touched packages.
sections short, and run `go test -race` on the touched packages. See
[Concurrency and lifecycle](#concurrency-and-lifecycle) for the failure modes
to check for.
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS, 9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file, Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
add the counterpart or a build-tagged fallback for the others. 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 11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
keep peer IPs and hostnames out of logs above debug level. 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 ## Error handling
Use single-assignment form when the error is only needed inside the `if`: 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 - Close errors may be ignored for read-only operations; log them at debug for
writes. 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 ## Comments
Comment the **why**, never the **what**. Default to no comment, and add one only 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 ### Length budget
Neither of these is linter-enforced, so they are conventions the surrounding code - **90 characters per line.** Wrap the comment, do not run past it.
mostly follows rather than hard limits: - **250 characters per comment**, roughly three wrapped lines. Doc comments on
exported identifiers may exceed it when the API genuinely needs the
- **Around 90 characters per line.** Wrap the comment rather than running well past explanation; inline comments inside a function body may not.
it.
- **Roughly 250 characters per comment**, about three wrapped lines. Doc comments
on exported identifiers may exceed it when the API genuinely needs the
explanation; inline comments inside a function body rarely should.
The budget is a smell detector, not a rule to game. Do not compress a needed 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 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. otherwise.
- **Message guidance:** optional for `NoError`/`Error`; always give context for - **Message guidance:** optional for `NoError`/`Error`; always give context for
comparison, boolean, and collection assertions. 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 ```go
server, err := StartTestServer() 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, than replacing it with your own summary: describe the change, link the issue,
tick the checklist honestly (including "ran locally" and "single purpose"), tick the checklist honestly (including "ran locally" and "single purpose"),
and complete the documentation section. Do not tick a box you have not 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 verified, and do not delete rows that do not apply.
that section and fails when it is missing.
- **Keep the PR description short.** Under 1000 words on top of the template's - **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; 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 on their own. Propose that split to the user rather than opening one large PR
and hoping. 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 - **User-facing changes need a docs PR** in
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR [netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
description. description.

View File

@@ -112,7 +112,6 @@ aligns with our security standards and design expectations.
- [Test suite](#test-suite) - [Test suite](#test-suite)
- [Checklist before submitting a PR](#checklist-before-submitting-a-pr) - [Checklist before submitting a PR](#checklist-before-submitting-a-pr)
- [When we close a PR](#when-we-close-a-pr) - [When we close a PR](#when-we-close-a-pr)
- [Translations](#translations)
- [Other project repositories](#other-project-repositories) - [Other project repositories](#other-project-repositories)
- [Contributor License Agreement](#contributor-license-agreement) - [Contributor License Agreement](#contributor-license-agreement)
@@ -613,17 +612,6 @@ A closed PR is not a rejected idea. Take it back to the
[discussion](https://github.com/netbirdio/netbird/discussions), settle the [discussion](https://github.com/netbirdio/netbird/discussions), settle the
approach, and reopen the work from there. approach, and reopen the work from there.
## Translations
Desktop UI translations are not contributed through pull requests. Translate on
[Crowdin](https://crowdin.com/project/netbird) instead: no ticket needed, just
join the project and pick your language. Crowdin syncs with this repository and
opens the service PRs itself, so hand-edited locale files would conflict with
the next sync. Style, terminology, and review guidance live in
[client/ui/i18n/TRANSLATING.md](client/ui/i18n/TRANSLATING.md). To request a
language the project does not offer yet, ask on the Crowdin project page or in
a [discussion](https://github.com/netbirdio/netbird/discussions).
## Other project repositories ## Other project repositories
NetBird project is composed of 3 main repositories: NetBird project is composed of 3 main repositories:

View File

@@ -15,7 +15,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/debug"
@@ -33,13 +32,6 @@ import (
types "github.com/netbirdio/netbird/upload-server/types" types "github.com/netbirdio/netbird/upload-server/types"
) )
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
// anonymizeLevel values for DebugBundle.
const (
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
)
// ConnectionListener export internal Listener for mobile // ConnectionListener export internal Listener for mobile
type ConnectionListener interface { type ConnectionListener interface {
peer.Listener peer.Listener
@@ -286,10 +278,8 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
} }
// DebugBundle generates a debug bundle, uploads it, and returns the upload key. // DebugBundle generates a debug bundle, uploads it, and returns the upload key.
// It works both with and without a running engine. anonymizeLevel is "default" // It works both with and without a running engine.
// or "strict"; strict also anonymizes internal IP ranges, peer names, and func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
// WireGuard public keys, and implies anonymize.
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
cfg, cacheDir, cc := c.stateSnapshot() cfg, cacheDir, cc := c.stateSnapshot()
// If the engine hasn't been started, load config from disk // If the engine hasn't been started, load config from disk
@@ -308,7 +298,6 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
InternalConfig: cfg, InternalConfig: cfg,
StatusRecorder: c.recorder, StatusRecorder: c.recorder,
TempDir: cacheDir, TempDir: cacheDir,
StatePath: platformFiles.StateFilePath(),
} }
if cc != nil { if cc != nil {
@@ -332,7 +321,6 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
deps, deps,
debug.BundleConfig{ debug.BundleConfig{
Anonymize: anonymize, Anonymize: anonymize,
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
IncludeSystemInfo: true, IncludeSystemInfo: true,
}, },
) )

View File

@@ -1,829 +0,0 @@
//go:build android
package android
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/ssh/detection"
)
const (
sshDialTimeout = 30 * time.Second
sshDetectionTimeout = 5 * time.Second
)
// PasswordRequiredMarker tells Java to prompt for a password and retry. It is
// a string because gomobile flattens errors to their message, so a sentinel
// value would not survive the binding.
const PasswordRequiredMarker = "netbird-ssh-password-required"
// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation,
// retry with TrustHostKey set. The presented fingerprint is appended after the
// marker so the prompt can display it and the retry can guard against a key
// that changed between the two connects. Only regular (non-NetBird) servers
// reach this: NetBird peers verify against the registry.
const HostKeyUnknownMarker = "netbird-ssh-hostkey-unknown"
var (
errPasswordRequired = errors.New(PasswordRequiredMarker)
errClientClosed = errors.New("ssh client closed")
)
// errHostKeyUnknown carries the presented fingerprint so Connect can build the
// marker message the Java side parses.
type errHostKeyUnknown struct {
fingerprint string
}
func (e *errHostKeyUnknown) Error() string {
return HostKeyUnknownMarker + ":" + e.fingerprint
}
// engineHostKeyVerifier adapts *internal.Engine to nbssh.HostKeyVerifier.
type engineHostKeyVerifier struct {
engine *internal.Engine
}
func (v *engineHostKeyVerifier) VerifySSHHostKey(peerAddress string, presented []byte) error {
storedKey, found := v.engine.GetPeerSSHKey(peerAddress)
if !found {
return nbssh.ErrPeerNotFound
}
return nbssh.VerifyHostKey(storedKey, presented, peerAddress)
}
// SSHTerminalListener receives SSH session events. It is implemented in Java.
//
// All callbacks are invoked from goroutines and may run concurrently with each
// other; the implementation must be safe to call from any thread.
type SSHTerminalListener interface {
OnConnected()
OnData(data []byte)
OnClose(reason string)
OnError(message string)
}
// SSHClient is a NetBird-aware SSH client exposed to Java via gomobile.
//
// It dials through the running NetBird tunnel and runs a standard SSH session
// on top with PTY enabled. Host-key verification uses the NetBird-provided
// peer SSH host keys, identical to the desktop client.
type SSHClient struct {
nb *Client
mu sync.Mutex
listener SSHTerminalListener
urlOpener URLOpener
sshClient *gossh.Client
session *gossh.Session
stdin io.WriteCloser
closed bool
// gen identifies the current connection attempt. Connect and Close bump it,
// so an in-flight dial or a reader left over from a previous connection
// finds itself stale and stays silent instead of publishing OnConnected or
// OnClose for a connection the caller already abandoned.
gen uint64
dialCancel context.CancelFunc
// knownHostsPath is the TOFU store for regular SSH servers. Java supplies a
// per-profile path, since an overlay IP is a different host under a
// different profile. Empty until set: without it a regular server cannot be
// verified and Connect refuses one.
knownHostsPath string
// trustHostKey carries the fingerprint the user confirmed on a previous
// attempt, so the retry accepts exactly that key and persists it.
trustHostKey string
}
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
func NewSSHClient(c *Client) *SSHClient {
return &SSHClient{nb: c}
}
// SetListener registers the Java listener. Must be called before Connect to
// receive any events.
func (s *SSHClient) SetListener(l SSHTerminalListener) {
s.mu.Lock()
s.listener = l
s.mu.Unlock()
}
// SetURLOpener registers the Java URL opener used to display the device-code
// authorization page in a Custom Tabs window when the target peer requires
// JWT authentication. Must be set before Connect to be effective.
func (s *SSHClient) SetURLOpener(opener URLOpener) {
s.mu.Lock()
s.urlOpener = opener
s.mu.Unlock()
}
// SetKnownHostsPath points the TOFU host-key store at a per-profile file. Must
// be set before connecting to a regular SSH server; without it such a server
// cannot be verified and Connect refuses one.
func (s *SSHClient) SetKnownHostsPath(path string) {
s.mu.Lock()
s.knownHostsPath = path
s.mu.Unlock()
}
// TrustHostKey records the fingerprint the user confirmed for a regular server,
// so the next Connect accepts that exact key and adds it to the known-hosts
// store. Passing a fingerprint that no longer matches makes the connect fail
// rather than trust a key that changed since the prompt.
func (s *SSHClient) TrustHostKey(fingerprint string) {
s.mu.Lock()
s.trustHostKey = fingerprint
s.mu.Unlock()
}
// Connect dials the SSH server through the NetBird tunnel and performs the
// SSH handshake. It auto-detects the server type via SSH banner inspection
// and selects the appropriate authentication path:
//
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
// flow, opens the verification URL through the registered URLOpener, and
// uses the resulting token as the SSH password. Host-key verification
// uses the NetBird peer registry.
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
// private key. Host-key verification uses the NetBird peer registry.
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
// first (so a user-installed NetBird public key works), then falls back
// to the supplied password if non-empty. Host-key verification is
// trust-on-first-use against the per-profile known-hosts store.
//
// The password parameter is only consulted for regular SSH servers.
func (s *SSHClient) Connect(host string, port int, user, password string) error {
if port < 1 || port > 65535 {
return fmt.Errorf("invalid port: %d", port)
}
cfg, _, cc := s.nb.stateSnapshot()
if cc == nil {
return errors.New("netbird client not running")
}
if cfg == nil {
return errors.New("netbird config not loaded")
}
engine := cc.Engine()
if engine == nil {
return errors.New("netbird engine not available")
}
s.mu.Lock()
s.gen++
gen := s.gen
s.mu.Unlock()
serverType := detectServerType(host, port)
log.Debugf("SSH server type: %s", serverType)
authMethods, hostKeyCallback, err := s.buildAuth(cfg, engine, serverType, password)
if err != nil {
return err
}
clientConfig := &gossh.ClientConfig{
User: user,
Auth: authMethods,
HostKeyCallback: hostKeyCallback,
Timeout: sshDialTimeout,
}
err = s.dialAndHandshake(gen, host, port, clientConfig)
// An unknown host key is a prompt, not a failure: return the marker intact
// (rootCause would unwrap it) so Java can show the fingerprint and retry.
var unknownHost *errHostKeyUnknown
if errors.As(err, &unknownHost) {
return errors.New(unknownHost.Error())
}
// A regular server may still accept a password, so let the caller ask for
// one instead of failing. NetBird servers never use a password, so a
// failure there is genuine.
if err != nil && serverType != detection.ServerTypeNetBirdJWT &&
serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) &&
passwordCouldHelp(err, password != "") {
return errPasswordRequired
}
if err != nil {
return rootCause(err)
}
return nil
}
// StartSession requests a PTY and starts an interactive shell. Output from
// the session is forwarded to the listener via OnData.
func (s *SSHClient) StartSession(cols, rows int) error {
err := s.startSession(cols, rows)
if err != nil {
log.Infof("SSH: start session failed: %v", err)
return rootCause(err)
}
return nil
}
// Write sends data to the SSH session stdin.
func (s *SSHClient) Write(data []byte) error {
s.mu.Lock()
stdin := s.stdin
s.mu.Unlock()
if stdin == nil {
return errors.New("ssh session not started")
}
if _, err := stdin.Write(data); err != nil {
return fmt.Errorf("write stdin: %w", err)
}
return nil
}
// Resize updates the PTY window size.
func (s *SSHClient) Resize(cols, rows int) error {
s.mu.Lock()
session := s.session
s.mu.Unlock()
if session == nil {
return errors.New("ssh session not started")
}
return session.WindowChange(rows, cols)
}
// Reset makes a closed client usable for another Connect: Close leaves the
// one-shot guard set, and clearing it lets the same client back a reconnect.
func (s *SSHClient) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.closed = false
}
// Close terminates the SSH session and underlying connection. Safe to call
// multiple times.
func (s *SSHClient) Close() error {
s.mu.Lock()
s.gen++
if s.dialCancel != nil {
s.dialCancel()
s.dialCancel = nil
}
sshClient := s.sshClient
session := s.session
stdin := s.stdin
s.sshClient = nil
s.session = nil
s.stdin = nil
notify := !s.closed
s.closed = true
listener := s.listener
s.mu.Unlock()
if stdin != nil {
if err := stdin.Close(); err != nil {
log.Debugf("ssh: stdin close: %v", err)
}
}
if session != nil {
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
log.Debugf("ssh: session close: %v", err)
}
}
var firstErr error
if sshClient != nil {
if err := sshClient.Close(); err != nil {
firstErr = err
}
}
if notify && listener != nil {
listener.OnClose("closed by client")
}
return firstErr
}
func (s *SSHClient) startSession(cols, rows int) error {
log.Debugf("SSH: starting session %dx%d", cols, rows)
s.mu.Lock()
sshClient := s.sshClient
gen := s.gen
s.mu.Unlock()
if sshClient == nil {
return errors.New("ssh client not connected")
}
session, err := sshClient.NewSession()
if err != nil {
return fmt.Errorf("new session: %w", err)
}
modes := gossh.TerminalModes{
gossh.ECHO: 1,
gossh.TTY_OP_ISPEED: 14400,
gossh.TTY_OP_OSPEED: 14400,
gossh.VINTR: 3,
gossh.VQUIT: 28,
gossh.VERASE: 127,
}
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
closeQuiet(session, "session after pty error")
return fmt.Errorf("request pty: %w", err)
}
stdin, err := session.StdinPipe()
if err != nil {
closeQuiet(session, "session after stdin error")
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
closeQuiet(session, "session after stdout error")
return fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := session.StderrPipe()
if err != nil {
closeQuiet(session, "session after stderr error")
return fmt.Errorf("stderr pipe: %w", err)
}
if err := session.Shell(); err != nil {
closeQuiet(session, "session after shell error")
return fmt.Errorf("start shell: %w", err)
}
s.mu.Lock()
if gen != s.gen {
s.mu.Unlock()
closeQuiet(session, "stale session")
return errClientClosed
}
s.session = session
s.stdin = stdin
s.mu.Unlock()
readerDone := make(chan string, 2)
go func() { readerDone <- s.readLoop(stdout, "stdout") }()
go func() { readerDone <- s.readLoop(stderr, "stderr") }()
go func() {
reason := <-readerDone
if second := <-readerDone; reason == "" {
reason = second
}
s.notifyClose(gen, reason)
}()
log.Debug("SSH: session started, shell running")
return nil
}
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, engine *internal.Engine,
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
switch serverType {
case detection.ServerTypeNetBirdJWT:
token, err := s.requestJWTToken(cfg)
if err != nil {
return nil, nil, fmt.Errorf("jwt: %w", err)
}
auths := []gossh.AuthMethod{gossh.Password(token)}
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
case detection.ServerTypeNetBirdNoJWT:
if cfg.SSHKey == "" {
return nil, nil, errors.New("no NetBird SSH key available")
}
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
if err != nil {
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
}
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
case detection.ServerTypeRegular:
var auths []gossh.AuthMethod
if cfg.SSHKey != "" {
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
auths = append(auths, gossh.PublicKeys(signer))
} else {
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
}
}
if password != "" {
pw := password
auths = append(auths, gossh.Password(pw))
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
answers[i] = pw
}
return answers, nil
}))
}
if len(auths) == 0 {
// Nothing to offer at all: ask for a password rather than failing,
// so the caller can retry once the user supplies one.
return nil, nil, errPasswordRequired
}
callback, err := s.tofuHostKeyCallback()
if err != nil {
return nil, nil, err
}
return auths, callback, nil
default:
return nil, nil, fmt.Errorf("unsupported SSH server type: %v", serverType)
}
}
// tofuHostKeyCallback verifies a regular server's host key against the
// per-profile known-hosts file. An unknown host returns errHostKeyUnknown so
// Java can show the fingerprint and, once confirmed, retry with the key
// trusted; a changed key is rejected outright, as OpenSSH does. When the user
// has confirmed a fingerprint, the callback accepts exactly that key and
// appends it to the store.
func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) {
s.mu.Lock()
path := s.knownHostsPath
trusted := s.trustHostKey
s.mu.Unlock()
if path == "" {
return nil, errors.New("no known-hosts store configured for regular SSH")
}
if err := ensureFileExists(path); err != nil {
return nil, fmt.Errorf("prepare known-hosts store: %w", err)
}
known, err := knownhosts.New(path)
if err != nil {
return nil, fmt.Errorf("load known-hosts store: %w", err)
}
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
err := known(hostname, remote, key)
if err == nil {
return nil
}
var keyErr *knownhosts.KeyError
if !errors.As(err, &keyErr) {
return err
}
// Want holds the keys already stored for this host: non-empty means the
// presented key replaced a known one, which TOFU must never accept
// silently.
if len(keyErr.Want) > 0 {
return fmt.Errorf("SSH host key changed for %s (possible attack)", hostname)
}
fingerprint := gossh.FingerprintSHA256(key)
if trusted == "" {
return &errHostKeyUnknown{fingerprint: fingerprint}
}
if trusted != fingerprint {
return fmt.Errorf("SSH host key changed since it was confirmed for %s", hostname)
}
if err := appendKnownHost(path, hostname, remote, key); err != nil {
return fmt.Errorf("persist trusted host key: %w", err)
}
// The confirmation is spent: now that the key is stored, a later
// reconnect must verify against the file, not re-accept this fingerprint.
s.mu.Lock()
s.trustHostKey = ""
s.mu.Unlock()
return nil
}, nil
}
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) {
s.mu.Lock()
urlOpener := s.urlOpener
s.mu.Unlock()
if urlOpener == nil {
return "", errors.New("URL opener not configured for JWT auth")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profilemanager.GetLoginHint())
if err != nil {
return "", fmt.Errorf("create oauth flow: %w", err)
}
flowInfo, err := flow.RequestAuthInfo(ctx)
if err != nil {
return "", fmt.Errorf("request auth info: %w", err)
}
// Called synchronously: Open is what marks the surface as opened on the
// client side, and OnLoginSuccess below is a no-op until it has. Starting
// both in their own goroutines let them race, so a fast token left the
// browser in front of the terminal.
urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
// WaitToken blocks for as long as the browser round-trip takes, so say so
// rather than leaving the terminal blank.
s.notifyStatus("Waiting for browser authentication...")
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
if err != nil {
return "", fmt.Errorf("wait for token: %w", err)
}
token := tokenInfo.GetTokenToUse()
if token == "" {
return "", errors.New("empty token returned by IdP")
}
// Tells the client the browser round-trip is over so it can dismiss the
// surface it opened, the same way the login and session-extend flows do.
// Without it the Custom Tab stays in front of the terminal even though the
// token has already been collected.
urlOpener.OnLoginSuccess()
return token, nil
}
func (s *SSHClient) dialAndHandshake(gen uint64, host string, port int, clientConfig *gossh.ClientConfig) error {
addr := net.JoinHostPort(host, strconv.Itoa(port))
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
defer cancel()
s.mu.Lock()
if gen != s.gen {
s.mu.Unlock()
return errClientClosed
}
s.dialCancel = cancel
s.mu.Unlock()
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
// DialContext bounds only the TCP establishment; without a deadline on the
// socket a peer that accepts and then goes silent blocks the handshake
// forever.
if deadline, ok := ctx.Deadline(); ok {
if err := conn.SetDeadline(deadline); err != nil {
closeQuiet(conn, "conn after deadline error")
return fmt.Errorf("set handshake deadline: %w", err)
}
}
sshConn, chans, reqs, err := gossh.NewClientConn(conn, addr, clientConfig)
if err != nil {
if cerr := conn.Close(); cerr != nil {
log.Debugf("ssh: close after handshake error: %v", cerr)
}
return fmt.Errorf("ssh handshake: %w", err)
}
if err := conn.SetDeadline(time.Time{}); err != nil {
closeQuiet(sshConn, "ssh conn after deadline clear error")
return fmt.Errorf("clear handshake deadline: %w", err)
}
client := gossh.NewClient(sshConn, chans, reqs)
s.mu.Lock()
if gen != s.gen {
s.mu.Unlock()
closeQuiet(client, "stale ssh client")
return errClientClosed
}
s.sshClient = client
listener := s.listener
s.mu.Unlock()
if listener != nil {
listener.OnConnected()
}
return nil
}
func (s *SSHClient) readLoop(r io.Reader, name string) string {
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
if n > 0 {
s.mu.Lock()
listener := s.listener
s.mu.Unlock()
if listener != nil {
chunk := make([]byte, n)
copy(chunk, buf[:n])
listener.OnData(chunk)
}
}
if err != nil {
// EOF is a normal shell exit, so report it without a reason.
if errors.Is(err, io.EOF) {
return ""
}
log.Debugf("ssh %s read: %v", name, err)
return rootCause(err).Error()
}
}
}
// notifyStatus writes a progress line to the terminal through the normal
// output path, so long steps are visible while nothing else is arriving.
func (s *SSHClient) notifyStatus(text string) {
s.mu.Lock()
listener := s.listener
s.mu.Unlock()
if listener != nil {
listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n"))
}
}
func (s *SSHClient) notifyClose(gen uint64, reason string) {
s.mu.Lock()
if gen != s.gen || s.closed {
s.mu.Unlock()
return
}
s.closed = true
listener := s.listener
s.mu.Unlock()
if listener != nil {
listener.OnClose(reason)
}
}
// RemoveKnownHost deletes every known_hosts entry for host:port from the store,
// so a host trusted for a session that is being deleted does not linger. Java
// calls this only once no session targets that host, so a shared host stays
// trusted. Missing file or entry is not an error: the goal state is "absent".
func RemoveKnownHost(path, host string, port int) error {
target := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var kept []string
changed := false
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := scanner.Text()
if knownHostsLineMatches(line, target) {
changed = true
continue
}
kept = append(kept, line)
}
if err := scanner.Err(); err != nil {
return err
}
if !changed {
return nil
}
out := strings.Join(kept, "\n")
if len(kept) > 0 {
out += "\n"
}
return os.WriteFile(path, []byte(out), 0o600)
}
func closeQuiet(c io.Closer, label string) {
if c == nil {
return
}
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
log.Debugf("ssh: close %s: %v", label, err)
}
}
func detectServerType(host string, port int) detection.ServerType {
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
defer cancel()
dialer := &net.Dialer{}
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
if err != nil {
log.Debugf("ssh: server detection failed: %v (assuming regular SSH)", err)
return detection.ServerTypeRegular
}
return serverType
}
// rootCause returns the innermost error of a %w chain, so the terminal shows
// "i/o timeout" rather than every layer that added context on the way up.
func rootCause(err error) error {
for {
// A joined error has no single root, so keep it as-is.
if _, ok := err.(interface{ Unwrap() []error }); ok {
return err
}
next := errors.Unwrap(err)
if next == nil {
return err
}
err = next
}
}
// ensureFileExists creates an empty known-hosts file when none exists yet, so
// knownhosts.New has something to parse on the first connection to any host.
func ensureFileExists(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
return f.Close()
}
// appendKnownHost adds the confirmed key to the store in the standard
// known_hosts format, so it verifies silently on later connections and can be
// inspected or edited like any OpenSSH known_hosts file.
func appendKnownHost(path, hostname string, remote net.Addr, key gossh.PublicKey) error {
addresses := []string{knownhosts.Normalize(hostname)}
if remote != nil {
if normalized := knownhosts.Normalize(remote.String()); normalized != addresses[0] {
addresses = append(addresses, normalized)
}
}
line := knownhosts.Line(addresses, key)
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil {
log.Debugf("ssh: close known-hosts after append: %v", cerr)
}
}()
_, err = f.WriteString(line + "\n")
return err
}
// knownHostsLineMatches reports whether a known_hosts line's address list
// contains the normalized target. Comment and blank lines never match.
func knownHostsLineMatches(line, target string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return false
}
fields := strings.Fields(trimmed)
if len(fields) == 0 {
return false
}
for _, addr := range strings.Split(fields[0], ",") {
if addr == target {
return true
}
}
return false
}
// isAuthFailure distinguishes credential rejection from dial, timeout and
// host-key errors, which retrying with a password would not fix.
func isAuthFailure(err error) bool {
if errors.Is(err, errPasswordRequired) {
return true
}
var partial *gossh.PartialSuccessError
if errors.As(err, &partial) {
return true
}
return strings.Contains(err.Error(), "unable to authenticate")
}
// passwordCouldHelp reports whether prompting for a password again can change
// the outcome. gossh lists a method under "attempted methods" only when the
// server offered it, so a supplied password that was never attempted means the
// server does not accept passwords and the real error should surface instead.
func passwordCouldHelp(err error, passwordOffered bool) bool {
if !passwordOffered {
return true
}
msg := err.Error()
return strings.Contains(msg, "password") || strings.Contains(msg, "keyboard-interactive")
}

View File

@@ -2,7 +2,6 @@ package anonymize
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64"
"fmt" "fmt"
"math/big" "math/big"
"net" "net"
@@ -16,88 +15,13 @@ import (
const anonTLD = ".domain" const anonTLD = ".domain"
// Level selects how much the anonymizer redacts. Levels are ordered: a higher
// level redacts strictly more. On the wire (protos, flags) levels travel as
// their string form.
type Level int
const (
// LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC
// addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are
// preserved so support can reason about the real topology.
LevelDefault Level = iota
// LevelStrict additionally anonymizes internal IP ranges, peer names, and
// WireGuard public keys.
LevelStrict
)
// LevelDefaultString and LevelStrictString are the wire forms of the levels,
// for boundaries that pass levels as strings (flags, protos, mobile bindings).
const (
LevelDefaultString = "default"
LevelStrictString = "strict"
)
// ParseLevel maps s to a Level. Empty means LevelDefault; anything
// unrecognized maps to LevelStrict so an unknown request never yields less
// anonymization than intended.
func ParseLevel(s string) Level {
switch strings.ToLower(s) {
case "", LevelDefaultString:
return LevelDefault
default:
return LevelStrict
}
}
// String returns the wire form of the level: "default" or "strict".
func (l Level) String() string {
if l >= LevelStrict {
return LevelStrictString
}
return LevelDefaultString
}
// protectedDomains are NetBird-operated suffixes that stay recognizable in an
// anonymized bundle. At LevelStrict the labels in front of them (the peer
// name) are still replaced, except under netbird.io, which only hosts
// NetBird infrastructure (api, signal, flow), never peer names.
var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"}
const infraDomain = "netbird.io"
var (
macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`)
macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`)
wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`)
)
type Anonymizer struct { type Anonymizer struct {
ipAnonymizer map[netip.Addr]netip.Addr ipAnonymizer map[netip.Addr]netip.Addr
domainAnonymizer map[string]string domainAnonymizer map[string]string
// domainOrder caches the keys of domainAnonymizer sorted longest-first currentAnonIPv4 netip.Addr
// for AnonymizeString; it is rebuilt when the map gains entries. currentAnonIPv6 netip.Addr
domainOrder []string startAnonIPv4 netip.Addr
labelAnonymizer map[string]string startAnonIPv6 netip.Addr
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 domainKeyRegex *regexp.Regexp
} }
@@ -108,50 +32,25 @@ func DefaultAddresses() (netip.Addr, netip.Addr) {
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::") 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 { func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
internalIPv4, internalIPv6 := InternalAddresses()
return &Anonymizer{ return &Anonymizer{
ipAnonymizer: map[netip.Addr]netip.Addr{}, ipAnonymizer: map[netip.Addr]netip.Addr{},
domainAnonymizer: map[string]string{}, 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, currentAnonIPv4: startIPv4,
currentAnonIPv6: startIPv6, currentAnonIPv6: startIPv6,
startAnonIPv4: startIPv4, startAnonIPv4: startIPv4,
startAnonIPv6: startIPv6, startAnonIPv6: startIPv6,
level: LevelDefault,
currentAnonInternalIPv4: internalIPv4,
currentAnonInternalIPv6: internalIPv6,
startAnonInternalIPv4: internalIPv4,
startAnonInternalIPv6: internalIPv6,
domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`), 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 { 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() || if ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
(ip.Is4() && ip.IsPrivate()) ||
ip.IsUnspecified() || ip.IsUnspecified() ||
ip.IsMulticast() || ip.IsMulticast() ||
isWellKnown(ip) || isWellKnown(ip) ||
@@ -160,100 +59,18 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
return ip return ip
} }
if isInternal(ip) && a.level < LevelStrict {
return ip
}
if _, ok := a.ipAnonymizer[ip]; !ok { if _, ok := a.ipAnonymizer[ip]; !ok {
a.ipAnonymizer[ip] = a.nextAnonIP(ip) if ip.Is4() {
a.ipAnonymizer[ip] = a.currentAnonIPv4
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
} else {
a.ipAnonymizer[ip] = a.currentAnonIPv6
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
}
} }
return a.ipAnonymizer[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 { func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
// Convert IP to netip.Addr // Convert IP to netip.Addr
ip, ok := netip.AddrFromSlice(addr.IP) ip, ok := netip.AddrFromSlice(addr.IP)
@@ -272,12 +89,12 @@ func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
// isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs // isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs
func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool { func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
if ip.Is4() { if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 {
return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) || return true
inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4) } else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 {
return true
} }
return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) || return false
inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6)
} }
func (a *Anonymizer) AnonymizeIPString(ip string) string { func (a *Anonymizer) AnonymizeIPString(ip string) string {
@@ -301,17 +118,14 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
baseDomain = domain[:len(domain)-1] baseDomain = domain[:len(domain)-1]
} }
if strings.HasSuffix(baseDomain, anonTLD) { 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) {
return domain 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, ".") parts := strings.Split(baseDomain, ".")
if len(parts) < 2 { if len(parts) < 2 {
return domain return domain
@@ -327,53 +141,12 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
} }
result := strings.Replace(baseDomain, baseForLookup, anonymized, 1) result := strings.Replace(baseDomain, baseForLookup, anonymized, 1)
if a.level >= LevelStrict && len(parts) > 2 { if hasDot {
prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup) result += "."
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 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 { func (a *Anonymizer) AnonymizeURI(uri string) string {
u, err := url.Parse(uri) u, err := url.Parse(uri)
if err != nil { if err != nil {
@@ -408,70 +181,16 @@ func (a *Anonymizer) AnonymizeString(str string) string {
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
for _, domain := range a.sortedDomains() { for domain, anonDomain := range a.domainAnonymizer {
str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain]) str = strings.ReplaceAll(str, domain, anonDomain)
} }
str = a.AnonymizeSchemeURI(str) str = a.AnonymizeSchemeURI(str)
str = a.AnonymizeDNSLogLine(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 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. // AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes.
func (a *Anonymizer) AnonymizeSchemeURI(text string) string { func (a *Anonymizer) AnonymizeSchemeURI(text string) string {
re := regexp.MustCompile(`(?i)\b(wss?://|rels?://|stuns?:|turns?:|https?://)\S+\b`) re := regexp.MustCompile(`(?i)\b(wss?://|rels?://|stuns?:|turns?:|https?://)\S+\b`)
@@ -520,79 +239,10 @@ func isWellKnown(addr netip.Addr) bool {
"128.0.0.0", "8000::", // 2nd split subnet for default routes "128.0.0.0", "8000::", // 2nd split subnet for default routes
} }
return slices.Contains(wellKnown, addr.String()) if 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 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}) cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0})
cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10) cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10)

View File

@@ -1,11 +1,8 @@
package anonymize_test package anonymize_test
import ( import (
"bytes"
"encoding/base64"
"net/netip" "net/netip"
"regexp" "regexp"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -47,301 +44,6 @@ 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) { func TestAnonymizeDNSLogLine(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{}) anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{})
tests := []struct { tests := []struct {

View File

@@ -27,8 +27,8 @@ import (
const errCloseConnection = "Failed to close connection: %v" const errCloseConnection = "Failed to close connection: %v"
var ( var (
logFileCount uint32 logFileCount uint32
systemInfoFlag bool systemInfoFlag bool
uploadBundleFlag bool uploadBundleFlag bool
uploadBundleURLFlag string uploadBundleURLFlag string
uploadBundleInsecureFlag bool uploadBundleInsecureFlag bool
@@ -156,11 +156,6 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
// request. Returns an error if the RPC fails or if the daemon reports // request. Returns an error if the RPC fails or if the daemon reports
// an upload failure reason. // an upload failure reason.
func debugBundle(cmd *cobra.Command, _ []string) error { func debugBundle(cmd *cobra.Command, _ []string) error {
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
if err != nil {
return err
}
conn, err := getClient(cmd) conn, err := getClient(cmd)
if err != nil { if err != nil {
return err return err
@@ -173,11 +168,10 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
client := proto.NewDaemonServiceClient(conn) client := proto.NewDaemonServiceClient(conn)
request := &proto.DebugBundleRequest{ request := &proto.DebugBundleRequest{
Anonymize: anonymizeEnabled, Anonymize: anonymizeFlag,
AnonymizeLevel: anonymizeLevel.String(), SystemInfo: systemInfoFlag,
SystemInfo: systemInfoFlag, LogFileCount: logFileCount,
LogFileCount: logFileCount, CliVersion: version.NetbirdVersion(),
CliVersion: version.NetbirdVersion(),
} }
if uploadBundleFlag { if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag request.UploadURL = uploadBundleURLFlag
@@ -235,11 +229,6 @@ func runForDuration(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid duration format: %v", err) return fmt.Errorf("invalid duration format: %v", err)
} }
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
if err != nil {
return err
}
conn, err := getClient(cmd) conn, err := getClient(cmd)
if err != nil { if err != nil {
return err return err
@@ -379,11 +368,10 @@ func runForDuration(cmd *cobra.Command, args []string) error {
cmd.Println("Creating debug bundle...") cmd.Println("Creating debug bundle...")
request := &proto.DebugBundleRequest{ request := &proto.DebugBundleRequest{
Anonymize: anonymizeEnabled, Anonymize: anonymizeFlag,
AnonymizeLevel: anonymizeLevel.String(), SystemInfo: systemInfoFlag,
SystemInfo: systemInfoFlag, LogFileCount: logFileCount,
LogFileCount: logFileCount, CliVersion: version.NetbirdVersion(),
CliVersion: version.NetbirdVersion(),
} }
if uploadBundleFlag { if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag request.UploadURL = uploadBundleURLFlag

View File

@@ -21,7 +21,6 @@ import (
"github.com/spf13/pflag" "github.com/spf13/pflag"
"google.golang.org/grpc" "google.golang.org/grpc"
"github.com/netbirdio/netbird/client/anonymize"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/profilemanager"
) )
@@ -70,7 +69,6 @@ var (
autoConnectDisabled bool autoConnectDisabled bool
extraIFaceBlackList []string extraIFaceBlackList []string
anonymizeFlag bool anonymizeFlag bool
anonymizeLevelFlag string
dnsRouteInterval time.Duration dnsRouteInterval time.Duration
// lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection // 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). // flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead).
@@ -158,8 +156,7 @@ func init() {
rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file") 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().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().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device")
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().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output")
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.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location")
rootCmd.AddCommand(upCmd) rootCmd.AddCommand(upCmd)
@@ -296,19 +293,6 @@ var CLIBackOffSettings = &backoff.ExponentialBackOff{
Clock: backoff.SystemClock, 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) { func getSetupKey() (string, error) {
if setupKeyPath != "" && setupKey == "" { if setupKeyPath != "" && setupKey == "" {
return getSetupKeyFromFile(setupKeyPath) return getSetupKeyFromFile(setupKeyPath)

View File

@@ -121,14 +121,8 @@ func statusFunc(cmd *cobra.Command, args []string) error {
sessionExpiresAt = ts.AsTime().UTC() sessionExpiresAt = ts.AsTime().UTC()
} }
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
if err != nil {
return err
}
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{ var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
Anonymize: anonymizeEnabled, Anonymize: anonymizeFlag,
AnonymizeLevel: anonymizeLevel,
DaemonVersion: resp.GetDaemonVersion(), DaemonVersion: resp.GetDaemonVersion(),
DaemonStatus: nbstatus.ParseDaemonStatus(status), DaemonStatus: nbstatus.ParseDaemonStatus(status),
StatusFilter: statusFilter, StatusFilter: statusFilter,

View File

@@ -2,8 +2,8 @@
// its wg interface into firewalld's "trusted" zone. This is required because // its wg interface into firewalld's "trusted" zone. This is required because
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent // firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
// versions, which returns EPERM to any other process that tries to insert // versions, which returns EPERM to any other process that tries to insert
// rules into them. Trusting the interface makes firewalld itself add the // rules into them. The workaround mirrors what Tailscale does: let firewalld
// accept rules to its own chains instead. // itself add the accept rules to its own chains by trusting the interface.
package firewalld package firewalld
// TrustedZone is the firewalld zone name used for interfaces whose traffic // TrustedZone is the firewalld zone name used for interfaces whose traffic

View File

@@ -22,16 +22,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net" nbnet "github.com/netbirdio/netbird/client/net"
) )
const (
// wgMsgTypeHandshakeInitiation is the lowest WireGuard message type.
wgMsgTypeHandshakeInitiation uint32 = 1
// wgMsgTypeTransport is the highest WireGuard message type.
wgMsgTypeTransport uint32 = 4
// wgMinMsgSize is the smallest WireGuard message: transport data with an empty
// payload, which is what a keepalive is.
wgMinMsgSize = 32
)
type receiverCreator struct { type receiverCreator struct {
iceBind *ICEBind iceBind *ICEBind
} }
@@ -226,15 +216,8 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO
for i := 0; i < numMsgs; i++ { for i := 0; i < numMsgs; i++ {
msg := &(*msgs)[i] msg := &(*msgs)[i]
if ok, err := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok { // todo: handle err
if err != nil { if ok, _ := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
log.Debugf("failed to handle STUN packet from %s: %v", msg.Addr, err)
}
// WireGuard reuses sizes and eps across reads and only skips a slot
// whose size is below the minimum message size. Leaving a consumed
// slot untouched makes it process this buffer again under the
// previous packet's length and endpoint.
sizes[i] = 0
continue continue
} }
sizes[i] = msg.N sizes[i] = msg.N
@@ -288,16 +271,11 @@ func (s *ICEBind) createOrUpdateMux() {
func (s *ICEBind) filterOutStunMessages(buffers [][]byte, n int, addr net.Addr) (bool, error) { func (s *ICEBind) filterOutStunMessages(buffers [][]byte, n int, addr net.Addr) (bool, error) {
for i := range buffers { for i := range buffers {
if n > len(buffers[i]) { if !stun.IsMessage(buffers[i]) {
continue
}
pkt := buffers[i][:n]
if isWireGuardMsg(pkt) || !stun.IsMessage(pkt) {
continue continue
} }
msg, err := s.parseSTUNMessage(pkt) msg, err := s.parseSTUNMessage(buffers[i][:n])
if err != nil { if err != nil {
buffers[i] = []byte{} buffers[i] = []byte{}
return true, err return true, err
@@ -369,34 +347,18 @@ func putMessages(msgs *[]ipv6.Message, msgsPool *sync.Pool) {
msgsPool.Put(msgs) msgsPool.Put(msgs)
} }
// isWireGuardMsg reports whether the packet carries a WireGuard message header: a
// little-endian uint32 message type in the range 1..4, which leaves the three bytes
// after the type byte zero, in a packet long enough to hold any WireGuard message.
//
// A well formed STUN message cannot take that shape. Its length field sits in the two
// bytes the type must leave zero, and for a message of at least wgMinMsgSize bytes that
// field holds at least 12, so the two framings do not overlap. The test has to be this
// tight because stun.IsMessage only looks at the magic cookie, which in a WireGuard
// message overlaps the receiver index: a session whose index happens to equal the cookie
// would otherwise have all of its inbound data misrouted to the STUN handler until the
// next rekey.
func isWireGuardMsg(pkt []byte) bool {
if len(pkt) < wgMinMsgSize {
return false
}
msgType := binary.LittleEndian.Uint32(pkt[:4])
return msgType >= wgMsgTypeHandshakeInitiation && msgType <= wgMsgTypeTransport
}
// isTransportPkg reports whether the packet is WireGuard transport data carrying a
// payload, which is what counts as peer activity. A keepalive holds no payload and is
// exactly wgMinMsgSize bytes.
func isTransportPkg(buffers [][]byte, n int) bool { func isTransportPkg(buffers [][]byte, n int) bool {
if n < 4 || n > len(buffers[0]) { // The first buffer should contain at least 4 bytes for type
return false if len(buffers[0]) < 4 {
return true
} }
msgType := binary.LittleEndian.Uint32(buffers[0][:4]) // WireGuard packet type is a little-endian uint32 at start
return msgType == wgMsgTypeTransport && n > wgMinMsgSize packetType := binary.LittleEndian.Uint32(buffers[0][:4])
// Check if packetType matches known WireGuard message types
if packetType == 4 && n > 32 {
return true
}
return false
} }

View File

@@ -1,215 +0,0 @@
//go:build !js
package bind
import (
"encoding/binary"
"net"
"testing"
"time"
"github.com/pion/stun/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/ipv4"
wgConn "golang.zx2c4.com/wireguard/conn"
)
// magicCookieBytes is the STUN magic cookie as it appears on the wire. In a
// WireGuard message the same offset holds the receiver (or sender) index, which is
// a random uint32, so a session can draw exactly this value.
var magicCookieBytes = []byte{0x21, 0x12, 0xA4, 0x42}
const testBufSize = 1500
// wgMsg builds a WireGuard message of the given type and size, with the index field
// at bytes 4:8 set to index.
func wgMsg(msgType uint32, size int, index []byte) []byte {
pkt := make([]byte, size)
binary.LittleEndian.PutUint32(pkt[:4], msgType)
copy(pkt[4:8], index)
return pkt
}
// intoBuffer copies pkt into a full-size receive buffer, the way the kernel read
// does, so tests see the same buffer/length split as the hot path.
func intoBuffer(pkt []byte) [][]byte {
buf := make([]byte, testBufSize)
copy(buf, pkt)
return [][]byte{buf}
}
func TestFilterOutStunMessages_PassesWireGuardWithCookieShapedIndex(t *testing.T) {
tests := []struct {
name string
msgType uint32
size int
}{
{"transport data", wgMsgTypeTransport, 128},
{"keepalive", wgMsgTypeTransport, wgMinMsgSize},
{"handshake initiation", wgMsgTypeHandshakeInitiation, 148},
{"handshake response", 2, 92},
{"cookie reply", 3, 64},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pkt := wgMsg(tc.msgType, tc.size, magicCookieBytes)
require.True(t, stun.IsMessage(pkt), "precondition: pion sees this as STUN")
buffers := intoBuffer(pkt)
bind := &ICEBind{}
filtered, err := bind.filterOutStunMessages(buffers, tc.size, &net.UDPAddr{})
assert.NoError(t, err)
assert.False(t, filtered, "WireGuard message must be handed to WireGuard, not the STUN handler")
assert.Len(t, buffers[0], testBufSize, "buffer must be left intact for WireGuard")
})
}
}
func TestFilterOutStunMessages_FiltersRealSTUNMessage(t *testing.T) {
msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint)
require.NoError(t, err)
buffers := intoBuffer(msg.Raw)
bind := &ICEBind{}
filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{})
assert.NoError(t, err)
assert.True(t, filtered, "STUN message must be consumed by the STUN handler")
assert.Empty(t, buffers[0], "consumed buffer must be emptied so WireGuard does not see it")
}
// TestIsWireGuardMsg_DisjointFromSTUN locks the invariant the filter relies on: a
// well formed STUN message long enough to be a WireGuard message always has a
// non-zero length field, so it cannot be mistaken for a WireGuard header.
func TestIsWireGuardMsg_DisjointFromSTUN(t *testing.T) {
types := []stun.MessageType{
stun.BindingRequest,
stun.BindingSuccess,
stun.BindingError,
{Method: stun.MethodBinding, Class: stun.ClassIndication},
}
for _, msgType := range types {
// Long enough that the length guard is not what makes this pass.
msg, err := stun.Build(msgType, stun.TransactionID,
stun.NewUsername("remoteUfrag:localUfrag"), stun.Fingerprint)
require.NoError(t, err)
require.GreaterOrEqual(t, len(msg.Raw), wgMinMsgSize, "precondition: %s", msgType)
assert.False(t, isWireGuardMsg(msg.Raw),
"%s must not look like a WireGuard message", msgType)
}
}
func TestIsWireGuardMsg(t *testing.T) {
tests := []struct {
name string
pkt []byte
want bool
}{
{"transport data", wgMsg(wgMsgTypeTransport, 128, nil), true},
{"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), true},
{"unknown type 5", wgMsg(5, 128, nil), false},
{"type 0", wgMsg(0, 128, nil), false},
{"non-zero reserved byte", []byte{0x04, 0x00, 0x01, 0x00}, false},
{"too short", []byte{0x04, 0x00, 0x00}, false},
{"empty", nil, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, isWireGuardMsg(tc.pkt), "wrong classification for %s", tc.name)
})
}
}
// TestFilterOutStunMessages_IgnoresBytesBeyondPacket guards against classifying on
// buffer contents left over from an earlier, longer packet.
func TestFilterOutStunMessages_IgnoresBytesBeyondPacket(t *testing.T) {
buf := make([]byte, testBufSize)
copy(buf[4:8], magicCookieBytes)
buffers := [][]byte{buf}
bind := &ICEBind{}
filtered, err := bind.filterOutStunMessages(buffers, 2, &net.UDPAddr{})
assert.NoError(t, err)
assert.False(t, filtered, "a 2 byte packet must not be classified from stale buffer bytes")
}
// TestReceiveFn_ClearsSizeOfConsumedPacket covers the accounting WireGuard relies
// on: sizes is reused across reads, so a slot whose packet was consumed as STUN must
// be reported as empty. Otherwise WireGuard reprocesses the same buffer under the
// previous packet's length, which for a WireGuard-shaped packet means it is handled
// twice.
func TestReceiveFn_ClearsSizeOfConsumedPacket(t *testing.T) {
conn := listenUDP(t, "udp4", "127.0.0.1:0")
defer conn.Close()
recvFn := receiverCreator{setupICEBind(t)}.CreateReceiverFn(
ipv4.NewPacketConn(conn), conn, false, createMsgPool(),
)
msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint)
require.NoError(t, err)
sender := listenUDP(t, "udp4", "127.0.0.1:0")
defer sender.Close()
_, err = sender.WriteTo(msg.Raw, conn.LocalAddr())
require.NoError(t, err)
require.NoError(t, conn.SetReadDeadline(time.Now().Add(3*time.Second)))
bufs := [][]byte{make([]byte, 1500)}
// A leftover size from an earlier read, which is what makes the missing reset
// observable.
sizes := []int{148}
eps := make([]wgConn.Endpoint, 1)
n, err := recvFn(bufs, sizes, eps)
require.NoError(t, err)
require.Equal(t, 1, n)
assert.Zero(t, sizes[0], "consumed STUN packet must not leave a size behind for WireGuard")
}
func TestIsTransportPkg(t *testing.T) {
tests := []struct {
name string
pkt []byte
n int
want bool
}{
{"transport data with payload", wgMsg(wgMsgTypeTransport, 128, nil), 128, true},
{"keepalive", wgMsg(wgMsgTypeTransport, wgMinMsgSize, nil), wgMinMsgSize, false},
{"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), 148, false},
{"stale type bytes beyond packet", wgMsg(wgMsgTypeTransport, 128, nil), 2, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, isTransportPkg(intoBuffer(tc.pkt), tc.n),
"wrong activity classification for %s", tc.name)
})
}
}
// TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType covers the one STUN
// encoding whose leading bytes collide with a WireGuard message type: method 0x080 as a
// request encodes to 0x0200, so the type byte reads as a handshake response and the byte
// after it is zero. Only the length check keeps such a message out of WireGuard's hands.
// pion implements no method in that range, so this is a synthetic worst case rather than
// traffic ICE produces.
func TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType(t *testing.T) {
msg, err := stun.Build(stun.NewType(stun.Method(0x080), stun.ClassRequest), stun.TransactionID)
require.NoError(t, err)
require.Equal(t, []byte{0x02, 0x00, 0x00, 0x00}, msg.Raw[:4],
"precondition: the leading bytes read as a WireGuard message type")
buffers := intoBuffer(msg.Raw)
bind := &ICEBind{}
filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{})
assert.NoError(t, err)
assert.True(t, filtered, "STUN message must be consumed despite its WireGuard-shaped type")
}

View File

@@ -34,8 +34,9 @@ import (
"github.com/netbirdio/netbird/shared/netiputil" "github.com/netbirdio/netbird/shared/netiputil"
) )
const readmeContent = `This debug bundle contains the following files. const readmeContent = `Netbird debug bundle
If anonymization is enabled (--anonymize / --anonymize-level), the files are anonymized to protect sensitive information. This debug bundle contains the following files.
If the --anonymize flag is set, the files are anonymized to protect sensitive information.
status.txt: Anonymized status information of the NetBird client. status.txt: Anonymized status information of the NetBird client.
client.log: Most recent, anonymized client log file of the NetBird client. client.log: Most recent, anonymized client log file of the NetBird client.
@@ -69,34 +70,21 @@ capture.pcap: Packet capture in pcap format. Only present when capture was runni
Anonymization Process Anonymization Process
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: The files in this bundle have been anonymized to protect sensitive information. Here's how the anonymization was applied:
IP Addresses IP Addresses
Default level: IPv4 addresses are replaced with addresses starting from 198.51.100.0
- Public IPv4 addresses are replaced with addresses starting from 198.51.100.0 IPv6 addresses are replaced with addresses starting from 100::
- 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. 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. 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 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. 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. 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 Sync Response
The network_map.json file contains the following anonymized information: The network_map.json file contains the following anonymized information:
@@ -293,7 +281,6 @@ type BundleGenerator struct {
cliVersion string cliVersion string
anonymize bool anonymize bool
anonymizeLevel anonymize.Level
includeSystemInfo bool includeSystemInfo bool
logFileCount uint32 logFileCount uint32
@@ -301,10 +288,7 @@ type BundleGenerator struct {
} }
type BundleConfig struct { type BundleConfig struct {
Anonymize bool Anonymize bool
// AnonymizeLevel selects how much the anonymizer redacts.
// anonymize.LevelStrict implies Anonymize.
AnonymizeLevel anonymize.Level
IncludeSystemInfo bool IncludeSystemInfo bool
LogFileCount uint32 LogFileCount uint32
} }
@@ -343,11 +327,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
uiLogOpener = openLogFile uiLogOpener = openLogFile
} }
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(cfg.AnonymizeLevel)
return &BundleGenerator{ return &BundleGenerator{
anonymizer: anonymizer, anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
internalConfig: deps.InternalConfig, internalConfig: deps.InternalConfig,
statusRecorder: deps.StatusRecorder, statusRecorder: deps.StatusRecorder,
@@ -364,8 +345,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
daemonVersion: deps.DaemonVersion, daemonVersion: deps.DaemonVersion,
cliVersion: deps.CliVersion, cliVersion: deps.CliVersion,
anonymize: cfg.Anonymize || cfg.AnonymizeLevel >= anonymize.LevelStrict, anonymize: cfg.Anonymize,
anonymizeLevel: cfg.AnonymizeLevel,
includeSystemInfo: cfg.IncludeSystemInfo, includeSystemInfo: cfg.IncludeSystemInfo,
logFileCount: logFileCount, logFileCount: logFileCount,
} }
@@ -505,13 +485,7 @@ func (g *BundleGenerator) addSystemInfo() {
} }
func (g *BundleGenerator) addReadme() error { func (g *BundleGenerator) addReadme() error {
level := "none (anonymization disabled)" readmeReader := strings.NewReader(readmeContent)
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 { if err := g.addFileToZip(readmeReader, "README.txt"); err != nil {
return fmt.Errorf("add README file to zip: %w", err) return fmt.Errorf("add README file to zip: %w", err)
} }
@@ -533,10 +507,9 @@ func (g *BundleGenerator) addStatus() error {
fullStatus := g.statusRecorder.GetFullStatus() fullStatus := g.statusRecorder.GetFullStatus()
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus) protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{ overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
Anonymize: g.anonymize, Anonymize: g.anonymize,
AnonymizeLevel: g.anonymizeLevel, ProfileName: profName,
ProfileName: profName, DaemonVersion: g.daemonVersion,
DaemonVersion: g.daemonVersion,
}) })
overview.CliVersion = g.cliVersion overview.CliVersion = g.cliVersion
statusOutput := overview.FullDetailSummary() statusOutput := overview.FullDetailSummary()
@@ -689,7 +662,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString("NetBird Client Configuration:\n\n") configContent.WriteString("NetBird Client Configuration:\n\n")
if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil { if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil {
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", g.anonymizer.AnonymizeWGKey(key.PublicKey().String()))) configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", key.PublicKey().String()))
} }
configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface)) configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface))
configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort)) configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort))
@@ -979,11 +952,6 @@ func (g *BundleGenerator) addUpdateLogs() error {
} }
baseName := filepath.Base(logFile) 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 { 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) return fmt.Errorf("add update log file %s to zip: %w", baseName, err)
} }
@@ -1011,13 +979,6 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
} }
fileName := filepath.Base(match) 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 { 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) log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err)
continue continue
@@ -1029,27 +990,6 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
return nil 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 { func (g *BundleGenerator) addMetrics() error {
if g.clientMetrics == nil { if g.clientMetrics == nil {
log.Debugf("skipping metrics in debug bundle: no metrics collector") log.Debugf("skipping metrics in debug bundle: no metrics collector")
@@ -1522,7 +1462,6 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize.
} }
peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn) peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn)
peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey)
anonymizeSSHConfig(peer.SshConfig) anonymizeSSHConfig(peer.SshConfig)
} }

View File

@@ -35,14 +35,14 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName)) sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName))
sb.WriteString(fmt.Sprintf(" public key: %s\n", g.anonymizer.AnonymizeWGKey(s.PublicKey))) sb.WriteString(fmt.Sprintf(" public key: %s\n", s.PublicKey))
sb.WriteString(fmt.Sprintf(" listen port: %d\n", s.ListenPort)) sb.WriteString(fmt.Sprintf(" listen port: %d\n", s.ListenPort))
if s.FWMark != 0 { if s.FWMark != 0 {
sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark)) sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark))
} }
for _, peer := range s.Peers { for _, peer := range s.Peers {
sb.WriteString(fmt.Sprintf("\npeer: %s\n", g.anonymizer.AnonymizeWGKey(peer.PublicKey))) sb.WriteString(fmt.Sprintf("\npeer: %s\n", peer.PublicKey))
if peer.Endpoint.IP != nil { if peer.Endpoint.IP != nil {
if g.anonymize { if g.anonymize {
anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint) anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint)
@@ -54,11 +54,7 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
if len(peer.AllowedIPs) > 0 { if len(peer.AllowedIPs) > 0 {
var ipStrings []string var ipStrings []string
for _, ipnet := range peer.AllowedIPs { for _, ipnet := range peer.AllowedIPs {
ipStr := ipnet.String() ipStrings = append(ipStrings, 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, ", "))) sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", ")))
} }

View File

@@ -23,7 +23,6 @@ import (
"golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/anonymize"
nberrors "github.com/netbirdio/netbird/client/errors" nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall" "github.com/netbirdio/netbird/client/firewall"
"github.com/netbirdio/netbird/client/firewall/firewalld" "github.com/netbirdio/netbird/client/firewall/firewalld"
@@ -1386,7 +1385,6 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
bundleJobParams := debug.BundleConfig{ bundleJobParams := debug.BundleConfig{
Anonymize: params.Anonymize, Anonymize: params.Anonymize,
AnonymizeLevel: anonymize.ParseLevel(params.AnonymizeLevel),
IncludeSystemInfo: true, IncludeSystemInfo: true,
LogFileCount: uint32(params.LogFileCount), LogFileCount: uint32(params.LogFileCount),
} }

View File

@@ -4,17 +4,11 @@ package metrics
type ConnectionType string type ConnectionType string
const ( const (
// ConnectionTypeICEP2P represents a direct peer-to-peer connection using ICE // ConnectionTypeICE represents a direct peer-to-peer connection using ICE
ConnectionTypeICEP2P ConnectionType = "ice_p2p" ConnectionTypeICE ConnectionType = "ice"
// ConnectionTypeICETurn represents an ICE connection through a TURN server
ConnectionTypeICETurn ConnectionType = "ice_turn"
// ConnectionTypeRelay represents a relayed connection // ConnectionTypeRelay represents a relayed connection
ConnectionTypeRelay ConnectionType = "relay" ConnectionTypeRelay ConnectionType = "relay"
// ConnectionTypeUnknown represents a connection with no active transport. It is not pushed.
ConnectionTypeUnknown ConnectionType = "unknown"
) )
// String returns the string representation of the connection type // String returns the string representation of the connection type

View File

@@ -28,7 +28,7 @@ func TestInfluxDBMetrics_RecordAndExport(t *testing.T) {
WgHandshakeSuccess: time.Now().Add(-1 * time.Second), WgHandshakeSuccess: time.Now().Add(-1 * time.Second),
} }
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts) m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
var buf bytes.Buffer var buf bytes.Buffer
err := m.Export(&buf) err := m.Export(&buf)
@@ -60,7 +60,7 @@ func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) {
// Record multiple times and verify consistent field order // Record multiple times and verify consistent field order
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts) m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
} }
var buf bytes.Buffer var buf bytes.Buffer

View File

@@ -56,33 +56,14 @@ Measurement: `netbird_peer_connection`
Tags: Tags:
- `deployment_type`: "cloud" | "selfhosted" | "unknown" - `deployment_type`: "cloud" | "selfhosted" | "unknown"
- `connection_type`: "ice_p2p" | "ice_turn" | "relay" (see below) - `connection_type`: "ice" | "relay"
- `attempt_type`: "initial" | "reconnection" - `attempt_type`: "initial" | "reconnection"
- `version`: NetBird version string - `version`: NetBird version string
- `os`: Operating system (linux, darwin, windows, android, ios, etc.) - `os`: Operating system (linux, darwin, windows, android, ios, etc.)
- `arch`: CPU architecture (amd64, arm64, etc.) - `arch`: CPU architecture (amd64, arm64, etc.)
- `peer_id`: anonymised peer identifier (truncated SHA-256 of the WireGuard public key)
- `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides
**Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online. **Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online.
#### `connection_type` values
Derived from the connection priority (`conntype.ConnPriority`) by `metricsConnType` in `client/internal/peer/conn.go`:
| Value | Priority | Traffic is |
|-------|----------|------------|
| `ice_p2p` | `ICEP2P` | direct peer-to-peer |
| `ice_turn` | `ICETurn` | relayed, through a TURN server |
| `relay` | `Relay` | relayed, through a NetBird relay |
| `unknown` | `None` or unrecognised | no active transport — **the sample is not pushed** |
**Direct traffic is `ice_p2p` only.** `ice_turn` is relayed despite being negotiated by ICE, matching `Conn.isRelayed`.
`None` means no transport is active: not established yet, or reset after a relay drop or a peer-state reset. Such a sample cannot be attributed to a transport, so `recordConnectionMetrics` drops it instead of pushing it — `unknown` therefore never appears in the bucket. Connection counts are counts of connections whose transport was known at sampling time.
**Samples recorded before 0.77 used a single `ice` value** which covered `ICEP2P`, `ICETurn` *and* `None`, so historical `ice` samples overstate direct connections by an unknown amount and must not be compared with `ice_p2p`.
### Sync Duration ### Sync Duration
Measurement: `netbird_sync` Measurement: `netbird_sync`

View File

@@ -307,8 +307,6 @@ func (conn *Conn) Close(signalToRemote bool) {
if conn.wgWatcherCancel != nil { if conn.wgWatcherCancel != nil {
conn.wgWatcherCancel() conn.wgWatcherCancel()
conn.wgWatcher = nil
conn.wgWatcherCancel = nil
} }
conn.workerRelay.CloseConn() conn.workerRelay.CloseConn()
if conn.workerICE != nil { if conn.workerICE != nil {
@@ -961,9 +959,12 @@ func (conn *Conn) recordConnectionMetrics() {
priority := conn.currentConnPriority priority := conn.currentConnPriority
conn.mu.Unlock() conn.mu.Unlock()
connType := metricsConnType(priority) var connType metrics.ConnectionType
if connType == metrics.ConnectionTypeUnknown { switch priority {
return case conntype.Relay:
connType = metrics.ConnectionTypeRelay
default:
connType = metrics.ConnectionTypeICE
} }
// Record metrics with timestamps - duration calculation happens in metrics package // Record metrics with timestamps - duration calculation happens in metrics package
@@ -1064,16 +1065,3 @@ func boolToConnStatus(connected bool) guard.ConnStatus {
} }
return guard.ConnStatusDisconnected return guard.ConnStatusDisconnected
} }
func metricsConnType(priority conntype.ConnPriority) metrics.ConnectionType {
switch priority {
case conntype.Relay:
return metrics.ConnectionTypeRelay
case conntype.ICETurn:
return metrics.ConnectionTypeICETurn
case conntype.ICEP2P:
return metrics.ConnectionTypeICEP2P
default:
return metrics.ConnectionTypeUnknown
}
}

View File

@@ -11,8 +11,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal/metrics"
"github.com/netbirdio/netbird/client/internal/peer/conntype"
"github.com/netbirdio/netbird/client/internal/peer/dispatcher" "github.com/netbirdio/netbird/client/internal/peer/dispatcher"
"github.com/netbirdio/netbird/client/internal/peer/guard" "github.com/netbirdio/netbird/client/internal/peer/guard"
"github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/peer/ice"
@@ -388,33 +386,3 @@ func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
} }
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections") assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
} }
func TestMetricsConnType(t *testing.T) {
tests := []struct {
name string
priority conntype.ConnPriority
expected metrics.ConnectionType
}{
{"relay", conntype.Relay, metrics.ConnectionTypeRelay},
{"ice over turn is relayed, not p2p", conntype.ICETurn, metrics.ConnectionTypeICETurn},
{"direct p2p", conntype.ICEP2P, metrics.ConnectionTypeICEP2P},
{"unset priority is unknown, not p2p", conntype.None, metrics.ConnectionTypeUnknown},
{"unrecognised priority is unknown", conntype.ConnPriority(99), metrics.ConnectionTypeUnknown},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, metricsConnType(tc.priority))
})
}
}
func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) {
for _, priority := range []conntype.ConnPriority{conntype.None, conntype.Relay, conntype.ICETurn, conntype.ICEP2P} {
conn := &Conn{currentConnPriority: priority}
tag := metricsConnType(priority)
relayedTag := tag == metrics.ConnectionTypeRelay || tag == metrics.ConnectionTypeICETurn
assert.Equal(t, conn.isRelayed(), relayedTag,
"priority %s: isRelayed and the %q metric tag must agree", priority, tag)
}
}

View File

@@ -14,7 +14,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/debug"
@@ -29,13 +28,6 @@ import (
types "github.com/netbirdio/netbird/upload-server/types" types "github.com/netbirdio/netbird/upload-server/types"
) )
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
// anonymizeLevel values for DebugBundle.
const (
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
)
// ConnectionListener export internal Listener for mobile // ConnectionListener export internal Listener for mobile
type ConnectionListener interface { type ConnectionListener interface {
peer.Listener peer.Listener
@@ -208,10 +200,8 @@ func (c *Client) Stop() {
// DebugBundle generates a debug bundle, uploads it and returns the upload key. // 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 // 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 // the live config, sync response and client metrics; otherwise it loads the
// config from disk (or the preloaded tvOS config). anonymizeLevel is "default" // config from disk (or the preloaded tvOS config).
// or "strict"; strict also anonymizes internal IP ranges, peer names, and func (c *Client) DebugBundle(anonymize bool) (string, error) {
// WireGuard public keys, and implies anonymize.
func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, error) {
cfg, cc := c.stateSnapshot() cfg, cc := c.stateSnapshot()
// If the engine hasn't been started, load config so we can reach management. // If the engine hasn't been started, load config so we can reach management.
@@ -261,7 +251,6 @@ func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, err
deps, deps,
debug.BundleConfig{ debug.BundleConfig{
Anonymize: anonymize, Anonymize: anonymize,
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
IncludeSystemInfo: true, IncludeSystemInfo: true,
}, },
) )

View File

@@ -2781,11 +2781,6 @@ type DebugBundleRequest struct {
// untrusted TLS certificate. Restricted to privileged callers; for // untrusted TLS certificate. Restricted to privileged callers; for
// self-hosted upload servers. // self-hosted upload servers.
UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -2862,13 +2857,6 @@ func (x *DebugBundleRequest) GetUploadInsecure() bool {
return false return false
} }
func (x *DebugBundleRequest) GetAnonymizeLevel() string {
if x != nil {
return x.AnonymizeLevel
}
return ""
}
type DebugBundleResponse struct { type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -7265,7 +7253,7 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" + "\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" + "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" + "\x17ForwardingRulesResponse\x12,\n" +
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" + "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xdc\x01\n" +
"\x12DebugBundleRequest\x12\x1c\n" + "\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" + "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" + "\n" +
@@ -7276,8 +7264,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" + "\n" +
"cliVersion\x18\x06 \x01(\tR\n" + "cliVersion\x18\x06 \x01(\tR\n" +
"cliVersion\x12&\n" + "cliVersion\x12&\n" +
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\x12&\n" + "\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\"}\n" +
"\x0eanonymizeLevel\x18\b \x01(\tR\x0eanonymizeLevel\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" + "\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" + "\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" + "\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +

View File

@@ -540,11 +540,6 @@ message DebugBundleRequest {
// untrusted TLS certificate. Restricted to privileged callers; for // untrusted TLS certificate. Restricted to privileged callers; for
// self-hosted upload servers. // self-hosted upload servers.
bool uploadInsecure = 7; 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 { message DebugBundleResponse {

View File

@@ -16,7 +16,6 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status" gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/proto"
@@ -123,7 +122,6 @@ func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener deb
}, },
debug.BundleConfig{ debug.BundleConfig{
Anonymize: req.GetAnonymize(), Anonymize: req.GetAnonymize(),
AnonymizeLevel: anonymize.ParseLevel(req.GetAnonymizeLevel()),
IncludeSystemInfo: req.GetSystemInfo(), IncludeSystemInfo: req.GetSystemInfo(),
LogFileCount: req.GetLogFileCount(), LogFileCount: req.GetLogFileCount(),
}, },

View File

@@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi
// prepareCommandEnv prepares environment variables for command execution on Windows // prepareCommandEnv prepares environment variables for command execution on Windows
func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string { func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string {
username, domain := parseUsername(localUser.Username) username, domain := s.parseUsername(localUser.Username)
userEnv, err := s.getUserEnvironment(logger, username, domain) userEnv, err := s.getUserEnvironment(logger, username, domain)
if err != nil { if err != nil {
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err) log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
@@ -383,7 +383,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
return false return false
} }
username, domain := parseUsername(localUser.Username) username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid) shell := getUserShell(localUser.Uid)
req := PtyExecutionRequest{ req := PtyExecutionRequest{

View File

@@ -133,12 +133,7 @@ func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, resu
return nil return nil
} }
// Only uid 0 may bind below the threshold, which is the kernel's own rule and if result.User != nil && isPrivilegedUsername(result.User.Username) {
// is asked directly rather than through isPrivilegedOrUnknown: that helper
// reports an account it cannot evaluate as privileged, which is safe for a
// refusal and unsafe for a grant such as this one. Windows has returned
// above, so Uid here is a Unix uid and never a SID.
if result.User != nil && result.User.Uid == "0" {
return nil return nil
} }

View File

@@ -1,16 +0,0 @@
//go:build !windows
package server
// isProcessElevated is only meaningful on Windows; other platforms use the
// effective UID check in isCurrentProcessPrivileged.
func isProcessElevated() bool {
return false
}
// isWindowsAccountPrivilegedOrUnknown is only reachable on Windows. Report
// privileged on other platforms so a caller refusing privileged accounts fails
// closed.
func isWindowsAccountPrivilegedOrUnknown(string) bool {
return true
}

View File

@@ -1,228 +0,0 @@
//go:build windows
package server
import (
"fmt"
"strings"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
var (
netapi32 = windows.NewLazySystemDLL("netapi32.dll")
procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups")
)
const (
// lgIncludeIndirect makes NetUserGetLocalGroups also return local groups
// the user belongs to through a global group.
lgIncludeIndirect = 0x1
maxPreferredLength = 0xFFFFFFFF
)
// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0.
type localGroupUsersInfo0 struct {
name *uint16
}
// isProcessElevated reports whether the current process token is elevated
// (TokenElevation): true for elevated administrators, the built-in
// Administrator, administrators with UAC disabled, and SYSTEM; false for
// standard users and administrators running with a UAC-filtered token.
func isProcessElevated() bool {
return windows.GetCurrentProcessToken().IsElevated()
}
// isWindowsAccountPrivilegedOrUnknown reports whether the account is privileged
// on this machine: a well-known service account, a built-in Administrator
// (RID 500), or a member of the local Administrators group, directly or through
// nested groups.
//
// An account whose privilege cannot be determined counts as privileged, which
// is why the name says "or unknown". That is fail-closed for a caller that
// refuses privileged accounts, and fail-open for a caller that grants something
// to them, so only the former may use this.
func isWindowsAccountPrivilegedOrUnknown(username string) bool {
sid, _, _, err := windows.LookupSID("", username)
if err != nil {
log.Warnf("privilege check: SID lookup for %q failed, treating as privileged: %v", username, err)
return true
}
if isPrivilegedUserSID(sid) {
return true
}
member, err := isLocalAdminsMember(username)
if err != nil {
log.Warnf("privilege check: cannot determine Administrators membership for %q, treating as privileged: %v", username, err)
return true
}
return member
}
// isPrivilegedUserSID reports whether the SID itself identifies a privileged
// principal, without consulting group membership.
func isPrivilegedUserSID(sid *windows.SID) bool {
wellKnown := []windows.WELL_KNOWN_SID_TYPE{
windows.WinLocalSystemSid,
windows.WinLocalServiceSid,
windows.WinNetworkServiceSid,
windows.WinBuiltinAdministratorsSid,
}
for _, sidType := range wellKnown {
if sid.IsWellKnown(sidType) {
return true
}
}
return isBuiltinAdministratorSID(sid)
}
// isBuiltinAdministratorSID reports whether the SID is a machine or domain
// built-in Administrator account (S-1-5-21-...-500). RID 500 is reserved for
// that account; it can be renamed but cannot be removed from the
// Administrators group.
func isBuiltinAdministratorSID(sid *windows.SID) bool {
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
return false
}
count := sid.SubAuthorityCount()
if count < 2 || sid.SubAuthority(0) != 21 {
return false
}
return sid.SubAuthority(uint32(count-1)) == 500
}
// isLocalAdminsMember reports whether the account is a member of the local
// Administrators group.
//
// Local accounts are checked against the local SAM, which is authoritative for
// them and, unlike a token, cannot under-report: UAC filters the tokens of
// local administrators, and a filtered token carries Administrators as
// deny-only, which a membership check on the token would read as "not a
// member". Domain accounts are exempt from that filtering, so for them an S4U
// token is preferred because its group list is LSA's transitive expansion and
// therefore covers nested and universal groups plus the machine's own local
// groups. NetUserGetLocalGroups expands only one global-group hop but needs no
// logon, so it serves as the fallback when no token can be obtained.
func isLocalAdminsMember(username string) (bool, error) {
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
if err != nil {
return false, fmt.Errorf("create Administrators SID: %w", err)
}
account, domain := parseUsername(username)
if NewPrivilegeDropper().isLocalUser(domain) {
return localGroupsContainSID(account, adminSid)
}
member, s4uErr := s4uTokenIsMember(account, domain, adminSid)
if s4uErr == nil {
return member, nil
}
log.Debugf("privilege check: S4U membership check for %q failed, falling back to local group enumeration: %v", username, s4uErr)
member, err = localGroupsContainSID(buildUserCpn(account, domain), adminSid)
if err != nil {
return false, fmt.Errorf("S4U check: %w; local group enumeration: %w", s4uErr, err)
}
return member, nil
}
// s4uTokenIsMember obtains an S4U token for the account and checks whether the
// given SID is enabled in it.
func s4uTokenIsMember(account, domain string, sid *windows.SID) (bool, error) {
token, err := generateS4UUserToken(log.NewEntry(log.StandardLogger()), account, domain)
if err != nil {
return false, err
}
defer func() {
if err := windows.CloseHandle(token); err != nil {
log.Debugf("close S4U token: %v", err)
}
}()
return windows.Token(token).IsMember(sid)
}
// localGroupsContainSID reports whether the wanted group is among the local
// groups the account belongs to, directly or through a global group.
//
// The wanted SID is resolved to its group name once and compared against the
// enumerated names. Well-known SIDs resolve from a static table, so that lookup
// needs no domain controller, and it keeps the comparison correct for a renamed
// or localized group because both sides then carry the new name. Resolving each
// enumerated name back to a SID instead would add a lookup per group that can
// block until it times out while a domain controller is unreachable, and cannot
// change the outcome: the names enumerated here are local groups of this
// machine, whose names are unique, so a name match identifies the group.
//
// A failure to resolve the wanted SID is returned rather than reported as
// "not a member", so a privilege check built on this fails closed.
func localGroupsContainSID(username string, want *windows.SID) (bool, error) {
wantName, _, _, err := want.LookupAccount("")
if err != nil {
return false, fmt.Errorf("resolve group SID %s to a name: %w", want, err)
}
groups, err := netUserGetLocalGroups(username)
if err != nil {
return false, err
}
for _, group := range groups {
if strings.EqualFold(group, wantName) {
return true, nil
}
}
return false, nil
}
// netUserGetLocalGroups returns the names of the local groups the account is a
// member of, including indirect membership through global groups.
func netUserGetLocalGroups(username string) ([]string, error) {
name16, err := windows.UTF16PtrFromString(username)
if err != nil {
return nil, fmt.Errorf("convert username: %w", err)
}
var buf *byte
var entriesRead, totalEntries uint32
status, _, _ := procNetUserGetLocalGroups.Call(
0, // local server
uintptr(unsafe.Pointer(name16)),
0, // level 0: LOCALGROUP_USERS_INFO_0
lgIncludeIndirect,
uintptr(unsafe.Pointer(&buf)),
maxPreferredLength,
uintptr(unsafe.Pointer(&entriesRead)),
uintptr(unsafe.Pointer(&totalEntries)),
)
if status != 0 {
return nil, fmt.Errorf("NetUserGetLocalGroups for %q: status %d", username, status)
}
if buf == nil {
return nil, nil
}
defer func() {
if err := windows.NetApiBufferFree(buf); err != nil {
log.Debugf("free NetApi buffer: %v", err)
}
}()
// MAX_PREFERRED_LENGTH makes the API allocate as much as it needs, so a
// short read is not expected. Report it rather than silently returning a
// subset of the account's groups.
if entriesRead != totalEntries {
return nil, fmt.Errorf("NetUserGetLocalGroups for %q returned %d of %d groups", username, entriesRead, totalEntries)
}
entries := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buf)), entriesRead)
groups := make([]string, 0, entriesRead)
for _, entry := range entries {
groups = append(groups, windows.UTF16PtrToString(entry.name))
}
return groups, nil
}

View File

@@ -1,293 +0,0 @@
//go:build windows
package server
import (
"os/user"
"testing"
"unsafe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// filterNormalAccount limits NetUserEnum to normal user accounts.
const filterNormalAccount = 0x2
// TOKEN_ELEVATION_TYPE values.
const (
tokenElevationTypeDefault = 1
tokenElevationTypeFull = 2
tokenElevationTypeLimited = 3
)
// tokenElevationType reads TokenElevationType from a token.
func tokenElevationType(token windows.Token) (uint32, error) {
var elevationType, returnedLen uint32
err := windows.GetTokenInformation(token, windows.TokenElevationType,
(*byte)(unsafe.Pointer(&elevationType)), uint32(unsafe.Sizeof(elevationType)), &returnedLen)
if err != nil {
return 0, err
}
return elevationType, nil
}
// userInfo0 mirrors USER_INFO_0.
type userInfo0 struct {
name *uint16
}
func mustParseSID(t *testing.T, s string) *windows.SID {
t.Helper()
sid, err := windows.StringToSid(s)
require.NoError(t, err, "parse SID %s", s)
return sid
}
// localAccountNames returns the names of the local user accounts.
func localAccountNames(t *testing.T) []string {
t.Helper()
var buf *byte
var entriesRead, totalEntries, resume uint32
err := windows.NetUserEnum(nil, 0, filterNormalAccount, &buf, maxPreferredLength,
&entriesRead, &totalEntries, &resume)
require.NoError(t, err, "enumerate local users")
t.Cleanup(func() {
require.NoError(t, windows.NetApiBufferFree(buf), "free NetApi buffer")
})
entries := unsafe.Slice((*userInfo0)(unsafe.Pointer(buf)), entriesRead)
names := make([]string, 0, entriesRead)
for _, entry := range entries {
names = append(names, windows.UTF16PtrToString(entry.name))
}
return names
}
// localAccountNameByRID returns the name of the local account carrying the
// given RID. Accounts such as Administrator and Guest can be renamed and are
// localized, so tests must not name them literally.
func localAccountNameByRID(t *testing.T, rid uint32) string {
t.Helper()
for _, name := range localAccountNames(t) {
sid, _, _, err := windows.LookupSID("", name)
if err != nil {
continue
}
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
continue
}
count := sid.SubAuthorityCount()
if count < 2 || sid.SubAuthority(0) != 21 {
continue
}
if sid.SubAuthority(uint32(count-1)) == rid {
return name
}
}
t.Fatalf("no local account with RID %d", rid)
return ""
}
// wellKnownAccountName resolves a well-known SID to the qualified account name
// the local system uses for it, which is localized.
func wellKnownAccountName(t *testing.T, sidType windows.WELL_KNOWN_SID_TYPE) string {
t.Helper()
sid, err := windows.CreateWellKnownSid(sidType)
require.NoError(t, err, "create well-known SID")
name, domain, _, err := sid.LookupAccount("")
require.NoError(t, err, "resolve %s to an account name", sid)
if domain == "" {
return name
}
return domain + `\` + name
}
func TestIsBuiltinAdministratorSID(t *testing.T) {
tests := []struct {
name string
sid string
want bool
}{
{"machine_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
{"domain_administrator", "S-1-5-21-3390233681-4087452608-412898826-500", true},
{"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
{"guest_account", "S-1-5-21-1111111111-2222222222-3333333333-501", false},
{"domain_admins_group", "S-1-5-21-1111111111-2222222222-3333333333-512", false},
{"system", "S-1-5-18", false},
{"administrators_group", "S-1-5-32-544", false},
{"non_nt_authority", "S-1-1-0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isBuiltinAdministratorSID(mustParseSID(t, tt.sid))
assert.Equal(t, tt.want, result, "RID 500 detection for %s", tt.sid)
})
}
}
func TestIsPrivilegedUserSID(t *testing.T) {
tests := []struct {
name string
sid string
want bool
}{
{"local_system", "S-1-5-18", true},
{"local_service", "S-1-5-19", true},
{"network_service", "S-1-5-20", true},
{"administrators_group", "S-1-5-32-544", true},
{"builtin_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
{"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
{"users_group", "S-1-5-32-545", false},
{"everyone", "S-1-1-0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isPrivilegedUserSID(mustParseSID(t, tt.sid))
assert.Equal(t, tt.want, result, "SID privilege classification for %s", tt.sid)
})
}
}
func TestIsWindowsAccountPrivilegedOrUnknown(t *testing.T) {
tests := []struct {
name string
username string
want bool
}{
{"system", wellKnownAccountName(t, windows.WinLocalSystemSid), true},
{"local_service", wellKnownAccountName(t, windows.WinLocalServiceSid), true},
{"network_service", wellKnownAccountName(t, windows.WinNetworkServiceSid), true},
{"administrators_group", wellKnownAccountName(t, windows.WinBuiltinAdministratorsSid), true},
// The built-in Administrator (RID 500) and Guest (RID 501) accounts
// exist on every Windows installation, though they may be disabled.
{"builtin_administrator", localAccountNameByRID(t, 500), true},
{"guest", localAccountNameByRID(t, 501), false},
// Unresolvable accounts fail closed.
{"nonexistent_user", "netbird-no-such-user", true},
{"empty_username", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isWindowsAccountPrivilegedOrUnknown(tt.username)
assert.Equal(t, tt.want, result, "account privilege classification for %q", tt.username)
})
}
}
func TestIsProcessElevated(t *testing.T) {
elevated := isProcessElevated()
// TokenElevationType is a second, independent view of the same token:
// Full means elevated and Limited means a filtered administrator, while
// Default covers both a standard user and an administrator with no linked
// token (UAC off, the built-in Administrator, SYSTEM), so it implies nothing.
elevationType, err := tokenElevationType(windows.GetCurrentProcessToken())
require.NoError(t, err, "read token elevation type")
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
// Token(0) makes CheckTokenMembership evaluate the caller's own token. It
// counts only enabled SIDs, so a filtered administrator reports false here.
member, err := windows.Token(0).IsMember(adminSid)
require.NoError(t, err, "check own Administrators membership")
t.Logf("elevated=%v elevationType=%d memberOfAdministrators=%v", elevated, elevationType, member)
switch elevationType {
case tokenElevationTypeFull:
assert.True(t, elevated, "a token of elevation type Full must report elevated")
case tokenElevationTypeLimited:
assert.False(t, elevated, "a filtered administrator token must not report elevated")
}
// Administrators enabled in the token means the token wields administrative
// rights, which is what elevation reports.
if member {
assert.True(t, elevated, "token with enabled Administrators membership must report elevated")
}
}
// TestS4UMembershipAgreesWithLocalGroups exercises the S4U token path used
// for domain accounts. S4U logons need the TCB privilege, so the test runs
// only as SYSTEM (which is how CI executes the suite). For local accounts the
// token's Administrators membership must agree with the SAM enumeration.
func TestS4UMembershipAgreesWithLocalGroups(t *testing.T) {
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
require.NoError(t, err, "create SYSTEM SID")
current, err := user.Current()
require.NoError(t, err, "get current user")
if current.Uid != system.String() {
t.Skipf("S4U logon requires SYSTEM (running as %s)", current.Username)
}
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
checked := 0
for _, name := range localAccountNames(t) {
viaToken, err := s4uTokenIsMember(name, ".", adminSid)
if err != nil {
// Disabled or logon-restricted accounts cannot get an S4U logon.
t.Logf("skipping %s: %v", name, err)
continue
}
viaSAM, err := localGroupsContainSID(name, adminSid)
require.NoError(t, err, "enumerate local groups for %s", name)
assert.Equal(t, viaSAM, viaToken, "S4U token and SAM enumeration must agree on Administrators membership for %s", name)
checked++
}
// Ineligible accounts are skipped, so without this the test could report
// success while comparing nothing at all.
require.Positive(t, checked, "no local account completed an S4U logon, so nothing was compared")
t.Logf("checked %d local accounts via S4U", checked)
}
// TestLocalGroupsContainSID_Administrator checks the positive case against the
// built-in Administrator, a member of Administrators on every installation.
func TestLocalGroupsContainSID_Administrator(t *testing.T) {
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
administrator := localAccountNameByRID(t, 500)
member, err := localGroupsContainSID(administrator, adminSid)
require.NoError(t, err, "enumerate local groups for %s", administrator)
assert.True(t, member, "%s is a member of the Administrators group", administrator)
}
// TestLocalGroupsContainSID_UnresolvableGroupFailsClosed covers a wanted SID
// that resolves to no group: the error must surface rather than being reported
// as "not a member", so the privilege check treats the account as privileged.
func TestLocalGroupsContainSID_UnresolvableGroupFailsClosed(t *testing.T) {
unknown := mustParseSID(t, "S-1-5-21-1111111111-2222222222-3333333333-4444")
_, err := localGroupsContainSID(localAccountNameByRID(t, 500), unknown)
require.Error(t, err, "must report an error when the wanted group cannot be identified")
}
func TestLocalGroupsContainSID_Guest(t *testing.T) {
guestsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid)
require.NoError(t, err, "create Guests SID")
adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
guest := localAccountNameByRID(t, 501)
inGuests, err := localGroupsContainSID(guest, guestsSid)
require.NoError(t, err, "enumerate local groups for %s", guest)
assert.True(t, inGuests, "%s is a member of the Guests group", guest)
inAdmins, err := localGroupsContainSID(guest, adminsSid)
require.NoError(t, err, "enumerate local groups for %s", guest)
assert.False(t, inAdmins, "%s is not a member of the Administrators group", guest)
}

View File

@@ -239,7 +239,6 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType string forwardType string
port uint32 port uint32
username string username string
uid string
expectError bool expectError bool
errorMsg string errorMsg string
skipOnWindows bool skipOnWindows bool
@@ -249,7 +248,6 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote", forwardType: "remote",
port: 80, port: 80,
username: "testuser", username: "testuser",
uid: "1000",
expectError: true, expectError: true,
errorMsg: "cannot bind to privileged port", errorMsg: "cannot bind to privileged port",
skipOnWindows: true, skipOnWindows: true,
@@ -259,7 +257,6 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "tcpip-forward", forwardType: "tcpip-forward",
port: 443, port: 443,
username: "testuser", username: "testuser",
uid: "1000",
expectError: true, expectError: true,
errorMsg: "cannot bind to privileged port", errorMsg: "cannot bind to privileged port",
skipOnWindows: true, skipOnWindows: true,
@@ -269,7 +266,6 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote", forwardType: "remote",
port: 8080, port: 8080,
username: "testuser", username: "testuser",
uid: "1000",
expectError: false, expectError: false,
}, },
{ {
@@ -277,7 +273,6 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote", forwardType: "remote",
port: 0, port: 0,
username: "testuser", username: "testuser",
uid: "1000",
expectError: false, expectError: false,
}, },
{ {
@@ -285,35 +280,13 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote", forwardType: "remote",
port: 22, port: 22,
username: "root", username: "root",
uid: "0",
expectError: false, expectError: false,
}, },
{
// Only uid 0 is privileged, whatever the account is called.
name: "uid 0 under another name may bind a privileged port",
forwardType: "remote",
port: 22,
username: "toor",
uid: "0",
expectError: false,
skipOnWindows: true,
},
{
name: "account named root without uid 0 may not",
forwardType: "remote",
port: 22,
username: "root",
uid: "1000",
expectError: true,
errorMsg: "cannot bind to privileged port",
skipOnWindows: true,
},
{ {
name: "local forward privileged port allowed for non-root", name: "local forward privileged port allowed for non-root",
forwardType: "local", forwardType: "local",
port: 80, port: 80,
username: "testuser", username: "testuser",
uid: "1000",
expectError: false, expectError: false,
}, },
} }
@@ -326,7 +299,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
result := PrivilegeCheckResult{ result := PrivilegeCheckResult{
Allowed: true, Allowed: true,
User: &user.User{Username: tt.username, Uid: tt.uid}, User: &user.User{Username: tt.username},
} }
err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result) err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result)
@@ -447,13 +420,6 @@ func TestServer_PortConflictHandling(t *testing.T) {
func TestServer_IsPrivilegedUser(t *testing.T) { func TestServer_IsPrivilegedUser(t *testing.T) {
// Windows classification depends on account SIDs and group membership, and
// the accounts involved carry localized, renameable names. It is covered by
// TestIsWindowsAccountPrivileged, which resolves them from well-known SIDs.
if runtime.GOOS == "windows" {
t.Skip("covered by TestIsWindowsAccountPrivileged")
}
tests := []struct { tests := []struct {
username string username string
expected bool expected bool
@@ -474,16 +440,44 @@ func TestServer_IsPrivilegedUser(t *testing.T) {
expected: false, expected: false,
description: "empty username should not be privileged", description: "empty username should not be privileged",
}, },
{ }
username: "Administrator",
expected: false, // Add Windows-specific tests
description: "Administrator should not be privileged on non-Windows systems", if runtime.GOOS == "windows" {
}, tests = append(tests, []struct {
username string
expected bool
description string
}{
{
username: "Administrator",
expected: true,
description: "Administrator should be considered privileged on Windows",
},
{
username: "administrator",
expected: true,
description: "administrator should be considered privileged on Windows (case insensitive)",
},
}...)
} else {
// On non-Windows systems, Administrator should not be privileged
tests = append(tests, []struct {
username string
expected bool
description string
}{
{
username: "Administrator",
expected: false,
description: "Administrator should not be privileged on non-Windows systems",
},
}...)
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) { t.Run(tt.description, func(t *testing.T) {
result := isPrivilegedOrUnknown(tt.username) result := isPrivilegedUsername(tt.username)
assert.Equal(t, tt.expected, result, tt.description) assert.Equal(t, tt.expected, result, tt.description)
}) })
} }

View File

@@ -17,7 +17,7 @@ import (
// createSftpCommand creates a Windows SFTP command with user switching. // createSftpCommand creates a Windows SFTP command with user switching.
// The caller must close the returned token handle after starting the process. // The caller must close the returned token handle after starting the process.
func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, windows.Token, error) { func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, windows.Token, error) {
username, domain := parseUsername(targetUser.Username) username, domain := s.parseUsername(targetUser.Username)
netbirdPath, err := os.Executable() netbirdPath, err := os.Executable()
if err != nil { if err != nil {

View File

@@ -16,6 +16,11 @@ var (
ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges") ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges")
) )
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
func isPlatformUnix() bool {
return getCurrentOS() != "windows"
}
// Dependency injection variables for testing - allows mocking dynamic runtime checks // Dependency injection variables for testing - allows mocking dynamic runtime checks
var ( var (
getCurrentUser = currentUserWithGetent getCurrentUser = currentUserWithGetent
@@ -24,9 +29,6 @@ var (
getIsProcessPrivileged = isCurrentProcessPrivileged getIsProcessPrivileged = isCurrentProcessPrivileged
getEuid = os.Geteuid getEuid = os.Geteuid
getProcessElevated = isProcessElevated
getWindowsAccountPrivilegedOrUnknown = isWindowsAccountPrivilegedOrUnknown
) )
const ( const (
@@ -63,13 +65,6 @@ type PrivilegeCheckResult struct {
RequiresUserSwitching bool RequiresUserSwitching bool
} }
// privilegeCheckContext holds all context needed for privilege checking
type privilegeCheckContext struct {
currentUser *user.User
currentUserPrivileged bool
allowRoot bool
}
// CheckPrivileges performs comprehensive privilege checking for all SSH features. // CheckPrivileges performs comprehensive privilege checking for all SSH features.
// This is the single source of truth for privilege decisions across the SSH server. // This is the single source of truth for privilege decisions across the SSH server.
func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult { func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult {
@@ -80,7 +75,7 @@ func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult
// Handle empty username case - but still check root access controls // Handle empty username case - but still check root access controls
if req.RequestedUsername == "" { if req.RequestedUsername == "" {
if isPrivilegedOrUnknown(context.currentUser.Username) && !context.allowRoot { if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot {
return PrivilegeCheckResult{ return PrivilegeCheckResult{
Allowed: false, Allowed: false,
Error: &PrivilegedUserError{Username: context.currentUser.Username}, Error: &PrivilegedUserError{Username: context.currentUser.Username},
@@ -140,7 +135,7 @@ func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheck
needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser) needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser)
if isPrivilegedOrUnknown(resolvedUser.Username) && !ctx.allowRoot { if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot {
return PrivilegeCheckResult{ return PrivilegeCheckResult{
Allowed: false, Allowed: false,
Error: &PrivilegedUserError{Username: resolvedUser.Username}, Error: &PrivilegedUserError{Username: resolvedUser.Username},
@@ -180,42 +175,6 @@ func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, err
return u, nil return u, nil
} }
// SetAllowRootLogin configures root login access
func (s *Server) SetAllowRootLogin(allow bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.allowRootLogin = allow
}
// userNameLookup performs user lookup with root login permission check
func (s *Server) userNameLookup(username string) (*user.User, error) {
result, err := s.userPrivilegeCheck(username)
if err != nil {
return nil, err
}
return result.User, nil
}
// userPrivilegeCheck performs user lookup with full privilege check result
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return result, result.Error
}
return result, nil
}
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
func isPlatformUnix() bool {
return getCurrentOS() != "windows"
}
// isSameResolvedUser compares two resolved user identities // isSameResolvedUser compares two resolved user identities
func isSameResolvedUser(user1, user2 *user.User) bool { func isSameResolvedUser(user1, user2 *user.User) bool {
if user1 == nil || user2 == nil { if user1 == nil || user2 == nil {
@@ -224,6 +183,13 @@ func isSameResolvedUser(user1, user2 *user.User) bool {
return user1.Uid == user2.Uid return user1.Uid == user2.Uid
} }
// privilegeCheckContext holds all context needed for privilege checking
type privilegeCheckContext struct {
currentUser *user.User
currentUserPrivileged bool
allowRoot bool
}
// isSameUser checks if two usernames refer to the same user // isSameUser checks if two usernames refer to the same user
// SECURITY: This function must be conservative - it should only return true // SECURITY: This function must be conservative - it should only return true
// when we're certain both usernames refer to the exact same user identity // when we're certain both usernames refer to the exact same user identity
@@ -287,30 +253,159 @@ func isWindowsSameUser(requestedUsername, currentUsername string) bool {
return strings.EqualFold(reqDomain, curDomain) return strings.EqualFold(reqDomain, curDomain)
} }
// isPrivilegedOrUnknown reports whether the given username represents a // SetAllowRootLogin configures root login access
// privileged user, or on Windows an account whose privilege could not be func (s *Server) SetAllowRootLogin(allow bool) {
// determined. s.mu.Lock()
// On Unix: root. defer s.mu.Unlock()
// On Windows: well-known service accounts, built-in Administrator accounts, s.allowRootLogin = allow
// and members of the local Administrators group; handles domain-qualified }
// usernames like "DOMAIN\user" or "user@domain.com". An account that cannot be
// resolved or evaluated is reported as privileged. // userNameLookup performs user lookup with root login permission check
// func (s *Server) userNameLookup(username string) (*user.User, error) {
// Use this to refuse privileged accounts, never to grant them anything: the result := s.CheckPrivileges(PrivilegeCheckRequest{
// undetermined case is safe for a refusal and unsafe for a grant. RequestedUsername: username,
func isPrivilegedOrUnknown(username string) bool { FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return nil, result.Error
}
return result.User, nil
}
// userPrivilegeCheck performs user lookup with full privilege check result
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return result, result.Error
}
return result, nil
}
// isPrivilegedUsername checks if the given username represents a privileged user across platforms.
// On Unix: root
// On Windows: Administrator, SYSTEM (case-insensitive)
// Handles domain-qualified usernames like "DOMAIN\Administrator" or "user@domain.com"
func isPrivilegedUsername(username string) bool {
if getCurrentOS() != "windows" { if getCurrentOS() != "windows" {
return username == "root" return username == "root"
} }
return getWindowsAccountPrivilegedOrUnknown(username)
bareUsername := username
// Handle Windows domain format: DOMAIN\username
if idx := strings.LastIndex(username, `\`); idx != -1 {
bareUsername = username[idx+1:]
}
// Handle email-style format: username@domain.com
if idx := strings.Index(bareUsername, "@"); idx != -1 {
bareUsername = bareUsername[:idx]
}
return isWindowsPrivilegedUser(bareUsername)
}
// isWindowsPrivilegedUser checks if a bare username (domain already stripped) represents a Windows privileged account
func isWindowsPrivilegedUser(bareUsername string) bool {
// common privileged usernames (case insensitive)
privilegedNames := []string{
"administrator",
"admin",
"root",
"system",
"localsystem",
"networkservice",
"localservice",
}
usernameLower := strings.ToLower(bareUsername)
for _, privilegedName := range privilegedNames {
if usernameLower == privilegedName {
return true
}
}
// computer accounts (ending with $) are not privileged by themselves
// They only gain privileges through group membership or specific SIDs
if targetUser, err := lookupUser(bareUsername); err == nil {
return isWindowsPrivilegedSID(targetUser.Uid)
}
return false
}
// isWindowsPrivilegedSID checks if a Windows SID represents a privileged account
func isWindowsPrivilegedSID(sid string) bool {
privilegedSIDs := []string{
"S-1-5-18", // Local System (SYSTEM)
"S-1-5-19", // Local Service (NT AUTHORITY\LOCAL SERVICE)
"S-1-5-20", // Network Service (NT AUTHORITY\NETWORK SERVICE)
"S-1-5-32-544", // Administrators group (BUILTIN\Administrators)
"S-1-5-500", // Built-in Administrator account (local machine RID 500)
}
for _, privilegedSID := range privilegedSIDs {
if sid == privilegedSID {
return true
}
}
// Check for domain administrator accounts (RID 500 in any domain)
// Format: S-1-5-21-domain-domain-domain-500
// This is reliable as RID 500 is reserved for the domain Administrator account
if strings.HasPrefix(sid, "S-1-5-21-") && strings.HasSuffix(sid, "-500") {
return true
}
// Check for other well-known privileged RIDs in domain contexts
// RID 512 = Domain Admins group, RID 516 = Domain Controllers group
if strings.HasPrefix(sid, "S-1-5-21-") {
if strings.HasSuffix(sid, "-512") || // Domain Admins group
strings.HasSuffix(sid, "-516") || // Domain Controllers group
strings.HasSuffix(sid, "-519") { // Enterprise Admins group
return true
}
}
return false
} }
// isCurrentProcessPrivileged checks if the current process is running with elevated privileges. // isCurrentProcessPrivileged checks if the current process is running with elevated privileges.
// On Unix systems, this means running as root (UID 0). // On Unix systems, this means running as root (UID 0).
// On Windows, this means the process token is elevated (administrators, SYSTEM). // On Windows, this means running as Administrator or SYSTEM.
func isCurrentProcessPrivileged() bool { func isCurrentProcessPrivileged() bool {
if getCurrentOS() == "windows" { if getCurrentOS() == "windows" {
return getProcessElevated() return isWindowsElevated()
} }
return getEuid() == 0 return getEuid() == 0
} }
// isWindowsElevated checks if the current process is running with elevated privileges on Windows
func isWindowsElevated() bool {
currentUser, err := getCurrentUser()
if err != nil {
log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err)
return false
}
if isWindowsPrivilegedSID(currentUser.Uid) {
log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid)
return true
}
if isPrivilegedUsername(currentUser.Username) {
log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username)
return true
}
log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid)
return false
}

View File

@@ -4,7 +4,6 @@ import (
"errors" "errors"
"os/user" "os/user"
"runtime" "runtime"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -28,8 +27,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
originalLookupUser := lookupUser originalLookupUser := lookupUser
originalGetCurrentOS := getCurrentOS originalGetCurrentOS := getCurrentOS
originalGetEuid := getEuid originalGetEuid := getEuid
originalGetProcessElevated := getProcessElevated
originalGetWindowsAccountPrivilegedOrUnknown := getWindowsAccountPrivilegedOrUnknown // Reset caches to ensure clean test state
// Set test values - inject platform dependencies // Set test values - inject platform dependencies
getCurrentUser = func() (*user.User, error) { getCurrentUser = func() (*user.User, error) {
@@ -54,31 +53,16 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
return euid return euid
} }
// Simulate the Windows token elevation check based on the fixture user: // Mock privilege detection based on the test user
// the built-in Administrator (RID 500) and SYSTEM run elevated. getIsProcessPrivileged = func() bool {
getProcessElevated = func() bool {
if currentUser == nil { if currentUser == nil {
return false return false
} }
return currentUser.Uid == "S-1-5-18" || strings.HasSuffix(currentUser.Uid, "-500") // Check both username and SID for Windows systems
} if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) {
// Simulate the Windows account classifier for the fixture accounts.
// "root" does not exist on Windows; the real classifier fails closed on
// unresolvable accounts, so it counts as privileged here too.
getWindowsAccountPrivilegedOrUnknown = func(username string) bool {
bare := username
if idx := strings.LastIndex(bare, `\`); idx != -1 {
bare = bare[idx+1:]
}
if idx := strings.Index(bare, "@"); idx != -1 {
bare = bare[:idx]
}
switch strings.ToLower(bare) {
case "administrator", "system", "root":
return true return true
} }
return false return isPrivilegedUsername(currentUser.Username)
} }
// Return cleanup function // Return cleanup function
@@ -87,8 +71,10 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
lookupUser = originalLookupUser lookupUser = originalLookupUser
getCurrentOS = originalGetCurrentOS getCurrentOS = originalGetCurrentOS
getEuid = originalGetEuid getEuid = originalGetEuid
getProcessElevated = originalGetProcessElevated
getWindowsAccountPrivilegedOrUnknown = originalGetWindowsAccountPrivilegedOrUnknown getIsProcessPrivileged = isCurrentProcessPrivileged
// Reset caches after test
} }
} }
@@ -435,9 +421,6 @@ func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) {
} }
func TestPrivilegedUsernameDetection(t *testing.T) { func TestPrivilegedUsernameDetection(t *testing.T) {
// Windows classification is syscall-backed (SID resolution, group
// membership) and is covered by privileges_windows_test.go; here only the
// Unix logic and the platform dispatch are exercised.
tests := []struct { tests := []struct {
name string name string
username string username string
@@ -449,9 +432,25 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
{"unix_regular_user", "alice", "linux", false}, {"unix_regular_user", "alice", "linux", false},
{"unix_root_capital", "Root", "linux", false}, // Case-sensitive {"unix_root_capital", "Root", "linux", false}, // Case-sensitive
// Windows dispatch to the (mocked) account classifier // Windows tests
{"windows_administrator", "Administrator", "windows", true}, {"windows_administrator", "Administrator", "windows", true},
{"windows_system", "SYSTEM", "windows", true},
{"windows_admin", "admin", "windows", true},
{"windows_admin_lowercase", "administrator", "windows", true}, // Case-insensitive
{"windows_domain_admin", "DOMAIN\\Administrator", "windows", true},
{"windows_email_admin", "admin@domain.com", "windows", true},
{"windows_regular_user", "alice", "windows", false}, {"windows_regular_user", "alice", "windows", false},
{"windows_domain_user", "DOMAIN\\alice", "windows", false},
{"windows_localsystem", "localsystem", "windows", true},
{"windows_networkservice", "networkservice", "windows", true},
{"windows_localservice", "localservice", "windows", true},
// Computer accounts (these depend on current user context in real implementation)
{"windows_computer_account", "WIN2K19-C2$", "windows", false}, // Computer account by itself not privileged
{"windows_domain_computer", "DOMAIN\\COMPUTER$", "windows", false}, // Domain computer account
// Cross-platform
{"root_on_windows", "root", "windows", true}, // Root should be privileged everywhere
} }
for _, tt := range tests { for _, tt := range tests {
@@ -460,8 +459,50 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil) cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil)
defer cleanup() defer cleanup()
result := isPrivilegedOrUnknown(tt.username) result := isPrivilegedUsername(tt.username)
assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform) assert.Equal(t, tt.privileged, result)
})
}
}
func TestWindowsPrivilegedSIDDetection(t *testing.T) {
tests := []struct {
name string
sid string
privileged bool
description string
}{
// Well-known system accounts
{"system_account", "S-1-5-18", true, "Local System (SYSTEM)"},
{"local_service", "S-1-5-19", true, "Local Service"},
{"network_service", "S-1-5-20", true, "Network Service"},
{"administrators_group", "S-1-5-32-544", true, "Administrators group"},
{"builtin_administrator", "S-1-5-500", true, "Built-in Administrator"},
// Domain accounts
{"domain_administrator", "S-1-5-21-1234567890-1234567890-1234567890-500", true, "Domain Administrator (RID 500)"},
{"domain_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-512", true, "Domain Admins group"},
{"domain_controllers_group", "S-1-5-21-1234567890-1234567890-1234567890-516", true, "Domain Controllers group"},
{"enterprise_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-519", true, "Enterprise Admins group"},
// Regular users
{"regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1001", false, "Regular domain user"},
{"another_regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1234", false, "Another regular user"},
{"local_user", "S-1-5-21-1234567890-1234567890-1234567890-1000", false, "Local regular user"},
// Groups that are not privileged
{"domain_users", "S-1-5-21-1234567890-1234567890-1234567890-513", false, "Domain Users group"},
{"power_users", "S-1-5-32-547", false, "Power Users group"},
// Invalid SIDs
{"malformed_sid", "S-1-5-invalid", false, "Malformed SID"},
{"empty_sid", "", false, "Empty SID"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isWindowsPrivilegedSID(tt.sid)
assert.Equal(t, tt.privileged, result, "Failed for %s: %s", tt.description, tt.sid)
}) })
} }
} }

View File

@@ -91,7 +91,7 @@ func validateUsernameFormat(username string) error {
func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) { func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) {
logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty) logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty)
username, _ := parseUsername(localUser.Username) username, _ := s.parseUsername(localUser.Username)
if err := validateUsername(username); err != nil { if err := validateUsername(username); err != nil {
return nil, nil, fmt.Errorf("invalid username %q: %w", username, err) return nil, nil, fmt.Errorf("invalid username %q: %w", username, err)
} }
@@ -102,7 +102,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
// createUserSwitchCommand creates a command with Windows user switching. // createUserSwitchCommand creates a command with Windows user switching.
// Returns the command and a cleanup function that must be called after starting the process. // Returns the command and a cleanup function that must be called after starting the process.
func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) { func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
username, domain := parseUsername(localUser.Username) username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid) shell := getUserShell(localUser.Uid)
@@ -138,7 +138,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session,
} }
// parseUsername extracts username and domain from a Windows username // parseUsername extracts username and domain from a Windows username
func parseUsername(fullUsername string) (username, domain string) { func (s *Server) parseUsername(fullUsername string) (username, domain string) {
// Handle DOMAIN\username format // Handle DOMAIN\username format
if idx := strings.LastIndex(fullUsername, `\`); idx != -1 { if idx := strings.LastIndex(fullUsername, `\`); idx != -1 {
domain = fullUsername[:idx] domain = fullUsername[:idx]

View File

@@ -46,10 +46,7 @@ func ParseDaemonStatus(s string) DaemonStatus {
// ConvertOptions holds parameters for ConvertToStatusOutputOverview. // ConvertOptions holds parameters for ConvertToStatusOutputOverview.
type ConvertOptions struct { 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 DaemonVersion string
DaemonStatus DaemonStatus DaemonStatus DaemonStatus
StatusFilter string StatusFilter string
@@ -220,7 +217,6 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
if opts.Anonymize { if opts.Anonymize {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(opts.AnonymizeLevel)
anonymizeOverview(anonymizer, &overview) anonymizeOverview(anonymizer, &overview)
} }
@@ -980,7 +976,6 @@ func timeAgo(t time.Time) string {
func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) { func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) {
peer.FQDN = a.AnonymizeDomain(peer.FQDN) peer.FQDN = a.AnonymizeDomain(peer.FQDN)
peer.PubKey = a.AnonymizeWGKey(peer.PubKey)
if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil { if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil {
peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port) peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port)
} }
@@ -1012,7 +1007,6 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL) overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL)
overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error) overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error)
overview.PubKey = a.AnonymizeWGKey(overview.PubKey)
overview.IP = a.AnonymizeIPString(overview.IP) overview.IP = a.AnonymizeIPString(overview.IP)
overview.IPv6 = a.AnonymizeIPString(overview.IPv6) overview.IPv6 = a.AnonymizeIPString(overview.IPv6)
for i, detail := range overview.Relays.Details { for i, detail := range overview.Relays.Details {

View File

@@ -71,12 +71,10 @@ type BundleOptions = {
hasWindow: boolean; hasWindow: boolean;
totalSec: number; totalSec: number;
uploadUrl: string; uploadUrl: string;
anonymizeLevel: AnonymizeLevel; anonymize: boolean;
systemInfo: boolean; systemInfo: boolean;
}; };
export type AnonymizeLevel = "none" | "default" | "strict";
const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => { const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
try { try {
// Mirror the CLI's safety margin: window + 30s, server caps at 10m. // Mirror the CLI's safety margin: window + 30s, server caps at 10m.
@@ -189,10 +187,7 @@ const runBundleFlow = async (
if (opts.uploadUrl) setStage({ kind: "uploading" }); if (opts.uploadUrl) setStage({ kind: "uploading" });
const result = await DebugSvc.Bundle({ const result = await DebugSvc.Bundle({
anonymize: opts.anonymizeLevel !== "none", anonymize: opts.anonymize,
// 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, systemInfo: opts.systemInfo,
uploadUrl: opts.uploadUrl, uploadUrl: opts.uploadUrl,
logFileCount, logFileCount,
@@ -203,7 +198,7 @@ const runBundleFlow = async (
}; };
const useDebugBundle = () => { const useDebugBundle = () => {
const [anonymizeLevel, setAnonymizeLevel] = useState<AnonymizeLevel>("none"); const [anonymize, setAnonymize] = useState(false);
const [systemInfo, setSystemInfo] = useState(true); const [systemInfo, setSystemInfo] = useState(true);
const [upload, setUpload] = useState(true); const [upload, setUpload] = useState(true);
const [trace, setTrace] = useState(true); const [trace, setTrace] = useState(true);
@@ -245,7 +240,7 @@ const useDebugBundle = () => {
hasWindow: capture && totalSec > 0, hasWindow: capture && totalSec > 0,
totalSec, totalSec,
uploadUrl: upload ? NETBIRD_UPLOAD_URL : "", uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
anonymizeLevel, anonymize,
systemInfo, systemInfo,
}; };
@@ -277,8 +272,8 @@ const useDebugBundle = () => {
}; };
return { return {
anonymizeLevel, anonymize,
setAnonymizeLevel, setAnonymize,
systemInfo, systemInfo,
setSystemInfo, setSystemInfo,
upload, 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 { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
import { LanguagePicker } from "@/components/LanguagePicker.tsx"; import { LanguagePicker } from "@/components/LanguagePicker.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
export function SettingsGeneral() { export function SettingsGeneral() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -20,7 +19,6 @@ export function SettingsGeneral() {
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } = const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
useManagementUrl(); useManagementUrl();
const { mdm, features } = useRestrictions(); const { mdm, features } = useRestrictions();
const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const managementUrlId = useId(); const managementUrlId = useId();
@@ -59,15 +57,6 @@ export function SettingsGeneral() {
helpText={t("settings.general.autostart.help")} 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> </SectionGroup>
{!mdm.managementURL && !features.disableUpdateSettings && ( {!mdm.managementURL && !features.disableUpdateSettings && (

View File

@@ -1,6 +1,6 @@
import { useId, type ReactNode } from "react"; import { useId, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react"; import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
import { Browser } from "@wailsio/runtime"; import { Browser } from "@wailsio/runtime";
import { Debug as DebugSvc } from "@bindings/services"; import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js"; import type { DebugBundleResult } from "@bindings/services/models.js";
@@ -8,22 +8,13 @@ import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions"; import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription"; import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading"; import { DialogHeading } from "@/components/dialog/DialogHeading";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import HelpText from "@/components/typography/HelpText.tsx"; import HelpText from "@/components/typography/HelpText.tsx";
import { Input } from "@/components/inputs/Input"; import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label"; import { Label } from "@/components/typography/Label";
import { SquareIcon } from "@/components/SquareIcon"; import { SquareIcon } from "@/components/SquareIcon";
import { Tooltip } from "@/components/Tooltip";
import { cn } from "@/lib/cn";
import { formatRemaining } from "@/lib/formatters"; import { formatRemaining } from "@/lib/formatters";
import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext"; import type { DebugStage } from "@/contexts/DebugBundleContext";
import { useDebugBundleContext } from "@/contexts/DebugBundleContext"; import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
@@ -33,8 +24,8 @@ export function SettingsTroubleshooting() {
const { t } = useTranslation(); const { t } = useTranslation();
const durationId = useId(); const durationId = useId();
const { const {
anonymizeLevel, anonymize,
setAnonymizeLevel, setAnonymize,
systemInfo, systemInfo,
setSystemInfo, setSystemInfo,
upload, upload,
@@ -64,71 +55,12 @@ export function SettingsTroubleshooting() {
return ( return (
<SectionGroup title={t("settings.troubleshooting.section.title")}> <SectionGroup title={t("settings.troubleshooting.section.title")}>
<div className={"flex items-center justify-between gap-6"}> <FancyToggleSwitch
<div className={"max-w-md flex-1"}> value={anonymize}
<Label as={"div"}> onChange={setAnonymize}
<span className={"inline-flex items-center gap-1.5"}> label={t("settings.troubleshooting.anonymize.label")}
{t("settings.troubleshooting.anonymize.label")} helpText={t("settings.troubleshooting.anonymize.help")}
<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 <FancyToggleSwitch
value={systemInfo} value={systemInfo}
onChange={setSystemInfo} onChange={setSystemInfo}

View File

@@ -2,24 +2,9 @@
A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating). A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating).
**Translations are managed on Crowdin: <https://crowdin.com/project/netbird>.** Join the project, pick your language, and translate in the editor. Each string carries a context note (the `description` from the source file) telling you what it is and where it shows up, and the project's glossary, style guide, and QA checks mirror this document. **Drive an agent with:** *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* — or *"…and review the existing German translation."*
> 💡 **The one habit that matters most:** read each string's context before translating it. Labels are terse and ambiguous on their own; the context tells you what the string is, where it shows up, what to keep verbatim, and what it actually means. > 💡 **The one habit that matters most:** read each key's `description` before translating it. Labels are terse and ambiguous on their own; the `description` tells you what the string is, where it shows up, what to keep verbatim, and what it actually means.
---
## How contributions flow
```text
i18n/locales/en/common.json ──sync──▶ Crowdin ──service PR──▶ i18n/locales/<code>/common.json
```
- `i18n/locales/en/common.json` is the source of truth. New and changed strings sync to Crowdin automatically (see `crowdin.yml` in the repository root).
- Crowdin opens and updates a service pull request with the translated bundles, keeping the source's file shape and key order. Keys nobody has translated yet are left out of the export; the app falls back to English for them at runtime. Maintainers review and merge that PR.
- Don't hand-edit `i18n/locales/<code>/common.json` in your own PRs: the next sync would conflict with or overwrite your changes. Translate on Crowdin instead.
- Missing your language? Request it on the Crowdin project page or in a [GitHub discussion](https://github.com/netbirdio/netbird/discussions). When a language first ships, a maintainer adds its row to `i18n/locales/_index.json` with `code`, `displayName` (the native name), and `englishName`, which puts it in the app's language picker.
**Prefer translating with an AI agent?** That still works: drive it with *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* as before, but deliver the result to Crowdin instead of a pull request. Download your language's file from the Crowdin editor, let the agent translate it, and upload it back (the editor's offline translation flow). Crowdin runs its QA checks on upload, and the next service PR carries the strings into the repo.
--- ---
@@ -45,6 +30,25 @@ A **business zero-trust VPN** — an encrypted **overlay mesh** between a compan
--- ---
## The files
```
i18n/locales/_index.json shipped-language list
i18n/locales/en/common.json source of truth — message + description
i18n/locales/<code>/common.json a target — message only
```
Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**.
| ✅ Do | ❌ Don't |
|---|---|
| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) |
| Put **only `message`** in target bundles | Copy `description` into a target bundle |
| Give every key a non-empty `message` | Leave keys missing or empty |
| Save valid UTF-8 JSON, no BOM | Add trailing commas or break the JSON |
---
## Hard rules — get these exactly right ## Hard rules — get these exactly right
These are the usual ways a translation *breaks the app*, not just reads oddly. These are the usual ways a translation *breaks the app*, not just reads oddly.
@@ -54,7 +58,7 @@ These are the usual ways a translation *breaks the app*, not just reads oddly.
| Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) | | Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) |
| Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder | | Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder |
| Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) | | Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) |
| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the context flags | | Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the description flags |
**Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply. **Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply.
@@ -74,15 +78,13 @@ When a brand sits beside a common noun, keep its exact spelling but join them th
> **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it. > **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it.
Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing translation:** match how a term was already rendered for your language rather than re-deciding it. Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing bundles:** match how a term was already rendered for your language rather than re-deciding it.
Two checks before you commit a term: Two checks before you commit a term:
- **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google). - **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google).
- **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it. - **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it.
These tiers are mirrored in the Crowdin project glossary, so the editor highlights them inline. When you settle a new Tier C term for your language, add its translation to the glossary entry so it sticks for everyone who comes after you.
--- ---
## Style ## Style
@@ -96,7 +98,7 @@ These tiers are mirrored in the Crowdin project glossary, so the editor highligh
Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them. Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them.
A few habits that keep a translation reading like one product rather than a word-for-word port: A few habits that keep a bundle reading like one product rather than a word-for-word port:
- **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque. - **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque.
- **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling. - **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling.
@@ -105,26 +107,27 @@ A few habits that keep a translation reading like one product rather than a word
--- ---
## Reviewing a language ## Procedure
**On Crowdin:** proofread in the editor — context, glossary highlights, and QA flags sit inline next to each string. **New language** — read `en/common.json` *with* descriptions → settle your Tier C terms → write `i18n/locales/<code>/common.json` (same keys and order as `en`, `message` only, placeholders & brands preserved) → add a row to `_index.json` (`{"code","displayName"` = native name`,"englishName"}`) → run the QA list. Use the locale-code style the existing entries use (e.g. `fr`, `pt`, `zh-CN`).
**In the repo** — e.g. driving an AI agent with *"Read `i18n/TRANSLATING.md` and review the existing German translation"* — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node``Exit Node`, hu `Kilépő csomópont``Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Report what you found, and apply the fixes **on Crowdin** — direct edits to the locale files are overwritten by the next sync. **Review (de / hu / …)** — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node``Exit Node`, hu `Kilépő csomópont``Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Fix in place, then report what you changed (especially term standardizations) so a native speaker can sanity-check.
--- ---
## QA before you finish ## QA before you finish
- [ ] Valid JSON · **every `en` key** present, same order · **no `description`** fields
- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept - [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept
- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing translation for your language) - [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing bundle for your language)
- [ ] Buttons & tray short · locale punctuation and capitalization applied - [ ] Buttons & tray short · locale punctuation and capitalization applied
- [ ] Crowdin QA flags resolved (variables, glossary terms, punctuation) - [ ] New language added to `_index.json`
- [ ] **Tested in the running app** - [ ] **Tested in the running app**
--- ---
## Test it in the app ## Test it in the app
A translation can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens. A bundle can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens.
How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step. How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step.

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": { "settings.general.autostart.errorTitle": {
"message": "Ändern des Autostarts fehlgeschlagen" "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": { "settings.general.language.label": {
"message": "Anzeigesprache" "message": "Anzeigesprache"
}, },

View File

@@ -735,14 +735,6 @@
"message": "Autostart Change Failed", "message": "Autostart Change Failed",
"description": "Error-dialog title when changing the autostart setting fails." "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": { "settings.general.language.label": {
"message": "Display Language", "message": "Display Language",
"description": "Label for the display-language picker." "description": "Label for the display-language picker."
@@ -1013,27 +1005,11 @@
}, },
"settings.troubleshooting.anonymize.label": { "settings.troubleshooting.anonymize.label": {
"message": "Anonymize Sensitive Information", "message": "Anonymize Sensitive Information",
"description": "Label for the anonymization level dropdown (None, Default, Strict)." "description": "Toggle label: anonymize sensitive information in the bundle."
}, },
"settings.troubleshooting.anonymize.help": { "settings.troubleshooting.anonymize.help": {
"message": "Hides IP addresses, domains, and other sensitive values.", "message": "Hides public IP addresses and non-NetBird domains from logs.",
"description": "Helper text under the anonymization dropdown. The level details live in the info tooltip." "description": "Helper text for anonymizing logs (hides public IPs and non-NetBird domains)."
},
"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": { "settings.troubleshooting.systemInfo.label": {
"message": "Include System Information", "message": "Include System Information",

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": { "settings.general.autostart.errorTitle": {
"message": "Error al cambiar el inicio automático" "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": { "settings.general.language.label": {
"message": "Idioma de la interfaz" "message": "Idioma de la interfaz"
}, },

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": { "settings.general.autostart.errorTitle": {
"message": "Échec de la modification du démarrage automatique" "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": { "settings.general.language.label": {
"message": "Langue daffichage" "message": "Langue daffichage"
}, },

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": { "settings.general.autostart.errorTitle": {
"message": "Az automatikus indítás módosítása sikertelen" "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": { "settings.general.language.label": {
"message": "Megjelenítési nyelv" "message": "Megjelenítési nyelv"
}, },

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": { "settings.general.autostart.errorTitle": {
"message": "Modifica avvio automatico non riuscita" "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": { "settings.general.language.label": {
"message": "Lingua dell'interfaccia" "message": "Lingua dell'interfaccia"
}, },

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": { "settings.general.autostart.errorTitle": {
"message": "自動起動の変更に失敗しました" "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": { "settings.general.language.label": {
"message": "表示言語" "message": "表示言語"
}, },

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": { "settings.general.autostart.errorTitle": {
"message": "Falha ao alterar o início automático" "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": { "settings.general.language.label": {
"message": "Idioma de exibição" "message": "Idioma de exibição"
}, },

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": { "settings.general.autostart.errorTitle": {
"message": "Не удалось изменить автозапуск" "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": { "settings.general.language.label": {
"message": "Язык интерфейса" "message": "Язык интерфейса"
}, },

View File

@@ -551,14 +551,6 @@
"settings.general.autostart.errorTitle": { "settings.general.autostart.errorTitle": {
"message": "更改自启动设置失败" "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": { "settings.general.language.label": {
"message": "显示语言" "message": "显示语言"
}, },

View File

@@ -180,7 +180,6 @@ func main() {
WindowManager: windowManager, WindowManager: windowManager,
Session: authSession, Session: authSession,
Localizer: localizer, Localizer: localizer,
Preferences: prefStore,
}) })
listenForShowSignal(context.Background(), tray) 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 // 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. // and is never reset, so the default-on flow runs at most once, ever.
AutostartInitialized bool `json:"autostartInitialized"` 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. // LanguageValidator rejects SetLanguage inputs with no shipped bundle.
@@ -187,26 +183,6 @@ func (s *Store) SetAutostartInitialized(done bool) error {
return nil 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. // SetLanguage validates, persists, and broadcasts. No-op if unchanged.
func (s *Store) SetLanguage(lang i18n.LanguageCode) error { func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
if lang == "" { 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") 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) { func TestStore_ExistedAtLoad(t *testing.T) {
withTempConfigDir(t) withTempConfigDir(t)

View File

@@ -49,11 +49,5 @@ func getCursorPosition(app *application.App) (application.Point, bool) {
if app == nil || app.Screen == nil { if app == nil || app.Screen == nil {
return p, true 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 return app.Screen.PhysicalToDipPoint(p), true
} }

View File

@@ -15,13 +15,10 @@ import (
) )
type DebugBundleParams struct { type DebugBundleParams struct {
Anonymize bool `json:"anonymize"` Anonymize bool `json:"anonymize"`
// AnonymizeLevel is "default" or "strict"; strict also anonymizes SystemInfo bool `json:"systemInfo"`
// private IP ranges, peer names, and WireGuard public keys. UploadURL string `json:"uploadUrl"`
AnonymizeLevel string `json:"anonymizeLevel"` LogFileCount uint32 `json:"logFileCount"`
SystemInfo bool `json:"systemInfo"`
UploadURL string `json:"uploadUrl"`
LogFileCount uint32 `json:"logFileCount"`
} }
// DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload // DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload
@@ -51,12 +48,11 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
return DebugBundleResult{}, err return DebugBundleResult{}, err
} }
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{ resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
Anonymize: p.Anonymize, Anonymize: p.Anonymize,
AnonymizeLevel: p.AnonymizeLevel, SystemInfo: p.SystemInfo,
SystemInfo: p.SystemInfo, UploadURL: p.UploadURL,
UploadURL: p.UploadURL, LogFileCount: p.LogFileCount,
LogFileCount: p.LogFileCount, CliVersion: version.NetbirdVersion(),
CliVersion: version.NetbirdVersion(),
}) })
if err != nil { if err != nil {
return DebugBundleResult{}, err 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 { func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
return s.store.SetOnboardingCompleted(done) 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/authsession"
"github.com/netbirdio/netbird/client/ui/i18n" "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/client/ui/services"
"github.com/netbirdio/netbird/version" "github.com/netbirdio/netbird/version"
) )
@@ -51,9 +50,8 @@ type TrayServices struct {
WindowManager *services.WindowManager WindowManager *services.WindowManager
// Session is bound to authsession directly because the services wrapper // Session is bound to authsession directly because the services wrapper
// only re-exposes the React subset. // only re-exposes the React subset.
Session *authsession.Session Session *authsession.Session
Localizer *Localizer Localizer *Localizer
Preferences *preferences.Store
} }
type Tray struct { type Tray struct {
@@ -463,12 +461,10 @@ func (t *Tray) handleQuit() {
t.profileMu.Unlock() t.profileMu.Unlock()
t.svc.DaemonFeed.CancelProfileSwitch() t.svc.DaemonFeed.CancelProfileSwitch()
if t.svc.Preferences == nil || !t.svc.Preferences.Get().KeepConnectedOnQuit { ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout) defer cancel()
defer cancel() if err := t.svc.Connection.Down(ctx); err != nil {
if err := t.svc.Connection.Down(ctx); err != nil { log.Errorf("disconnect on quit: %v", err)
log.Errorf("disconnect on quit: %v", err)
}
} }
t.app.Quit() 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 package main

View File

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

View File

@@ -1,11 +0,0 @@
skip_untranslated_strings: true
skip_untranslated_files: true
import_eq_suggestions: true
files:
- source: /client/ui/i18n/locales/en/common.json
translation: /client/ui/i18n/locales/%two_letters_code%/common.json
type: chrome
languages_mapping:
two_letters_code:
zh-CN: zh-CN

View File

@@ -438,10 +438,14 @@ func TestProvidersMatrix(t *testing.T) {
// Create every provider, all enabled, each with a unique model string so the // Create every provider, all enabled, each with a unique model string so the
// proxy's connect-time snapshot carries them all and model→provider routing // proxy's connect-time snapshot carries them all and model→provider routing
// is unambiguous (provider toggles after connect don't reconcile to the // is unambiguous (provider toggles after connect don't reconcile to the
// proxy, so we enable everything up front). // proxy, so we enable everything up front). The first create bootstraps the
// cluster.
ids := make([]string, 0, len(matrix)) ids := make([]string, 0, len(matrix))
for _, pc := range matrix { for i, pc := range matrix {
req := providerRequest(pc) req := providerRequest(pc)
if i == 0 {
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
}
prov, perr := srv.CreateProvider(ctx, req) prov, perr := srv.CreateProvider(ctx, req)
require.NoError(t, perr, "create provider %s", pc.name) require.NoError(t, perr, "create provider %s", pc.name)
ids = append(ids, prov.Id) ids = append(ids, prov.Id)

View File

@@ -82,12 +82,13 @@ func provisionPricedProvider(t *testing.T, ctx context.Context, name string, mod
// need NOT be in the catalog — the operator names it and prices it here. // need NOT be in the catalog — the operator names it and prices it here.
dummyKey := "sk-price-e2e" dummyKey := "sk-price-e2e"
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
Name: name, Name: name,
ProviderId: "openai_api", ProviderId: "openai_api",
UpstreamUrl: vllm.URL, UpstreamUrl: vllm.URL,
ApiKey: &dummyKey, ApiKey: &dummyKey,
Enabled: ptr(true), Enabled: ptr(true),
Models: &models, BootstrapCluster: ptr(harness.AgentNetworkCluster),
Models: &models,
}) })
require.NoError(t, err, "create provider") require.NoError(t, err, "create provider")
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })

View File

@@ -113,14 +113,15 @@ func runPathRoutedGuardrailCase(t *testing.T, tc pathRoutedGuardrailCase) {
// Catch-all provider (no models) so the router forwards any model; a static // Catch-all provider (no models) so the router forwards any model; a static
// bearer key means the router injects a static auth header instead of minting // bearer key means the router injects a static auth header instead of minting
// a GCP token. // a GCP token. Bootstraps the cluster if it isn't already.
staticKey := "static-e2e-token" staticKey := "static-e2e-token"
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
Name: tc.name, Name: tc.name,
ProviderId: tc.catalogID, ProviderId: tc.catalogID,
UpstreamUrl: vllm.URL, UpstreamUrl: vllm.URL,
ApiKey: &staticKey, ApiKey: &staticKey,
Enabled: ptr(true), Enabled: ptr(true),
BootstrapCluster: ptr(harness.AgentNetworkCluster),
}) })
require.NoError(t, err, "create %s provider", tc.name) require.NoError(t, err, "create %s provider", tc.name)
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })

View File

@@ -73,6 +73,7 @@ func TestGuardrailGroupSwitchTakesEffectAfterTTL(t *testing.T) {
{Id: modelA, InputPer1k: 0.001, OutputPer1k: 0.001}, {Id: modelA, InputPer1k: 0.001, OutputPer1k: 0.001},
{Id: modelB, InputPer1k: 0.001, OutputPer1k: 0.001}, {Id: modelB, InputPer1k: 0.001, OutputPer1k: 0.001},
}, },
BootstrapCluster: ptr(harness.AgentNetworkCluster),
}) })
require.NoError(t, err, "create provider") require.NoError(t, err, "create provider")
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })

View File

@@ -61,14 +61,15 @@ func TestGuardrailMultiPolicyModelAllowlist(t *testing.T) {
} }
// pRestricted declares the two guardrailed models so routing is deterministic // pRestricted declares the two guardrailed models so routing is deterministic
// (model -> provider). // (model -> provider). Created first, so it carries the bootstrap cluster.
pRestricted, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ pRestricted, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
Name: "restricted", Name: "restricted",
ProviderId: "openai_api", ProviderId: "openai_api",
UpstreamUrl: vllm.URL, UpstreamUrl: vllm.URL,
ApiKey: &staticKey, ApiKey: &staticKey,
Enabled: ptr(true), Enabled: ptr(true),
Models: models(modelSelected, modelOther), Models: models(modelSelected, modelOther),
BootstrapCluster: ptr(harness.AgentNetworkCluster),
}) })
require.NoError(t, err, "create restricted provider") require.NoError(t, err, "create restricted provider")
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pRestricted.Id) }) t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pRestricted.Id) })

View File

@@ -115,7 +115,7 @@ func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) {
staticKey := "static-e2e-token" staticKey := "static-e2e-token"
enabled := true enabled := true
for _, c := range cases { for i, c := range cases {
req := api.AgentNetworkProviderRequest{ req := api.AgentNetworkProviderRequest{
Name: "e2e-pergroup-" + c.name, Name: "e2e-pergroup-" + c.name,
ProviderId: c.catalogID, ProviderId: c.catalogID,
@@ -124,6 +124,9 @@ func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) {
Enabled: ptr(true), Enabled: ptr(true),
Models: c.models, Models: c.models,
} }
if i == 0 {
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
}
prov, perr := srv.CreateProvider(ctx, req) prov, perr := srv.CreateProvider(ctx, req)
require.NoError(t, perr, "create provider %s", c.name) require.NoError(t, perr, "create provider %s", c.name)
c.providerID = prov.Id c.providerID = prov.Id
@@ -280,12 +283,13 @@ func TestGuardrailMultiGroupUser(t *testing.T) {
// P1 — union scenario: two restricting policies, one per group. // P1 — union scenario: two restricting policies, one per group.
p1, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ p1, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
Name: "e2e-mg-union", Name: "e2e-mg-union",
ProviderId: "openai_api", ProviderId: "openai_api",
UpstreamUrl: vllm.URL, UpstreamUrl: vllm.URL,
ApiKey: &staticKey, ApiKey: &staticKey,
Enabled: ptr(true), Enabled: ptr(true),
Models: priced(unionA, unionB, unionC), Models: priced(unionA, unionB, unionC),
BootstrapCluster: ptr(harness.AgentNetworkCluster),
}) })
require.NoError(t, err, "create union provider") require.NoError(t, err, "create union provider")
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p1.Id) }) t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p1.Id) })

View File

@@ -115,11 +115,14 @@ func TestModelAllowlistEnforced(t *testing.T) {
}) })
require.NoError(t, err, "mint setup key") require.NoError(t, err, "mint setup key")
// Providers with their configured (allowed) models // Providers with their configured (allowed) models; the first bootstraps the cluster.
ids := make([]string, 0, len(providers)) ids := make([]string, 0, len(providers))
allowed := make([]string, 0, len(providers)) allowed := make([]string, 0, len(providers))
for _, pc := range providers { for i, pc := range providers {
req := providerRequest(pc) req := providerRequest(pc)
if i == 0 {
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
}
prov, perr := srv.CreateProvider(ctx, req) prov, perr := srv.CreateProvider(ctx, req)
require.NoError(t, perr, "create provider %s", pc.name) require.NoError(t, perr, "create provider %s", pc.name)
id := prov.Id id := prov.Id

View File

@@ -14,7 +14,6 @@ import (
"time" "time"
"github.com/netbirdio/netbird/e2e/harness" "github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
) )
// srv is the shared combined server for the package, ready (PAT-authenticated) // srv is the shared combined server for the package, ready (PAT-authenticated)
@@ -43,14 +42,5 @@ func run(m *testing.M) int {
return 1 return 1
} }
// Bootstrap the account's agent-network endpoint once for the package:
// providers no longer have settings side effects, and every data-plane
// test expects the shared account pinned to the combined proxy cluster.
cluster := harness.AgentNetworkCluster
if _, err := srv.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ProxyAddress: &cluster}); err != nil {
fmt.Fprintf(os.Stderr, "e2e: bootstrap agent-network endpoint: %v\n", err)
return 1
}
return m.Run() return m.Run()
} }

View File

@@ -21,10 +21,11 @@ func ptr[T any](v T) *T { return &v }
func newProvider(t *testing.T, ctx context.Context, name string) api.AgentNetworkProvider { func newProvider(t *testing.T, ctx context.Context, name string) api.AgentNetworkProvider {
t.Helper() t.Helper()
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
Name: name, Name: name,
ProviderId: "openai_api", ProviderId: "openai_api",
UpstreamUrl: "https://api.openai.com", UpstreamUrl: "https://api.openai.com",
ApiKey: ptr("sk-dummy-e2e-key"), ApiKey: ptr("sk-dummy-e2e-key"),
BootstrapCluster: ptr("eu.proxy.netbird.test"),
}) })
require.NoError(t, err, "create provider %q", name) require.NoError(t, err, "create provider %q", name)
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
@@ -56,11 +57,17 @@ func TestProviderLifecycle(t *testing.T) {
}} }}
} }
for _, pc := range cases { for i, pc := range cases {
pc := pc i, pc := i, pc
t.Run(pc.name, func(t *testing.T) { t.Run(pc.name, func(t *testing.T) {
req := providerRequest(pc) req := providerRequest(pc)
req.Name = "lc-" + pc.name req.Name = "lc-" + pc.name
// Bootstrap the cluster on the first create in case the matrix has
// not run (e.g. no provider keys → settings not yet bootstrapped).
if i == 0 {
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
}
prov, err := srv.CreateProvider(ctx, req) prov, err := srv.CreateProvider(ctx, req)
require.NoError(t, err, "create %s provider", pc.name) require.NoError(t, err, "create %s provider", pc.name)
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
@@ -130,65 +137,45 @@ func TestProviderValidation(t *testing.T) {
requireClientError(t, err) requireClientError(t, err)
} }
// TestSettingsRoundTrip flips the collection toggles and confirms the // TestSettingsRoundTrip flips the collection toggles and confirms cluster /
// endpoint and proxy address stay immutable, then restores the original // subdomain stay immutable, then restores the original state.
// state. A second bootstrap attempt must be rejected as a conflict.
func TestSettingsRoundTrip(t *testing.T) { func TestSettingsRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// The package's TestMain bootstrapped the shared account's endpoint. // Settings are bootstrapped on first provider create.
newProvider(t, ctx, "Settings Bootstrap")
before, err := srv.GetSettings(ctx) before, err := srv.GetSettings(ctx)
require.NoError(t, err, "get settings") require.NoError(t, err, "get settings")
require.NotEmpty(t, before.Endpoint, "settings must carry the bootstrapped endpoint") require.NotEmpty(t, before.Cluster, "settings must carry an assigned cluster")
require.NotEmpty(t, before.ProxyAddress, "settings must carry the bootstrapped proxy address")
require.NotNil(t, before.AccessLogRetentionDays, "bootstrapped settings must carry a retention")
beforeRetention := *before.AccessLogRetentionDays
flipped, err := srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ flipped, err := srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: before.Endpoint,
ProxyAddress: before.ProxyAddress,
EnableLogCollection: !before.EnableLogCollection, EnableLogCollection: !before.EnableLogCollection,
EnablePromptCollection: !before.EnablePromptCollection, EnablePromptCollection: !before.EnablePromptCollection,
RedactPii: !before.RedactPii, RedactPii: !before.RedactPii,
AccessLogRetentionDays: beforeRetention,
}) })
require.NoError(t, err, "update settings") require.NoError(t, err, "update settings")
assert.Equal(t, !before.EnableLogCollection, flipped.EnableLogCollection, "log collection toggle must flip") assert.Equal(t, !before.EnableLogCollection, flipped.EnableLogCollection, "log collection toggle must flip")
assert.Equal(t, !before.EnablePromptCollection, flipped.EnablePromptCollection, "prompt collection toggle must flip") assert.Equal(t, !before.EnablePromptCollection, flipped.EnablePromptCollection, "prompt collection toggle must flip")
require.NotNil(t, flipped.AccessLogRetentionDays) assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
assert.Equal(t, beforeRetention, *flipped.AccessLogRetentionDays, assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
"retention sent unchanged must round-trip, not reset to the zero value")
assert.Equal(t, before.Endpoint, flipped.Endpoint, "endpoint must be immutable across updates")
assert.Equal(t, before.ProxyAddress, flipped.ProxyAddress, "proxy address must be immutable across updates")
// The account is already bootstrapped: a second bootstrap is a conflict, // A cluster different from the pinned one must be rejected; echoing the
// whatever shape it asks for. // pinned one back is valid.
_, err = srv.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
Endpoint: ptr("attacker.cluster.invalid"),
})
requireClientError(t, err)
// The identity fields ride along on the PUT as a required echo: a request
// carrying a different endpoint is rejected without applying anything.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ _, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: "other.cluster.invalid", Cluster: ptr("attacker.cluster.invalid"),
ProxyAddress: before.ProxyAddress,
EnableLogCollection: before.EnableLogCollection, EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection, EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii, RedactPii: before.RedactPii,
AccessLogRetentionDays: beforeRetention,
}) })
requireClientError(t, err) requireClientError(t, err)
// Restore the original toggles. // Restore the original toggles.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ _, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: before.Endpoint, Cluster: ptr(before.Cluster),
ProxyAddress: before.ProxyAddress,
EnableLogCollection: before.EnableLogCollection, EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection, EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii, RedactPii: before.RedactPii,
AccessLogRetentionDays: beforeRetention,
}) })
require.NoError(t, err, "restore settings") require.NoError(t, err, "restore settings")
} }

View File

@@ -4,7 +4,6 @@ package agentnetwork
import ( import (
"context" "context"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -15,8 +14,7 @@ import (
) )
// harnessStartFresh boots a dedicated combined server with its own fresh // harnessStartFresh boots a dedicated combined server with its own fresh
// account and registers its teardown on t. Unlike the shared srv, the fresh // account and registers its teardown on t.
// account has NOT had its agent-network endpoint bootstrapped.
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) { func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
t.Helper() t.Helper()
fresh, err := harness.StartCombined(ctx) fresh, err := harness.StartCombined(ctx)
@@ -30,16 +28,16 @@ func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, er
return fresh, nil return fresh, nil
} }
// TestSettingsBootstrapViaPost covers the explicit bootstrap contract on an // TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
// account that has never been bootstrapped: the GET reads as the defaults // account that has never been bootstrapped: the GET reads as the defaults
// with an empty endpoint/proxy_address, a PUT has no row to update and fails, // with an empty cluster/subdomain/endpoint, a PUT without a cluster has
// and a POST creates the row and assigns the immutable endpoint — labeled // nothing to pin and fails, and a PUT carrying a cluster creates the row and
// beneath a proxy address here, with the toggle overrides from the same // pins it immutably. The shared srv cannot provide that starting state (any
// request applied. The shared srv cannot provide that starting state // provider-creating test bootstraps it, and test order is deliberately not
// (TestMain bootstraps it), so this boots a dedicated combined server — the // relied on), so this boots a dedicated combined server — the image is
// image is already built and cached by TestMain's StartCombined, so the extra // already built and cached by TestMain's StartCombined, so the extra cost is
// cost is one container start. // one container start.
func TestSettingsBootstrapViaPost(t *testing.T) { func TestSettingsBootstrapViaPut(t *testing.T) {
ctx := context.Background() ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t) fresh, err := harnessStartFresh(ctx, t)
@@ -49,35 +47,32 @@ func TestSettingsBootstrapViaPost(t *testing.T) {
// as an error and not as a null body. // as an error and not as a null body.
before, err := fresh.GetSettings(ctx) before, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings on a fresh account must succeed") require.NoError(t, err, "get settings on a fresh account must succeed")
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap") assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
assert.Empty(t, before.ProxyAddress, "proxy address must be empty before bootstrap") assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
assert.False(t, before.Dedicated, "an unbootstrapped account has no serving shape") assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap") assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off") assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
// A PUT has no row to update yet — bootstrap is the explicit POST. // A PUT without a cluster has nothing to pin the account to.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ _, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
EnableLogCollection: true, EnableLogCollection: true,
AccessLogRetentionDays: 30,
}) })
requireClientError(t, err) requireClientError(t, err)
// A POST with a proxy address bootstraps a labeled endpoint and applies // A PUT carrying a cluster bootstraps the account and applies the
// the toggles from the same request. Every toggle is set away from its // mutable fields from the same request. Every toggle is set away from
// bootstrap default so each assertion can actually fail. // its bootstrap default so each assertion can actually fail.
const cluster = "e2e.bootstrap.netbird.selfhosted" const cluster = "e2e.bootstrap.netbird.selfhosted"
bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
ProxyAddress: ptr(cluster), Cluster: ptr(cluster),
EnableLogCollection: ptr(false), EnableLogCollection: false,
EnablePromptCollection: ptr(true), EnablePromptCollection: true,
RedactPii: ptr(true), RedactPii: true,
}) })
require.NoError(t, err, "bootstrap settings via POST must succeed") require.NoError(t, err, "bootstrap settings via PUT must succeed")
assert.Equal(t, cluster, bootstrapped.ProxyAddress, "proxy address must be pinned from the request") assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
require.NotEmpty(t, bootstrapped.Endpoint, "endpoint must be assigned at bootstrap") require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
assert.True(t, strings.HasSuffix(bootstrapped.Endpoint, "."+cluster), assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
"labeled endpoint must hang one label beneath the proxy address: %s", bootstrapped.Endpoint)
assert.False(t, bootstrapped.Dedicated, "a labeled pin is not dedicated")
assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default") assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply") assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply") assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
@@ -90,90 +85,30 @@ func TestSettingsBootstrapViaPost(t *testing.T) {
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist") assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist") assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
// Once bootstrapped, PUT updates the toggles. The identity fields ride // Once bootstrapped, later updates may omit the cluster entirely.
// along as a required echo of the assigned values; a matching echo is
// accepted and never written.
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: bootstrapped.Endpoint,
ProxyAddress: bootstrapped.ProxyAddress,
EnableLogCollection: true, EnableLogCollection: true,
EnablePromptCollection: false, EnablePromptCollection: false,
RedactPii: true, RedactPii: true,
AccessLogRetentionDays: 21,
}) })
require.NoError(t, err, "post-bootstrap update must succeed") require.NoError(t, err, "post-bootstrap update without cluster must succeed")
require.NotNil(t, persisted.AccessLogRetentionDays) assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
assert.Equal(t, 21, *persisted.AccessLogRetentionDays, "retention from the update must apply")
assert.Equal(t, bootstrapped.Endpoint, persisted.Endpoint, "endpoint must survive updates untouched")
assert.Equal(t, cluster, persisted.ProxyAddress, "proxy address must survive updates untouched")
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply") assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply") assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
// The endpoint is immutable: a PUT carrying a different endpoint is // The cluster is immutable: a different value is rejected rather than
// rejected, and a second bootstrap is rejected as a conflict. Neither // silently ignored, and the rejected update must not disturb anything.
// rejected write may disturb anything.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ _, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: "other.cluster.invalid", Cluster: ptr("other.cluster.invalid"),
ProxyAddress: persisted.ProxyAddress, EnableLogCollection: false,
EnableLogCollection: persisted.EnableLogCollection,
EnablePromptCollection: persisted.EnablePromptCollection,
RedactPii: persisted.RedactPii,
AccessLogRetentionDays: 21,
})
requireClientError(t, err)
_, err = fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
Endpoint: ptr("other.cluster.invalid"),
}) })
requireClientError(t, err) requireClientError(t, err)
final, err := fresh.GetSettings(ctx) final, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after the rejected bootstrap must succeed") require.NoError(t, err, "get settings after the rejected cluster change must succeed")
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected bootstrap must not change the endpoint") assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
assert.Equal(t, persisted.ProxyAddress, final.ProxyAddress, "rejected bootstrap must not change the proxy address") assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected bootstrap must not apply its toggles") assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected bootstrap must not apply its toggles") assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected bootstrap must not apply its toggles") assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
}
// TestSettingsBootstrapSelfAddressed covers the dedicated shape end to end:
// a POST carrying an endpoint claims the hostname verbatim, the proxy address
// equals it, and the pin reads as dedicated — the address-first flow a
// self-hosted operator uses before deploying the proxy that will declare it.
// The tail covers the recovery path the guarded DELETE exists for: with no
// providers and no proxy at the address, the claim can be released and a
// fresh bootstrap succeeds — the fix for a typo'd immutable endpoint.
func TestSettingsBootstrapSelfAddressed(t *testing.T) {
ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t)
require.NoError(t, err, "start dedicated combined server")
created, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
Endpoint: ptr("gw.e2e.netbird.selfhosted"),
})
require.NoError(t, err, "self-addressed bootstrap must succeed")
assert.Equal(t, "gw.e2e.netbird.selfhosted", created.Endpoint, "endpoint must be claimed verbatim")
assert.Equal(t, created.Endpoint, created.ProxyAddress, "self-addressed: proxy address is the endpoint")
assert.True(t, created.Dedicated, "a self-addressed pin is dedicated")
// No providers exist and no proxy declares the address, so both delete
// guards are clear: the delete releases the claim and the account reads
// as unbootstrapped defaults again.
require.NoError(t, fresh.DeleteSettings(ctx), "guarded delete with both guards clear must succeed")
after, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after delete must succeed")
assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped")
// A second delete has nothing to remove.
requireClientError(t, fresh.DeleteSettings(ctx))
// Re-creating is a fresh bootstrap — the released hostname is free to be
// claimed again, or a different one chosen.
recreated, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
Endpoint: ptr("gw2.e2e.netbird.selfhosted"),
})
require.NoError(t, err, "bootstrap after delete must succeed")
assert.Equal(t, "gw2.e2e.netbird.selfhosted", recreated.Endpoint, "the fresh bootstrap claims the new hostname")
} }

View File

@@ -66,7 +66,9 @@ func TestProviderSkipTLSVerification(t *testing.T) {
} }
} }
// First create bootstraps the account cluster.
insecureReq := newReq("skip-tls", insecureModel, true) insecureReq := newReq("skip-tls", insecureModel, true)
insecureReq.BootstrapCluster = ptr(harness.AgentNetworkCluster)
insecureProv, err := srv.CreateProvider(ctx, insecureReq) insecureProv, err := srv.CreateProvider(ctx, insecureReq)
require.NoError(t, err, "create skip-tls provider") require.NoError(t, err, "create skip-tls provider")
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), insecureProv.Id) }) t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), insecureProv.Id) })

View File

@@ -57,11 +57,12 @@ func TestVLLMProvider(t *testing.T) {
// is enumerated so the router dispatches this model string to this provider. // is enumerated so the router dispatches this model string to this provider.
dummyKey := "sk-vllm-e2e" dummyKey := "sk-vllm-e2e"
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
Name: "vllm", Name: "vllm",
ProviderId: "vllm", ProviderId: "vllm",
UpstreamUrl: vllm.URL, UpstreamUrl: vllm.URL,
ApiKey: &dummyKey, ApiKey: &dummyKey,
Enabled: ptr(true), Enabled: ptr(true),
BootstrapCluster: ptr(harness.AgentNetworkCluster),
Models: &[]api.AgentNetworkProviderModel{ Models: &[]api.AgentNetworkProviderModel{
{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}, {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002},
}, },

View File

@@ -20,9 +20,5 @@ ENV NETBIRD_BIN="/usr/local/bin/netbird" \
NB_ENABLE_CAPTURE="false" \ NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30" NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
# --chmod because the build context is not always a git checkout. A suite in COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
# another module builds from this module's extracted copy in the module cache,
# where every file is 0444 — the cache drops the executable bit git records — and
# a bare COPY then produces an entrypoint the runtime cannot exec.
COPY --chmod=0755 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY --from=builder /out/netbird /usr/local/bin/netbird COPY --from=builder /out/netbird /usr/local/bin/netbird

View File

@@ -126,33 +126,17 @@ func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error {
return anDelete(ctx, c, "/api/agent-network/guardrails/"+id) return anDelete(ctx, c, "/api/agent-network/guardrails/"+id)
} }
// CreateSettings bootstraps the account's agent-network settings row, // GetSettings returns the account's agent-network settings row. It exists only
// assigning the immutable endpoint. Exactly one of req.ProxyAddress (labeled // after the first provider create bootstraps it.
// endpoint beneath that cluster) and req.Endpoint (self-addressed dedicated
// endpoint) must be set; a second bootstrap returns a conflict.
func (c *Combined) CreateSettings(ctx context.Context, req api.AgentNetworkSettingsCreateRequest) (api.AgentNetworkSettings, error) {
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPost, "/api/agent-network/settings", req)
}
// GetSettings returns the account's agent-network settings row. Before the
// CreateSettings bootstrap it reads as the defaults with an empty endpoint.
func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) { func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) {
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodGet, "/api/agent-network/settings", nil) return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodGet, "/api/agent-network/settings", nil)
} }
// UpdateSettings applies the mutable collection toggles. The request must // UpdateSettings applies the mutable collection toggles.
// echo the assigned endpoint and proxy address unchanged — the server rejects
// a PUT that tries to change them.
func (c *Combined) UpdateSettings(ctx context.Context, req api.AgentNetworkSettingsRequest) (api.AgentNetworkSettings, error) { func (c *Combined) UpdateSettings(ctx context.Context, req api.AgentNetworkSettingsRequest) (api.AgentNetworkSettings, error) {
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPut, "/api/agent-network/settings", req) return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPut, "/api/agent-network/settings", req)
} }
// DeleteSettings removes the account's settings row, releasing the endpoint.
// Refused while providers exist or a proxy is actively serving the endpoint.
func (c *Combined) DeleteSettings(ctx context.Context) error {
return anDelete(ctx, c, "/api/agent-network/settings")
}
// ListConsumption returns the account's consumption rows (possibly empty). // ListConsumption returns the account's consumption rows (possibly empty).
func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) { func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) {
return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil) return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil)

View File

@@ -32,36 +32,12 @@ type Client struct {
container testcontainers.Container container testcontainers.Container
} }
// clientOptions is what the ClientOption values assemble.
type clientOptions struct {
name string
}
// ClientOption adjusts how StartClient runs the agent.
type ClientOption func(*clientOptions)
// WithClientName names the agent, which sets both its network alias and its
// container hostname. The hostname matters beyond addressing: the agent reports
// it to management at registration, so it is the name the peer appears under in
// the API.
//
// Required to run more than one agent against the same server — the default name
// is shared, and two containers cannot hold the same alias on one network.
func WithClientName(name string) ClientOption {
return func(o *clientOptions) { o.name = name }
}
// StartClient builds the client image and runs it on the combined server's // StartClient builds the client image and runs it on the combined server's
// network, joining via the given setup key. The image entrypoint brings the // network, joining via the given setup key. The image entrypoint brings the
// daemon up automatically; callers wait for connectivity with WaitConnected / // daemon up automatically; callers wait for connectivity with WaitConnected /
// WaitProxyPeer. // WaitProxyPeer.
func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...ClientOption) (*Client, error) { func StartClient(ctx context.Context, c *Combined, setupKey string) (*Client, error) {
o := clientOptions{name: clientAlias} root, err := repoRoot()
for _, opt := range opts {
opt(&o)
}
root, err := repoRoot(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -71,13 +47,9 @@ func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...Clie
} }
req := testcontainers.ContainerRequest{ req := testcontainers.ContainerRequest{
Image: clientImage, Image: clientImage,
// The agent reports the container's hostname to management, so this is
// the name the peer is addressable by in the API as well as on the
// network. The entrypoint takes no hostname flag of its own.
Hostname: o.name,
Networks: []string{c.network.Name}, Networks: []string{c.network.Name},
NetworkAliases: map[string][]string{c.network.Name: {o.name}}, NetworkAliases: map[string][]string{c.network.Name: {clientAlias}},
Env: map[string]string{ Env: map[string]string{
"NB_MANAGEMENT_URL": combinedExposedURL, "NB_MANAGEMENT_URL": combinedExposedURL,
"NB_SETUP_KEY": setupKey, "NB_SETUP_KEY": setupKey,

View File

@@ -61,68 +61,11 @@ type Combined struct {
workDir string workDir string
} }
// combinedOptions is what the CombinedOption values assemble.
type combinedOptions struct {
geolocation bool
env map[string]string
}
// CombinedOption adjusts how StartCombined boots the server. The defaults suit a
// suite that only drives the API; the options exist for the ones that need more
// of the product than that.
type CombinedOption func(*combinedOptions)
// WithGeolocation leaves the GeoLite database download enabled. It is off by
// default because the download adds startup latency that most suites get nothing
// for. A suite asserting on location-based posture checks needs it: management
// evaluates those rules against the database, and without it the rule fails
// instead of passing without having been checked.
func WithGeolocation() CombinedOption {
return func(o *combinedOptions) { o.geolocation = true }
}
// WithServerEnv adds environment variables to the combined container, overriding
// the defaults on a key collision. For settings this harness does not model
// directly, so a suite needing one does not have to fork the harness to get it.
func WithServerEnv(env map[string]string) CombinedOption {
return func(o *combinedOptions) {
if o.env == nil {
o.env = map[string]string{}
}
for k, v := range env {
o.env[k] = v
}
}
}
// combinedEnv is the combined container's environment: setup-PAT enabled so the
// caller can mint an admin token through /api/setup, geolocation off unless the
// suite asked for it, and whatever the suite added on top.
func combinedEnv(o combinedOptions) map[string]string {
env := map[string]string{
"NB_SETUP_PAT_ENABLED": "true",
}
if !o.geolocation {
// Skip the GeoLite DB download — it blocks startup and agent-network
// ingest doesn't use geolocation.
env["NB_DISABLE_GEOLOCATION"] = "true"
}
for k, v := range o.env {
env[k] = v
}
return env
}
// StartCombined builds the combined server from its multistage Dockerfile and // StartCombined builds the combined server from its multistage Dockerfile and
// boots it with setup-PAT enabled on a fresh shared network, returning once the // boots it with setup-PAT enabled on a fresh shared network, returning once the
// API is serving. The caller still owns minting the admin PAT via Bootstrap. // API is serving. The caller still owns minting the admin PAT via Bootstrap.
func StartCombined(ctx context.Context, opts ...CombinedOption) (*Combined, error) { func StartCombined(ctx context.Context) (*Combined, error) {
var o combinedOptions root, err := repoRoot()
for _, opt := range opts {
opt(&o)
}
root, err := repoRoot(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -145,7 +88,7 @@ func StartCombined(ctx context.Context, opts ...CombinedOption) (*Combined, erro
return nil, fmt.Errorf("create work dir: %w", err) return nil, fmt.Errorf("create work dir: %w", err)
} }
cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, !o.geolocation, containerIssuer) cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, containerIssuer)
if err := os.WriteFile(filepath.Join(workDir, "config.yaml"), []byte(cfg), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container if err := os.WriteFile(filepath.Join(workDir, "config.yaml"), []byte(cfg), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container
_ = net.Remove(ctx) _ = net.Remove(ctx)
return nil, fmt.Errorf("write combined config: %w", err) return nil, fmt.Errorf("write combined config: %w", err)
@@ -169,8 +112,13 @@ func StartCombined(ctx context.Context, opts ...CombinedOption) (*Combined, erro
ExposedPorts: []string{combinedHTTPPort}, ExposedPorts: []string{combinedHTTPPort},
Networks: []string{net.Name}, Networks: []string{net.Name},
NetworkAliases: map[string][]string{net.Name: {combinedAlias}}, NetworkAliases: map[string][]string{net.Name: {combinedAlias}},
Env: combinedEnv(o), Env: map[string]string{
Cmd: []string{"--config", "/nb/config.yaml"}, "NB_SETUP_PAT_ENABLED": "true",
// Skip the GeoLite DB download — it blocks startup and agent-network
// ingest doesn't use geolocation.
"NB_DISABLE_GEOLOCATION": "true",
},
Cmd: []string{"--config", "/nb/config.yaml"},
HostConfigModifier: func(hc *container.HostConfig) { HostConfigModifier: func(hc *container.HostConfig) {
hc.Binds = append(hc.Binds, workDir+":/nb") hc.Binds = append(hc.Binds, workDir+":/nb")
}, },

View File

@@ -15,11 +15,6 @@ package harness
// server is required to load it — a broken path or malformed file fails startup // server is required to load it — a broken path or malformed file fails startup
// rather than silently falling back to the compiled-in rates, and TestMain then // rather than silently falling back to the compiled-in rates, and TestMain then
// fails with the container logs. // fails with the container logs.
//
// disableGeoliteUpdate is a parameter rather than a fixed true because a suite
// that exercises geolocation needs the database: management can only evaluate a
// location rule with GeoLite loaded, and a rule it cannot evaluate fails rather
// than passing vacuously. See WithGeolocation.
const combinedConfigYAML = `server: const combinedConfigYAML = `server:
listenAddress: ":8080" listenAddress: ":8080"
exposedAddress: "%s" exposedAddress: "%s"
@@ -30,7 +25,7 @@ const combinedConfigYAML = `server:
authSecret: "e2e-relay-secret" authSecret: "e2e-relay-secret"
dataDir: "/nb/data" dataDir: "/nb/data"
disableAnonymousMetrics: true disableAnonymousMetrics: true
disableGeoliteUpdate: %t disableGeoliteUpdate: true
auth: auth:
issuer: "%s" issuer: "%s"
store: store:

View File

@@ -1,161 +0,0 @@
//go:build e2e
package harness
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The options exist so a suite can ask for a deployment this harness would not
// otherwise give it. What they configure is a container environment and a config
// file, both assembled before anything is started, so they are checkable without
// Docker — which is the point: a wiring mistake here would otherwise only show up
// as a puzzling failure minutes into a container run.
func TestCombinedEnvGeolocation(t *testing.T) {
var off combinedOptions
assert.Equal(t, "true", combinedEnv(off)["NB_DISABLE_GEOLOCATION"],
"geolocation should be off by default")
var on combinedOptions
WithGeolocation()(&on)
assert.NotContains(t, combinedEnv(on), "NB_DISABLE_GEOLOCATION",
"WithGeolocation must leave NB_DISABLE_GEOLOCATION unset, so the server downloads the database")
assert.Equal(t, "true", combinedEnv(on)["NB_SETUP_PAT_ENABLED"],
"the setup PAT must stay enabled whatever else is configured; Bootstrap depends on it")
}
// The config file carries the same decision as the environment variable, and the
// server needs both to agree: disableGeoliteUpdate suppresses the download even
// when geolocation itself is enabled.
func TestCombinedConfigGeolocation(t *testing.T) {
for _, tc := range []struct {
name string
opts []CombinedOption
want string
}{
{name: "default", want: "disableGeoliteUpdate: true"},
{name: "with geolocation", opts: []CombinedOption{WithGeolocation()}, want: "disableGeoliteUpdate: false"},
} {
t.Run(tc.name, func(t *testing.T) {
var o combinedOptions
for _, opt := range tc.opts {
opt(&o)
}
cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, !o.geolocation, containerIssuer)
assert.Contains(t, cfg, tc.want, "geolocation not rendered as expected")
// The issuer is the last verb; a mis-ordered argument list would put
// the boolean here instead and the server would fail to start.
assert.Contains(t, cfg, `issuer: "`+containerIssuer+`"`, "issuer not rendered")
})
}
}
func TestWithServerEnvOverrides(t *testing.T) {
var o combinedOptions
WithServerEnv(map[string]string{"NB_LOG_LEVEL": "debug"})(&o)
WithServerEnv(map[string]string{"NB_SETUP_PAT_ENABLED": "false"})(&o)
env := combinedEnv(o)
assert.Equal(t, "debug", env["NB_LOG_LEVEL"], "added variable missing")
assert.Equal(t, "false", env["NB_SETUP_PAT_ENABLED"], "a suite must be able to override a default")
}
// Two agents on one network cannot share an alias, so the name has to reach both
// the alias and the hostname. The hostname is the one management records, so it is
// also what the peer is addressable by through the API.
func TestWithClientName(t *testing.T) {
o := clientOptions{name: clientAlias}
require.Equal(t, "client", o.name, "unexpected default client name")
WithClientName("peer2")(&o)
assert.Equal(t, "peer2", o.name, "WithClientName did not take")
}
// repoRoot has to recognise this module rather than merely finding a go.mod, or a
// suite in another module gets its own root and a build context without the
// component Dockerfiles in it.
func TestIsModule(t *testing.T) {
dir := t.TempDir()
other := filepath.Join(dir, "go.mod")
require.NoError(t, os.WriteFile(other, []byte("module example.com/other\n\ngo 1.25\n"), 0o600))
assert.False(t, isModule(other, modulePath), "another module's go.mod must not be taken for this repo")
ours := filepath.Join(dir, "ours.mod")
require.NoError(t, os.WriteFile(ours, []byte("// a comment\n\nmodule "+modulePath+"\n\ngo 1.25\n"), 0o600))
assert.True(t, isModule(ours, modulePath), "this repo's go.mod was not recognised")
assert.False(t, isModule(filepath.Join(dir, "absent.mod"), modulePath),
"a missing go.mod must not report a match")
}
// Running from inside the repo, repoRoot finds it by walking up — the module
// lookup is only the fallback, and this asserts the walk still wins so an in-repo
// run never depends on the module cache.
func TestRepoRootFindsThisRepo(t *testing.T) {
root, err := repoRoot(context.Background())
require.NoError(t, err)
assert.True(t, isModule(filepath.Join(root, "go.mod"), modulePath),
"repoRoot returned %s, which is not this module", root)
for _, f := range []string{combinedDockerfile, clientDockerfile} {
_, err := os.Stat(filepath.Join(root, f))
assert.NoError(t, err, "%s is not present under the reported root %s", f, root)
}
}
// A caller that vendors its dependencies puts the go command in automatic vendor
// mode, where `go list -m -f {{.Dir}}` succeeds and reports an EMPTY directory:
// vendor/ holds packages, not module source. Without -mod=readonly the lookup
// would come back empty and the harness would report a missing module for a
// dependency that is present.
func TestModuleDirResolvesUnderVendorMode(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no go tool on PATH")
}
ctx := context.Background()
base := t.TempDir()
dep := filepath.Join(base, "dep")
main := filepath.Join(base, "main")
require.NoError(t, os.MkdirAll(dep, 0o750))
require.NoError(t, os.MkdirAll(main, 0o750))
// A local replacement rather than a real dependency, so this needs no network.
require.NoError(t, os.WriteFile(filepath.Join(dep, "go.mod"),
[]byte("module example.com/dep\n\ngo 1.25\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(dep, "dep.go"),
[]byte("package dep\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(main, "go.mod"),
[]byte("module example.com/main\n\ngo 1.25\n\nrequire example.com/dep v0.0.0\n\nreplace example.com/dep v0.0.0 => ../dep\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(main, "main.go"),
[]byte("package main\n\nimport _ \"example.com/dep\"\n\nfunc main() {}\n"), 0o600))
t.Chdir(main)
vendor := exec.CommandContext(ctx, "go", "mod", "vendor")
out, err := vendor.CombinedOutput()
require.NoError(t, err, "go mod vendor: %s", out)
dir, err := moduleDir(ctx, "example.com/dep")
require.NoError(t, err, "the module must still resolve with a vendor directory present")
assert.Equal(t, dep, dir, "resolved the wrong directory")
}
// A cancelled context has to stop the lookup rather than leaving the caller
// waiting on a subprocess it has already given up on.
func TestModuleDirHonoursContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := moduleDir(ctx, modulePath)
assert.ErrorIs(t, err, context.Canceled, "a cancelled context must stop the lookup")
}

View File

@@ -3,82 +3,27 @@
package harness package harness
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings"
) )
// modulePath is this module, used both to recognise the repo when walking up // repoRoot walks up from the working directory to the module root (the
// from the working directory and to locate it when the suite lives elsewhere. // directory holding go.mod), so the Docker build context is correct no matter
const modulePath = "github.com/netbirdio/netbird" // which package the test runs from.
func repoRoot() (string, error) {
// repoRoot returns the directory the component Dockerfiles are built from.
//
// Walking up from the working directory finds it for any test inside this repo,
// no matter which package it runs from. A suite in another module gets a
// different answer that way — its own module root, where combined/Dockerfile
// does not exist — so the ancestor has to be this module and not merely some
// module. When it is not, the build context is the extracted module directory of
// whichever version that suite depends on, which is the right one: the server it
// tests against is then built from the same revision as the client library it
// was compiled with.
func repoRoot(ctx context.Context) (string, error) {
dir, err := os.Getwd() dir, err := os.Getwd()
if err != nil { if err != nil {
return "", err return "", err
} }
for { for {
if isModule(filepath.Join(dir, "go.mod"), modulePath) { if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil {
return dir, nil return dir, nil
} }
parent := filepath.Dir(dir) parent := filepath.Dir(dir)
if parent == dir { if parent == dir {
break return "", fmt.Errorf("go.mod not found above %s", dir)
} }
dir = parent dir = parent
} }
return moduleDir(ctx, modulePath)
}
// isModule reports whether the go.mod at path declares the given module.
func isModule(path, want string) bool {
b, err := os.ReadFile(path)
if err != nil {
return false
}
for _, line := range strings.Split(string(b), "\n") {
if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "module "); ok {
return strings.TrimSpace(rest) == want
}
}
return false
}
// moduleDir asks the go tool where a module's source is, which for a dependent
// module is its extracted copy in the module cache. The cache is read-only, and
// a Docker build context is only ever read.
//
// -mod=readonly is required rather than cosmetic. A caller that vendors its
// dependencies puts the go command in automatic vendor mode, where this lookup
// succeeds with an EMPTY directory — vendor/ holds packages, not module source,
// so there is nothing to report. Asking in readonly mode resolves against the
// module graph instead, which answers for both a cached module and a local
// replacement, and neither writes to go.mod.
func moduleDir(ctx context.Context, module string) (string, error) {
cmd := exec.CommandContext(ctx, "go", "list", "-mod=readonly", "-m", "-f", "{{.Dir}}", module)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("locate %s: %w", module, err)
}
dir := strings.TrimSpace(string(out))
if dir == "" {
return "", fmt.Errorf("locate %s: the go tool reported no directory; run `go mod download %s`", module, module)
}
if _, err := os.Stat(dir); err != nil {
return "", fmt.Errorf("locate %s: %w", module, err)
}
return dir, nil
} }

View File

@@ -43,7 +43,7 @@ type Proxy struct {
// or override any NB_PROXY_* var (e.g. NB_PROXY_TUNNEL_CACHE_TTL for tests that // or override any NB_PROXY_* var (e.g. NB_PROXY_TUNNEL_CACHE_TTL for tests that
// need a short authorization-cache window). // need a short authorization-cache window).
func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverrides ...map[string]string) (*Proxy, error) { func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverrides ...map[string]string) (*Proxy, error) {
root, err := repoRoot(ctx) root, err := repoRoot()
if err != nil { if err != nil {
return nil, err return nil, err
} }

5
go.mod
View File

@@ -99,7 +99,7 @@ require (
github.com/pires/go-proxyproto v0.11.0 github.com/pires/go-proxyproto v0.11.0
github.com/pkg/sftp v1.13.9 github.com/pkg/sftp v1.13.9
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/quic-go/quic-go v0.59.1 github.com/quic-go/quic-go v0.55.0
github.com/redis/go-redis/v9 v9.7.3 github.com/redis/go-redis/v9 v9.7.3
github.com/rs/xid v1.3.0 github.com/rs/xid v1.3.0
github.com/shirou/gopsutil/v4 v4.25.8 github.com/shirou/gopsutil/v4 v4.25.8
@@ -239,6 +239,7 @@ require (
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect
@@ -339,4 +340,4 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4

11
go.sum
View File

@@ -349,6 +349,8 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -488,8 +490,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db h1:gBOE2r4AW1soSmpYJC5/n9/1L8UQ8+HLjed8CY/TzZY= github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4 h1:UKztc3QjWvzU5DZk+uYaOWN0x62NSe/pkxuPvzqZIy4=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db/go.mod h1:bsdahLwBQxXjlmdPPeQyrTcDJfcqAr/ymFj0RXhwtWI= github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw= github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -580,8 +582,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -791,6 +793,7 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@@ -11,10 +11,6 @@ SED_STRIP_PADDING='s/=//g'
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
# Static IP for Traefik inside the compose bridge network. The management
# server trusts X-Forwarded-* headers from this address only.
TRAEFIK_IP="172.30.0.10"
check_docker_compose() { check_docker_compose() {
if command -v docker-compose &> /dev/null; then if command -v docker-compose &> /dev/null; then
echo "docker-compose" echo "docker-compose"
@@ -84,7 +80,7 @@ read_nb_domain() {
if ! check_domain_resolves "$value"; then if ! check_domain_resolves "$value"; then
echo "" > /dev/stderr echo "" > /dev/stderr
echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr
echo "Traefik will not be able to issue TLS certificates until it does." > /dev/stderr echo "Caddy will not be able to issue TLS certificates until it does." > /dev/stderr
local confirm="" local confirm=""
echo -n "Continue anyway? [y/N]: " > /dev/stderr echo -n "Continue anyway? [y/N]: " > /dev/stderr
read -r confirm < /dev/tty read -r confirm < /dev/tty
@@ -96,23 +92,6 @@ read_nb_domain() {
echo "$value" echo "$value"
} }
read_letsencrypt_email() {
if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL:-}" ]]; then
echo "$NETBIRD_LETSENCRYPT_EMAIL"
return
fi
local value=""
echo "Enter your email for Let's Encrypt certificate notifications." > /dev/stderr
echo -n "Email address: " > /dev/stderr
read -r value < /dev/tty
if [[ -z "$value" ]]; then
echo "Email is required for Let's Encrypt." > /dev/stderr
read_letsencrypt_email
return
fi
echo "$value"
}
read_required() { read_required() {
local prompt="$1" local prompt="$1"
local value="" local value=""
@@ -225,11 +204,11 @@ init_environment() {
check_openssl check_openssl
DOCKER_COMPOSE_COMMAND=$(check_docker_compose) DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]] || [[ -f Caddyfile ]]; then
echo "Generated files already exist in $(pwd)." echo "Generated files already exist in $(pwd)."
echo "If you want to reinitialize the environment, please remove them first:" echo "If you want to reinitialize the environment, please remove them first:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes" echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes"
echo " rm -f .env docker-compose.yml config.yaml" echo " rm -f .env docker-compose.yml Caddyfile config.yaml"
echo "Be aware this will remove all data from the database." echo "Be aware this will remove all data from the database."
exit 1 exit 1
fi fi
@@ -251,9 +230,6 @@ init_environment() {
echo "" echo ""
NETBIRD_DOMAIN=$(read_nb_domain) NETBIRD_DOMAIN=$(read_nb_domain)
echo ""
NETBIRD_LETSENCRYPT_EMAIL=$(read_letsencrypt_email)
echo "" echo ""
NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)") NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)")
@@ -262,7 +238,6 @@ init_environment() {
POSTGRES_DB="netbird" POSTGRES_DB="netbird"
POSTGRES_PASSWORD=$(rand_secret) POSTGRES_PASSWORD=$(rand_secret)
NETBIRD_ENCRYPTION_KEY=$(rand_b64_key) NETBIRD_ENCRYPTION_KEY=$(rand_b64_key)
NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)
NETBIRD_RELAY_AUTH_SECRET=$(rand_secret) 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" POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC"
@@ -272,7 +247,6 @@ init_environment() {
echo "Selected:" echo "Selected:"
echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}" echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}"
echo " Domain: ${NETBIRD_DOMAIN}" echo " Domain: ${NETBIRD_DOMAIN}"
echo " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}"
echo "" echo ""
echo "Rendering files into $(pwd) ..." echo "Rendering files into $(pwd) ..."
install -m 600 /dev/null .env install -m 600 /dev/null .env
@@ -282,6 +256,7 @@ init_environment() {
if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak
fi fi
render_caddyfile > Caddyfile
install -m 600 /dev/null config.yaml install -m 600 /dev/null config.yaml
render_config_yaml >> config.yaml render_config_yaml >> config.yaml
@@ -308,7 +283,7 @@ init_environment() {
echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env" echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env"
echo "" echo ""
echo "Tail logs:" echo "Tail logs:"
echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik" echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server caddy"
} }
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -331,11 +306,6 @@ NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW}
# Domain # Domain
NETBIRD_DOMAIN=${NETBIRD_DOMAIN} NETBIRD_DOMAIN=${NETBIRD_DOMAIN}
# Reverse proxy (Traefik)
NETBIRD_LETSENCRYPT_EMAIL=${NETBIRD_LETSENCRYPT_EMAIL}
NETBIRD_TRAEFIK_TAG=${NETBIRD_TRAEFIK_TAG:-v3.6}
NETBIRD_TRAEFIK_IP=${TRAEFIK_IP}
# Image tags. Default to "latest" # Image tags. Default to "latest"
NETBIRD_DASHBOARD_TAG=${NETBIRD_DASHBOARD_TAG:-latest} NETBIRD_DASHBOARD_TAG=${NETBIRD_DASHBOARD_TAG:-latest}
NETBIRD_SERVER_TAG=${NETBIRD_SERVER_TAG:-latest} NETBIRD_SERVER_TAG=${NETBIRD_SERVER_TAG:-latest}
@@ -408,78 +378,26 @@ EOF
render_compose_common() { render_compose_common() {
cat <<'EOF' cat <<'EOF'
# Reverse proxy with automatic TLS via Let's Encrypt. Routes are declared as caddy:
# labels on the services below and picked up through the Docker provider.
traefik:
<<: *default <<: *default
image: traefik:${NETBIRD_TRAEFIK_TAG} image: caddy:2
container_name: netbird-traefik container_name: netbird-caddy
networks: networks: [netbird]
netbird: environment:
ipv4_address: ${NETBIRD_TRAEFIK_IP} - CADDY_SECURE_DOMAIN=${NETBIRD_DOMAIN}
command:
# Logging
- "--log.level=INFO"
- "--accesslog=true"
# Docker provider
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=netbird"
# Entrypoints
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.allowACMEByPass=true"
# readTimeout bounds the whole request, and gRPC streams / relay WebSockets
# never end one; idleTimeout would close the keep-alive connection they
# are reused over. Entrypoint-wide is the only scope Traefik offers here.
# writeTimeout is left alone: it already defaults to 0.
- "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0"
- "--entrypoints.websecure.transport.respondingTimeouts.idleTimeout=0"
# HTTP to HTTPS redirect
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
# Let's Encrypt ACME
- "--certificatesresolvers.letsencrypt.acme.email=${NETBIRD_LETSENCRYPT_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
ports: ports:
- '443:443' - '443:443'
- '443:443/udp'
- '80:80' - '80:80'
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - netbird_caddy_data:/data
- netbird_traefik_letsencrypt:/letsencrypt - ./Caddyfile:/etc/caddy/Caddyfile
labels:
- traefik.enable=true
# Shared security headers, referenced by every NetBird router below. A
# label-declared middleware only exists while its container runs, so this
# lives on Traefik itself: declaring it on an app container would drop
# every router referencing it whenever that container restarts.
- traefik.http.middlewares.nb-security.headers.stsSeconds=3600
- traefik.http.middlewares.nb-security.headers.stsIncludeSubdomains=true
- traefik.http.middlewares.nb-security.headers.contentTypeNosniff=true
- traefik.http.middlewares.nb-security.headers.browserXssFilter=true
- traefik.http.middlewares.nb-security.headers.referrerPolicy=strict-origin-when-cross-origin
- traefik.http.middlewares.nb-security.headers.customResponseHeaders.X-Frame-Options=SAMEORIGIN
# Empty value strips the header. Only the dashboard's nginx sets one; the
# server emits none. Do not quote it — "" would send a literal Server: "".
- traefik.http.middlewares.nb-security.headers.customResponseHeaders.Server=
dashboard: dashboard:
<<: *default <<: *default
image: ghcr.io/netbirdio/dashboard-cloud:${NETBIRD_DASHBOARD_TAG} image: ghcr.io/netbirdio/dashboard-cloud:${NETBIRD_DASHBOARD_TAG}
container_name: netbird-dashboard container_name: netbird-dashboard
networks: [netbird] networks: [netbird]
labels:
- traefik.enable=true
# Dashboard catch-all: lowest priority so every route below wins
- traefik.http.routers.netbird-dashboard.rule=Host(`${NETBIRD_DOMAIN}`)
- traefik.http.routers.netbird-dashboard.entrypoints=websecure
- traefik.http.routers.netbird-dashboard.tls=true
- traefik.http.routers.netbird-dashboard.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-dashboard.middlewares=nb-security@docker
- traefik.http.routers.netbird-dashboard.service=dashboard
- traefik.http.routers.netbird-dashboard.priority=1
- traefik.http.services.dashboard.loadbalancer.server.port=80
environment: environment:
- NETBIRD_MGMT_API_ENDPOINT=https://${NETBIRD_DOMAIN} - NETBIRD_MGMT_API_ENDPOINT=https://${NETBIRD_DOMAIN}
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://${NETBIRD_DOMAIN} - NETBIRD_MGMT_GRPC_API_ENDPOINT=https://${NETBIRD_DOMAIN}
@@ -517,28 +435,6 @@ render_compose_server() {
- netbird_data:/var/lib/netbird - netbird_data:/var/lib/netbird
- ./config.yaml:/etc/netbird/config.yaml - ./config.yaml:/etc/netbird/config.yaml
command: ["--config", "/etc/netbird/config.yaml"] command: ["--config", "/etc/netbird/config.yaml"]
labels:
- traefik.enable=true
# Signal + Management gRPC (needs an h2c backend for HTTP/2 cleartext)
- traefik.http.routers.netbird-grpc.rule=Host(`${NETBIRD_DOMAIN}`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`) || PathPrefix(`/management.ProxyService/`))
- traefik.http.routers.netbird-grpc.entrypoints=websecure
- traefik.http.routers.netbird-grpc.tls=true
- traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-grpc.middlewares=nb-security@docker
- traefik.http.routers.netbird-grpc.service=netbird-server-h2c
- traefik.http.routers.netbird-grpc.priority=100
# Relay WebSocket, management API, and the embedded IdP
- traefik.http.routers.netbird-backend.rule=Host(`${NETBIRD_DOMAIN}`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`))
- traefik.http.routers.netbird-backend.entrypoints=websecure
- traefik.http.routers.netbird-backend.tls=true
- traefik.http.routers.netbird-backend.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-backend.middlewares=nb-security@docker
- traefik.http.routers.netbird-backend.service=netbird-server
- traefik.http.routers.netbird-backend.priority=100
# Services
- traefik.http.services.netbird-server.loadbalancer.server.port=80
- traefik.http.services.netbird-server-h2c.loadbalancer.server.port=80
- traefik.http.services.netbird-server-h2c.loadbalancer.server.scheme=h2c
environment: environment:
- NB_LICENSE_KEY=${NETBIRD_LICENSE_KEY} - NB_LICENSE_KEY=${NETBIRD_LICENSE_KEY}
- NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL} - NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}
@@ -601,18 +497,6 @@ render_compose_flow() {
- NB_FLOW_NATS_ENDPOINTS=nats://nats:4222 - NB_FLOW_NATS_ENDPOINTS=nats://nats:4222
- NB_FLOW_NATS_STREAM=traffic-events - NB_FLOW_NATS_STREAM=traffic-events
- NB_FLOW_AUTH_SECRET=${NETBIRD_RELAY_AUTH_SECRET} - NB_FLOW_AUTH_SECRET=${NETBIRD_RELAY_AUTH_SECRET}
labels:
- traefik.enable=true
# Flow receiver gRPC (h2c backend)
- traefik.http.routers.netbird-flow.rule=Host(`${NETBIRD_DOMAIN}`) && PathPrefix(`/flow.FlowService/`)
- traefik.http.routers.netbird-flow.entrypoints=websecure
- traefik.http.routers.netbird-flow.tls=true
- traefik.http.routers.netbird-flow.tls.certresolver=letsencrypt
- traefik.http.routers.netbird-flow.middlewares=nb-security@docker
- traefik.http.routers.netbird-flow.service=netbird-flow-h2c
- traefik.http.routers.netbird-flow.priority=100
- traefik.http.services.netbird-flow-h2c.loadbalancer.server.port=80
- traefik.http.services.netbird-flow-h2c.loadbalancer.server.scheme=h2c
EOF EOF
} }
@@ -652,16 +536,61 @@ EOF
fi fi
cat <<'EOF' cat <<'EOF'
netbird_postgres: netbird_postgres:
netbird_traefik_letsencrypt: netbird_caddy_data:
networks: networks:
netbird: netbird:
name: netbird EOF
driver: bridge }
ipam:
config: render_caddyfile() {
- subnet: 172.30.0.0/24 cat <<'EOF'
gateway: 172.30.0.1 {
servers :80,:443 {
protocols h1 h2c h2 h3
}
}
(security_headers) {
header * {
Strict-Transport-Security "max-age=3600; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
-Server
Referrer-Policy strict-origin-when-cross-origin
}
}
:80 {
redir https://{$CADDY_SECURE_DOMAIN}{uri} permanent
}
{$CADDY_SECURE_DOMAIN}:443 {
import security_headers
# Signal (gRPC over h2c)
reverse_proxy /signalexchange.SignalExchange/* h2c://netbird-server:80
# Management (gRPC over h2c + HTTP)
reverse_proxy /management.ManagementService/* h2c://netbird-server:80
reverse_proxy /api/* netbird-server:80
reverse_proxy /ws-proxy/* netbird-server:80
# Embedded IdP (OAuth2 endpoints served by netbird server)
reverse_proxy /oauth2/* netbird-server:80
# Relay (WebSocket multiplexed on the same port)
reverse_proxy /relay* netbird-server:80
EOF
if [[ "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then
cat <<'EOF'
# Flow receiver (gRPC over h2c)
reverse_proxy /flow.FlowService/* h2c://receiver:80
EOF
fi
cat <<'EOF'
# Dashboard
reverse_proxy /* dashboard:80
}
EOF EOF
} }
@@ -680,7 +609,7 @@ server:
logLevel: "info" logLevel: "info"
logFile: "console" logFile: "console"
# TLS is terminated by Traefik in front; leave this block empty. # TLS is terminated by Caddy in front; leave this block empty.
tls: tls:
certFile: "" certFile: ""
keyFile: "" keyFile: ""
@@ -697,23 +626,12 @@ server:
issuer: "https://${NETBIRD_DOMAIN}/oauth2" issuer: "https://${NETBIRD_DOMAIN}/oauth2"
localAuthDisabled: false localAuthDisabled: false
signKeyRefreshEnabled: false signKeyRefreshEnabled: false
sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"
dashboardRedirectURIs: dashboardRedirectURIs:
- "https://${NETBIRD_DOMAIN}/nb-auth" - "https://${NETBIRD_DOMAIN}/nb-auth"
- "https://${NETBIRD_DOMAIN}/nb-silent-auth" - "https://${NETBIRD_DOMAIN}/nb-silent-auth"
cliRedirectURIs: cliRedirectURIs:
- "http://localhost:53000/" - "http://localhost:53000/"
# Trust X-Forwarded-* only from the Traefik container's static address. Both
# keys must stay in step with the ipv4_address pinned in docker-compose.yml:
# trustedPeers decides whether forwarded headers are read at all, and leaving
# it unset falls back to 0.0.0.0/0.
reverseProxy:
trustedPeers:
- "${TRAEFIK_IP}/32"
trustedHTTPProxies:
- "${TRAEFIK_IP}/32"
store: store:
engine: "postgres" engine: "postgres"
dsn: "${POSTGRES_DSN}" dsn: "${POSTGRES_DSN}"

View File

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

View File

@@ -15,11 +15,7 @@ set -o pipefail
# 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store. # 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store.
# 3. Traffic flow — add NATS + flow-enricher + flow-receiver. # 3. Traffic flow — add NATS + flow-enricher + flow-receiver.
# #
# If any step fails once the stack has been touched, the script rolls itself # To revert:
# back automatically: generated files are removed, the Postgres volume this run
# created is dropped, and the original deployment is started again.
#
# To revert a successful migration:
# docker compose down # docker compose down
# rm -f docker-compose.override.yml config.yaml.enterprise # rm -f docker-compose.override.yml config.yaml.enterprise
# # If Postgres migration was done, also restore the SQLite backup printed # # If Postgres migration was done, also restore the SQLite backup printed
@@ -29,15 +25,6 @@ set -o pipefail
OVERRIDE_FILE="docker-compose.override.yml" OVERRIDE_FILE="docker-compose.override.yml"
ENTERPRISE_CONFIG_FILE="config.yaml.enterprise" ENTERPRISE_CONFIG_FILE="config.yaml.enterprise"
# Rollback bookkeeping. ROLLBACK_STATE flips to "armed" the moment the script
# starts mutating the deployment, and back to "disarmed" once the migration has
# completed successfully.
ROLLBACK_STATE="disarmed"
ENV_EXISTED="unknown"
ENV_BACKUP=""
PG_VOLUME_NAME=""
BACKUP_DIR=""
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
check_docker_compose() { check_docker_compose() {
@@ -173,11 +160,11 @@ EOF
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
detect_combined_service() { detect_combined_service() {
yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?netbirdio/netbird-server([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE" yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/netbird-server"))) | .[0].key // ""' "$COMPOSE_FILE"
} }
detect_dashboard_service() { detect_dashboard_service() {
yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?netbirdio/dashboard([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE" yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/dashboard"))) | .[0].key // ""' "$COMPOSE_FILE"
} }
detect_config_yaml_host_path() { detect_config_yaml_host_path() {
@@ -374,77 +361,7 @@ render_enterprise_config() {
# Execution steps # Execution steps
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
combined_container_id() { resolve_data_volume() {
$DOCKER_COMPOSE_COMMAND ps -aq "$COMBINED_SERVICE" 2>/dev/null | head -1
}
container_data_mount() {
local container="$1"
[[ -n "$container" ]] || return 0
docker inspect "$container" --format \
'{{range .Mounts}}{{if eq .Destination "/var/lib/netbird"}}{{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' 2>/dev/null
}
# The name comes from the container, so `-v` cannot invent an empty volume here.
# 0 = empty, 1 = holds data, 2 = could not determine. A failed listing must not
# be reported as empty: that would abort a healthy migration over a pull error
# or an unreadable bind mount.
data_dir_state() {
local src="$1" out
if [[ "$src" == /* ]]; then
[[ -d "$src" ]] || return 2
out=$(ls -A "$src" 2>/dev/null) || return 2
else
docker volume inspect "$src" &> /dev/null || return 0
out=$(docker run --rm -v "${src}:/d:ro" busybox sh -c 'ls -A /d' 2>/dev/null) || return 2
fi
[[ -z "$out" ]] && return 0
return 1
}
check_data_directory() {
[[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0
local container
container=$(combined_container_id)
if [[ -z "$container" ]]; then
echo "" > /dev/stderr
echo "No container found for service '$COMBINED_SERVICE'." > /dev/stderr
echo "The migration backs up the store by copying it out of that container," > /dev/stderr
echo "so it has to exist. Start the deployment and re-run:" > /dev/stderr
echo " $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr
exit 1
fi
local src
src=$(container_data_mount "$container")
if [[ -z "$src" ]]; then
echo "" > /dev/stderr
echo "The '$COMBINED_SERVICE' container has nothing mounted at /var/lib/netbird." > /dev/stderr
echo "Cannot locate the NetBird store to back it up." > /dev/stderr
exit 1
fi
local state=0
data_dir_state "$src" || state=$?
if [[ $state -eq 0 ]]; then
echo "" > /dev/stderr
echo "The NetBird data directory is empty:" > /dev/stderr
echo " $src" > /dev/stderr
echo "There is nothing to migrate. Check that you are running this from the" > /dev/stderr
echo "deployment directory of the NetBird install you mean to migrate." > /dev/stderr
exit 1
fi
if [[ $state -eq 2 ]]; then
echo " ⚠ Could not read $src to confirm it holds data — continuing." > /dev/stderr
echo " The backup step still fails loudly if it turns out to be empty." > /dev/stderr
fi
echo " Data directory: $src"
}
# Only for the Postgres volume, which has no container to read it off yet.
resolve_compose_volume() {
local short="$1" local short="$1"
local actual local actual
# Resolve project-prefixed volume name from Docker Compose config first. # Resolve project-prefixed volume name from Docker Compose config first.
@@ -474,21 +391,18 @@ resolve_compose_volume() {
backup_sqlite() { backup_sqlite() {
BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)" BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
local data_volume_actual
local container data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
container=$(combined_container_id) echo "Backing up SQLite store from volume '$data_volume_actual' to $BACKUP_DIR ..."
if [[ -z "$container" ]]; then docker run --rm \
echo " ⚠ No container found for '$COMBINED_SERVICE' — cannot back up the store." > /dev/stderr -v "${data_volume_actual}:/var/lib/netbird:ro" \
exit 1 -v "${BACKUP_DIR}:/backup" \
fi busybox \
sh -c 'cp -a /var/lib/netbird/. /backup/ 2>/dev/null || true'
echo "Backing up the NetBird store to $BACKUP_DIR ..."
docker cp "${container}:/var/lib/netbird/." "$BACKUP_DIR/"
local copied local copied
copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1) copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1)
if [[ -z "$copied" ]]; then if [[ -z "$copied" ]]; then
echo " ⚠ Backup directory is empty — /var/lib/netbird held no data. Aborting." > /dev/stderr echo " ⚠ Backup directory is empty — the volume '$data_volume_actual' didn't contain data. Aborting." > /dev/stderr
exit 1 exit 1
fi fi
echo " done" echo " done"
@@ -500,135 +414,6 @@ run_migrate_store() {
echo " done" echo " done"
} }
# ---------------------------------------------------------------------------
# Rollback — a failed run must not leave the operator with a stopped stack and
# half-written artifacts.
# ---------------------------------------------------------------------------
# Resolve the name Compose would give the Postgres volume before the override
# exists, so a leftover volume can be spotted up front.
compose_project_name() {
local container project
container=$($DOCKER_COMPOSE_COMMAND ps -aq 2>/dev/null | head -1)
if [[ -n "$container" ]]; then
project=$(docker inspect "$container" \
--format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null)
if [[ -n "$project" ]]; then
echo "$project"
return 0
fi
fi
project=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval '.name // ""' - 2>/dev/null)
if [[ -n "$project" ]] && [[ "$project" != "null" ]]; then
echo "$project"
fi
return 0
}
postgres_volume_name() {
local project
project=$(compose_project_name)
if [[ -n "$project" ]]; then
echo "${project}_netbird_postgres"
fi
return 0
}
# Postgres skips initdb when its data directory is non-empty, so a volume left
# behind by an interrupted run would keep the old password and old contents,
# and migrate-store would fail against it.
check_stale_postgres_volume() {
[[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0
PG_VOLUME_NAME=$(postgres_volume_name)
if [[ -z "$PG_VOLUME_NAME" ]]; then
echo ""
echo " ⚠ Could not determine the Compose project name, so a Postgres volume"
echo " left over from an earlier attempt cannot be checked for. If a"
echo " previous run failed, remove it before continuing:"
echo " docker volume ls | grep netbird_postgres"
return 0
fi
docker volume inspect "$PG_VOLUME_NAME" &> /dev/null || return 0
echo ""
echo " ⚠ A Postgres volume from an earlier attempt already exists:"
echo " $PG_VOLUME_NAME"
echo " Postgres does not re-initialise a non-empty data directory, so the"
echo " migration would run against stale credentials and stale data."
local remove
remove=$(read_yes_no " Remove it and continue?" "y")
if [[ "$remove" != "yes" ]]; then
echo "" > /dev/stderr
echo "Aborted. Remove it manually with: docker volume rm $PG_VOLUME_NAME" > /dev/stderr
exit 1
fi
docker volume rm "$PG_VOLUME_NAME" > /dev/null
echo " Removed."
}
# Undo whatever this run changed and start the previous deployment again.
rollback() {
ROLLBACK_STATE="done"
echo ""
echo "──────────────────────────────────────────────────────────────────────"
echo " Migration failed — restoring the previous deployment"
echo "──────────────────────────────────────────────────────────────────────"
# Resolve while the override is still present; without it Compose no longer
# knows about the Postgres volume.
local pg_volume="$PG_VOLUME_NAME"
if [[ -z "$pg_volume" ]] && [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
pg_volume=$(postgres_volume_name)
fi
echo ""
echo "Stopping services ..."
$DOCKER_COMPOSE_COMMAND down || true
echo "Removing generated files ..."
rm -f "$OVERRIDE_FILE" "$ENTERPRISE_CONFIG_FILE"
# Restore .env to exactly what it was, or remove it if this run created it.
if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
mv -f "$ENV_BACKUP" .env || echo " ⚠ Could not restore .env from $ENV_BACKUP." > /dev/stderr
elif [[ "$ENV_EXISTED" == "no" ]]; then
rm -f .env || true
fi
# Only ever the volume this run created — never the NetBird data volume.
if [[ -n "$pg_volume" ]] && [[ "$pg_volume" != "null" ]]; then
echo "Removing Postgres volume $pg_volume ..."
docker volume rm "$pg_volume" &> /dev/null || true
fi
echo "Starting the previous deployment ..."
if ! $DOCKER_COMPOSE_COMMAND up -d; then
echo ""
echo " ⚠ Could not start the previous deployment automatically." > /dev/stderr
echo " Run: $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr
fi
echo ""
echo "Rolled back. Your docker-compose.yml, config.yaml and the NetBird data"
echo "volume were never modified."
if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
echo "The SQLite backup taken during this run is kept at:"
echo " $BACKUP_DIR"
fi
echo "──────────────────────────────────────────────────────────────────────"
}
on_exit() {
local code=$?
trap - EXIT
if [[ $code -ne 0 ]] && [[ "$ROLLBACK_STATE" == "armed" ]]; then
rollback
fi
exit $code
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main # Main
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -661,12 +446,12 @@ init_migration() {
COMPOSE_NETWORK=$(detect_compose_network) COMPOSE_NETWORK=$(detect_compose_network)
if [[ -z "$COMBINED_SERVICE" ]]; then if [[ -z "$COMBINED_SERVICE" ]]; then
echo "Could not find a service running netbirdio/netbird-server or ghcr.io/netbirdio/netbird-server in $COMPOSE_FILE." > /dev/stderr echo "Could not find a service running netbirdio/netbird-server* in $COMPOSE_FILE." > /dev/stderr
echo "This script targets the community combined-server deployment." > /dev/stderr echo "This script targets the community combined-server deployment." > /dev/stderr
exit 1 exit 1
fi fi
if [[ -z "$DASHBOARD_SERVICE" ]]; then if [[ -z "$DASHBOARD_SERVICE" ]]; then
echo "Could not find a service running netbirdio/dashboard or ghcr.io/netbirdio/dashboard in $COMPOSE_FILE." > /dev/stderr echo "Could not find a service running netbirdio/dashboard* in $COMPOSE_FILE." > /dev/stderr
exit 1 exit 1
fi fi
if [[ -z "$CONFIG_YAML_HOST" ]]; then if [[ -z "$CONFIG_YAML_HOST" ]]; then
@@ -756,15 +541,9 @@ init_migration() {
ENABLE_FLOW="no" ENABLE_FLOW="no"
echo "Step 3 (traffic flow) skipped — requires Postgres." echo "Step 3 (traffic flow) skipped — requires Postgres."
fi fi
check_data_directory
check_stale_postgres_volume
} }
apply_changes() { apply_changes() {
# From here on a failure must roll the deployment back.
ROLLBACK_STATE="armed"
echo "" echo ""
echo "Writing $OVERRIDE_FILE ..." echo "Writing $OVERRIDE_FILE ..."
install -m 644 /dev/null "$OVERRIDE_FILE" install -m 644 /dev/null "$OVERRIDE_FILE"
@@ -785,14 +564,6 @@ apply_changes() {
# picks it up automatically. # picks it up automatically.
echo "Writing .env additions (mode 600) ..." echo "Writing .env additions (mode 600) ..."
local ENV_FILE=".env" local ENV_FILE=".env"
# Snapshot the operator's .env so a rollback can restore it byte for byte.
if [[ -f "$ENV_FILE" ]]; then
ENV_EXISTED="yes"
ENV_BACKUP="${ENV_FILE}.pre-enterprise-$(date +%Y%m%d-%H%M%S)"
cp -p "$ENV_FILE" "$ENV_BACKUP"
else
ENV_EXISTED="no"
fi
touch "$ENV_FILE" touch "$ENV_FILE"
chmod 600 "$ENV_FILE" chmod 600 "$ENV_FILE"
{ {
@@ -821,16 +592,11 @@ apply_changes() {
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
echo "" echo ""
# Stop, but keep the containers: the backup reads the store out of one. echo "Stopping existing services (volumes preserved) ..."
echo "Stopping services so the store is quiescent ..." $DOCKER_COMPOSE_COMMAND down
$DOCKER_COMPOSE_COMMAND stop
backup_sqlite backup_sqlite
echo ""
echo "Removing stopped containers (volumes preserved) ..."
$DOCKER_COMPOSE_COMMAND down
echo "" echo ""
echo "Starting Postgres ..." echo "Starting Postgres ..."
$DOCKER_COMPOSE_COMMAND up -d postgres $DOCKER_COMPOSE_COMMAND up -d postgres
@@ -860,9 +626,6 @@ apply_changes() {
echo "" echo ""
echo "Migration complete." echo "Migration complete."
# Nothing left to undo.
ROLLBACK_STATE="disarmed"
} }
print_summary() { print_summary() {
@@ -880,7 +643,6 @@ print_summary() {
echo " $OVERRIDE_FILE" echo " $OVERRIDE_FILE"
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE" [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
echo " .env (license key + secrets, mode 600)" echo " .env (license key + secrets, mode 600)"
[[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]] && echo " $ENV_BACKUP (.env as it was before this run)"
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)" [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)"
echo "" echo ""
echo " Tail logs:" echo " Tail logs:"
@@ -889,27 +651,19 @@ print_summary() {
echo "──────────────────────────────────────────────────────────────────────" echo "──────────────────────────────────────────────────────────────────────"
echo " To revert" echo " To revert"
echo "──────────────────────────────────────────────────────────────────────" echo "──────────────────────────────────────────────────────────────────────"
echo " $DOCKER_COMPOSE_COMMAND down"
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
# Resolve the project-prefixed volume name now, before the override is gone. # Resolve project-prefixed volume names now (before override is removed).
local pg_volume local pg_volume data_volume_actual
pg_volume=$(resolve_compose_volume "netbird_postgres") pg_volume=$(resolve_data_volume "netbird_postgres")
echo " # Stop, but keep the containers so the store can be copied back in:" data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
echo " $DOCKER_COMPOSE_COMMAND stop" echo " # Remove the Postgres volume FIRST, before deleting the override file:"
echo " # Restore SQLite from the backup created during this run:"
echo " docker cp ${BACKUP_DIR}/. \$($DOCKER_COMPOSE_COMMAND ps -aq $COMBINED_SERVICE):/var/lib/netbird/"
echo " $DOCKER_COMPOSE_COMMAND down"
echo " docker volume rm $pg_volume" echo " docker volume rm $pg_volume"
else echo " # Restore SQLite from the backup created during this run:"
echo " $DOCKER_COMPOSE_COMMAND down" echo " docker run --rm -v ${data_volume_actual}:/var/lib/netbird -v ${BACKUP_DIR}:/backup busybox sh -c 'cp -a /backup/. /var/lib/netbird/'"
fi fi
echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE" echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
echo " mv $ENV_BACKUP .env # restores .env as it was before this run"
elif [[ "$ENV_EXISTED" == "no" ]]; then
echo " rm -f .env # created by this run"
else
echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
fi
echo " $DOCKER_COMPOSE_COMMAND up -d" echo " $DOCKER_COMPOSE_COMMAND up -d"
echo "──────────────────────────────────────────────────────────────────────" echo "──────────────────────────────────────────────────────────────────────"
} }
@@ -918,10 +672,6 @@ print_summary() {
# Run # Run
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
trap on_exit EXIT
# Turn signals into a normal exit so the EXIT trap can roll back.
trap 'exit 130' INT TERM
init_migration init_migration
apply_changes apply_changes
print_summary print_summary

View File

@@ -176,7 +176,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
semaphore := make(chan struct{}, 10) semaphore := make(chan struct{}, 10)
c.injectAllProxyPolicies(ctx, account) c.injectAllProxyPolicies(ctx, account)
account.PrecomputePostureValidation(ctx)
dnsCache := &cache.DNSConfigCache{} dnsCache := &cache.DNSConfigCache{}
dnsDomain := c.GetDNSDomain(account.Settings) dnsDomain := c.GetDNSDomain(account.Settings)
peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
@@ -358,7 +357,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
// network map that omitted the synth DNS zone, and the agent kept // network map that omitted the synth DNS zone, and the agent kept
// resolving against the stale or absent record. // resolving against the stale or absent record.
c.injectAllProxyPolicies(ctx, account) c.injectAllProxyPolicies(ctx, account)
account.PrecomputePostureValidation(ctx)
dnsCache := &cache.DNSConfigCache{} dnsCache := &cache.DNSConfigCache{}
dnsDomain := c.GetDNSDomain(account.Settings) dnsDomain := c.GetDNSDomain(account.Settings)
peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)

View File

@@ -102,8 +102,8 @@ func TestSettingsHandler_GetExposesCollectionToggles(t *testing.T) {
require.NoError(t, f.store.SaveAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{ require.NoError(t, f.store.SaveAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{
AccountID: testAccountID, AccountID: testAccountID,
Domain: "violet.eu.proxy.netbird.io", Cluster: "eu.proxy.netbird.io",
ProxyAddress: "eu.proxy.netbird.io", Subdomain: "violet",
EnableLogCollection: true, EnableLogCollection: true,
EnablePromptCollection: true, EnablePromptCollection: true,
RedactPii: false, RedactPii: false,

View File

@@ -155,7 +155,12 @@ func (h *handler) createProvider(w http.ResponseWriter, r *http.Request) {
provider := types.NewProvider(userAuth.AccountId) provider := types.NewProvider(userAuth.AccountId)
provider.FromAPIRequest(&req) provider.FromAPIRequest(&req)
created, err := h.manager.CreateProvider(r.Context(), userAuth.UserId, provider) bootstrapCluster := ""
if req.BootstrapCluster != nil {
bootstrapCluster = *req.BootstrapCluster
}
created, err := h.manager.CreateProvider(r.Context(), userAuth.UserId, provider, bootstrapCluster)
if err != nil { if err != nil {
util.WriteError(r.Context(), err, w) util.WriteError(r.Context(), err, w)
return return

View File

@@ -12,55 +12,13 @@ import (
"github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/http/util"
) )
// addSettingsEndpoints registers the Agent Network settings routes. POST // addSettingsEndpoints registers the Agent Network settings routes. The
// bootstraps the settings row, assigning the account's immutable endpoint; // settings row is bootstrapped server-side on first provider create or on the
// GET reads it (defaults with an empty endpoint before bootstrap); PUT // first PUT carrying a cluster; GET reads it and PUT applies a partial update
// carries every field, replacing the mutable collection toggles and rejecting // of the mutable collection toggles (cluster/subdomain stay immutable).
// any change to the identity fields; DELETE removes the row — guarded so it
// stays a bootstrap-repair operation — releasing the endpoint for a fresh
// bootstrap.
func (h *handler) addSettingsEndpoints(router *mux.Router) { func (h *handler) addSettingsEndpoints(router *mux.Router) {
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS") router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
router.HandleFunc("/agent-network/settings", h.createSettings).Methods("POST", "OPTIONS")
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS") router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
router.HandleFunc("/agent-network/settings", h.deleteSettings).Methods("DELETE", "OPTIONS")
}
// createSettings bootstraps the account's settings row. Exactly one of
// proxy_address (labeled endpoint; the server allocates the label) and
// endpoint (self-addressed, claimed verbatim) must be provided; optional
// collection toggles ride along with defaults for omitted fields.
func (h *handler) createSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
var req api.AgentNetworkSettingsCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
return
}
settings := types.DefaultSettings(userAuth.AccountId)
settings.FromAPICreateRequest(&req)
proxyAddress := ""
if req.ProxyAddress != nil {
proxyAddress = *req.ProxyAddress
}
endpoint := ""
if req.Endpoint != nil {
endpoint = *req.Endpoint
}
created, err := h.manager.CreateSettings(r.Context(), userAuth.UserId, settings, proxyAddress, endpoint)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
} }
// updateSettings replaces the mutable settings fields on the account's row. // updateSettings replaces the mutable settings fields on the account's row.
@@ -90,24 +48,6 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
} }
// deleteSettings removes the account's settings row, releasing the endpoint.
// The manager refuses (412) while providers exist or a proxy is actively
// serving the endpoint; a later POST bootstraps fresh, allocating a new
// endpoint.
func (h *handler) deleteSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
if err := h.manager.DeleteSettings(r.Context(), userAuth.AccountId, userAuth.UserId); err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
}
// getSettings returns the account's agent-network settings. Accounts that // getSettings returns the account's agent-network settings. Accounts that
// haven't been bootstrapped yet read as the defaults with an empty cluster, // haven't been bootstrapped yet read as the defaults with an empty cluster,
// subdomain and endpoint; the manager synthesises that view. // subdomain and endpoint; the manager synthesises that view.

View File

@@ -1,25 +1,20 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
"github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/api"
) )
// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read // TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read
// convention shared with the account and DNS settings endpoints: settings // convention shared with the account and DNS settings endpoints: settings
// always read as a JSON object. Before bootstrap that object carries the // always read as a JSON object. Before bootstrap that object carries the
// defaults with an empty endpoint/proxy_address (the "not bootstrapped" // defaults with an empty cluster/subdomain/endpoint (the "not bootstrapped"
// signal) and no timestamps — never a 404 and never the legacy null body. // signal) and no timestamps — never a 404 and never the legacy null body.
func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) { func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
f := newAgentNetworkHandlerFixture(t) f := newAgentNetworkHandlerFixture(t)
@@ -32,9 +27,9 @@ func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
var got api.AgentNetworkSettings var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped") assert.Empty(t, got.Cluster, "cluster must be empty until bootstrapped")
assert.Empty(t, got.ProxyAddress, "proxy address must be empty until bootstrapped") assert.Empty(t, got.Subdomain, "subdomain must be empty until bootstrapped")
assert.False(t, got.Dedicated, "an unbootstrapped account has no serving shape") assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped, not a bare dot")
assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap") assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off") assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off")
assert.False(t, got.RedactPii, "defaults must show redaction off") assert.False(t, got.RedactPii, "defaults must show redaction off")
@@ -44,149 +39,62 @@ func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists") assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists")
} }
// TestSettingsHandler_PostBootstrapsLabeled covers the labeled bootstrap // TestSettingsHandler_PutBootstrapsWithCluster covers the settings-first
// shape: a POST carrying a proxy_address allocates a label beneath it, so the // bootstrap path: a PUT carrying a cluster on an unbootstrapped account
// endpoint hangs one label under the shared cluster's address and the pin is // creates the row (cluster pinned, subdomain assigned) and applies the
// not dedicated. Toggles riding along apply; omitted ones keep defaults. // mutable fields from the same request.
func TestSettingsHandler_PostBootstrapsLabeled(t *testing.T) { func TestSettingsHandler_PutBootstrapsWithCluster(t *testing.T) {
f := newAgentNetworkHandlerFixture(t) f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings", rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "access_log_retention_days": 14}`) `{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "eu.proxy.netbird.io", got.ProxyAddress, "proxy address must be pinned from the request") assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be pinned from the request")
require.NotEmpty(t, got.Endpoint, "endpoint must be allocated at bootstrap") assert.NotEmpty(t, got.Subdomain, "subdomain must be assigned at bootstrap")
assert.True(t, strings.HasSuffix(got.Endpoint, ".eu.proxy.netbird.io"), assert.Equal(t, got.Subdomain+".eu.proxy.netbird.io", got.Endpoint, "endpoint must combine subdomain and cluster")
"labeled endpoint must hang off the proxy address: %s", got.Endpoint) assert.True(t, got.EnableLogCollection, "toggle from the bootstrap request must apply")
label := strings.TrimSuffix(got.Endpoint, ".eu.proxy.netbird.io")
assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label)
assert.False(t, got.Dedicated, "a labeled pin is not dedicated")
assert.True(t, got.EnableLogCollection, "omitted toggle must keep its default")
assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply") assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply")
require.NotNil(t, got.AccessLogRetentionDays) require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 14, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply") assert.Equal(t, 30, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
assert.NotNil(t, got.CreatedAt, "a persisted row carries timestamps")
// The row is now readable via GET. // The row is now readable via GET.
rec = f.do(t, http.MethodGet, "/agent-network/settings", "") rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed") require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed")
var read api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &read))
assert.Equal(t, got.Endpoint, read.Endpoint, "GET must return the bootstrapped endpoint")
} }
// TestSettingsHandler_PostBootstrapsSelfAddressed covers the dedicated shape: // TestSettingsHandler_PutWithoutClusterOnUnbootstrapped pins that a PUT
// a POST carrying an endpoint claims the hostname verbatim, the proxy address // without a cluster cannot conjure a settings row out of nothing — there is
// equals it, and the pin reads as dedicated. The claim is legitimate before // no cluster to pin — and surfaces as 404 like the GET.
// any proxy declares the address (address-first). func TestSettingsHandler_PutWithoutClusterOnUnbootstrapped(t *testing.T) {
func TestSettingsHandler_PostBootstrapsSelfAddressed(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"endpoint": "Brave-Otter.Gateway.Example.com"}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "brave-otter.gateway.example.com", got.Endpoint,
"endpoint must be claimed verbatim, lowercased")
assert.Equal(t, got.Endpoint, got.ProxyAddress, "self-addressed: the proxy address is the endpoint")
assert.True(t, got.Dedicated, "a self-addressed pin is dedicated")
assert.True(t, got.EnableLogCollection, "omitted toggles must keep their defaults")
}
// TestSettingsHandler_PostRequiresExactlyOneIdentityField pins the request
// contract: proxy_address and endpoint are mutually exclusive and one is
// required — both or neither is a validation error, not a guess.
func TestSettingsHandler_PostRequiresExactlyOneIdentityField(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{}`)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"empty POST must be rejected: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "endpoint": "brave-otter.gateway.example.com"}`)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"POST with both identity fields must be rejected: got %d body=%s", rec.Code, rec.Body.String())
}
// TestSettingsHandler_PostRejectsMalformedHostnames pins per-write input
// validation: shapes canonicalization cannot repair — trailing dots, embedded
// whitespace, empty labels — are rejected with a validation error instead of
// landing in an immutable column.
func TestSettingsHandler_PostRejectsMalformedHostnames(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
for name, body := range map[string]string{
"trailing dot": `{"endpoint": "gateway.example.com."}`,
"leading dot": `{"endpoint": ".gateway.example.com"}`,
"inner whitespace": `{"endpoint": "gate way.example.com"}`,
"empty label": `{"proxy_address": "eu..proxy.netbird.io"}`,
} {
rec := f.do(t, http.MethodPost, "/agent-network/settings", body)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String())
}
}
// TestSettingsHandler_PostConflictsOnSecondBootstrap pins that bootstrap is a
// one-time create: a second POST returns 409 and leaves the row untouched.
func TestSettingsHandler_PostConflictsOnSecondBootstrap(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
require.Equal(t, http.StatusOK, rec.Code, "first bootstrap must succeed: %s", rec.Body.String())
var first api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &first))
rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "us.proxy.netbird.io"}`)
assert.Equal(t, http.StatusConflict, rec.Code,
"second bootstrap must 409: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, first.Endpoint, got.Endpoint, "the original endpoint must survive the rejected bootstrap")
assert.Equal(t, first.ProxyAddress, got.ProxyAddress, "the original proxy address must survive")
}
// TestSettingsHandler_PutBeforeBootstrapIs404 pins that a PUT cannot conjure a
// settings row out of nothing — bootstrap is the explicit POST — and the
// error points the caller there.
func TestSettingsHandler_PutBeforeBootstrapIs404(t *testing.T) {
f := newAgentNetworkHandlerFixture(t) f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings", rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`) `{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`)
assert.Equal(t, http.StatusNotFound, rec.Code, assert.Equal(t, http.StatusNotFound, rec.Code,
"PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String()) "cluster-less PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "/api/agent-network/settings", assert.Contains(t, rec.Body.String(), "cluster",
"the error must point the caller at the bootstrap POST: %s", rec.Body.String()) "the error must point the caller at the bootstrap paths: %s", rec.Body.String())
} }
// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared // TestSettingsHandler_PutReplacesMutableFields pins the update contract shared
// with the other PUT endpoints: the request carries every field, replacing the // with the other PUT endpoints: the request replaces every mutable field, so a
// mutable ones. The identity fields ride along as a required echo of the // toggle absent from the JSON lands as its zero value rather than being
// assigned values — compared, never written — so the endpoint and proxy // preserved. Cluster and subdomain survive untouched.
// address survive every accepted update.
func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) { func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
f := newAgentNetworkHandlerFixture(t) f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings", rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`) `{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
var before api.AgentNetworkSettings var before api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before)) require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf( rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 7}`, `{"enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
before.Endpoint, before.ProxyAddress))
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String()) require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings var got api.AgentNetworkSettings
@@ -195,201 +103,35 @@ func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
assert.False(t, got.EnablePromptCollection, "sent toggle must apply") assert.False(t, got.EnablePromptCollection, "sent toggle must apply")
assert.False(t, got.RedactPii, "sent toggle must apply") assert.False(t, got.RedactPii, "sent toggle must apply")
require.NotNil(t, got.AccessLogRetentionDays) require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 7, *got.AccessLogRetentionDays, "sent retention must apply")
assert.Equal(t, before.Endpoint, got.Endpoint, "endpoint must survive updates untouched")
assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "proxy address must survive updates untouched")
}
// TestSettingsHandler_PutRejectsChangedIdentity pins the immutability contract:
// the PUT carries the identity fields like every other field, but they are an
// echo — a request carrying a different endpoint or proxy address is rejected
// as a validation error and the row is left untouched. The comparison is
// lenient about casing (the stored values are normalized lowercase), so a
// client replaying a GET response with different casing is not rejected.
func TestSettingsHandler_PutRejectsChangedIdentity(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
var before api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
for name, body := range map[string]string{
"changed endpoint": fmt.Sprintf(
`{"endpoint": "other.gateway.example.com", "proxy_address": %q, "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
before.ProxyAddress),
"changed proxy_address": fmt.Sprintf(
`{"endpoint": %q, "proxy_address": "us.proxy.netbird.io", "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
before.Endpoint),
"omitted identity": `{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
} {
rec = f.do(t, http.MethodPut, "/agent-network/settings", body)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String())
}
// The rejected updates must not have applied anything — toggles included.
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, before.Endpoint, got.Endpoint, "rejected PUT must not change the endpoint")
assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "rejected PUT must not change the proxy address")
assert.True(t, got.EnablePromptCollection, "rejected PUT must not apply its toggles")
// An uppercased echo of the assigned values still names the same host and
// must be accepted.
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`,
strings.ToUpper(before.Endpoint), strings.ToUpper(before.ProxyAddress)))
assert.Equal(t, http.StatusOK, rec.Code,
"an uppercased identity echo must be accepted: got %d body=%s", rec.Code, rec.Body.String())
}
// TestSettingsHandler_PutOmittedRetentionLandsAsZero documents a residual the
// required-ness of access_log_retention_days does not remove. Marking the field
// required changes the generated client type from *int to int, so a generated
// client cannot omit it — but nothing validates OpenAPI required-ness at
// runtime, so a hand-rolled body without the field still decodes as 0, which
// the API documents as "keep indefinitely".
//
// That is the same latitude the three booleans already have, so it is left
// consistent rather than special-cased. This test exists to make the gap
// explicit: if request validation is ever added, this expectation is what
// changes.
func TestSettingsHandler_PutOmittedRetentionLandsAsZero(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "access_log_retention_days": 14}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
var before api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`,
before.Endpoint, before.ProxyAddress))
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 0, *got.AccessLogRetentionDays, assert.Equal(t, 0, *got.AccessLogRetentionDays,
"a non-conforming body that omits retention still replaces it with the zero value") "retention absent from the request must land as the zero value — PUT replaces all mutable fields")
assert.Equal(t, before.Cluster, got.Cluster, "cluster must survive updates untouched")
assert.Equal(t, before.Subdomain, got.Subdomain, "subdomain must survive updates untouched")
} }
// TestSettingsHandler_DeleteBeforeBootstrapIs404 pins that DELETE on an // TestSettingsHandler_PutRejectsClusterChange pins cluster immutability: once
// account with no settings row is a 404, mirroring the PUT. // assigned, a differing cluster is rejected as a validation error instead of
func TestSettingsHandler_DeleteBeforeBootstrapIs404(t *testing.T) { // being silently ignored, so callers never observe a value other than the one
// they sent. Echoing the assigned cluster back stays valid, which lets
// declarative clients send their full desired state idempotently.
func TestSettingsHandler_PutRejectsClusterChange(t *testing.T) {
f := newAgentNetworkHandlerFixture(t) f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodDelete, "/agent-network/settings", "") rec := f.do(t, http.MethodPut, "/agent-network/settings",
assert.Equal(t, http.StatusNotFound, rec.Code, `{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
"DELETE on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String()) require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
}
// TestSettingsHandler_DeleteBlockedByProviders pins the first delete guard: rec = f.do(t, http.MethodPut, "/agent-network/settings",
// while any provider exists for the account, the delete is refused with 412 `{"cluster": "us.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
// and the row survives. Providers route through the endpoint — the guard assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
// keeps DELETE a bootstrap-repair operation rather than a way to abandon a "cluster change must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String())
// configured gateway.
func TestSettingsHandler_DeleteBlockedByProviders(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`) rec = f.do(t, http.MethodPut, "/agent-network/settings",
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) `{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": true}`)
var before api.AgentNetworkSettings require.Equal(t, http.StatusOK, rec.Code, "echoing the assigned cluster must stay valid: %s", rec.Body.String())
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
f.seedProvider(t, "prov-guard")
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
"delete with a provider present must be refused: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var got api.AgentNetworkSettings var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, before.Endpoint, got.Endpoint, "the refused delete must leave the row intact") assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be unchanged")
} assert.True(t, got.RedactPii, "toggle sent alongside the echoed cluster must apply")
// TestSettingsHandler_DeleteBlockedByActiveProxy pins the second delete
// guard: while a proxy is actively serving the endpoint — an active proxy
// row declaring the endpoint hostname as its cluster address, the dedicated
// shape — the delete is refused with 412. A proxy that has disconnected no
// longer blocks: the guard is about a live serving path, not history.
//
// The proxy declares its address with mixed casing on purpose: Connect
// stores the declared address verbatim while the settings row is normalized
// lowercase, and hostnames are case-insensitive, so the guard must match
// across the casing difference rather than be sidestepped by it.
func TestSettingsHandler_DeleteBlockedByActiveProxy(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
const endpoint = "gw.dedicated.example.com"
rec := f.do(t, http.MethodPost, "/agent-network/settings", fmt.Sprintf(`{"endpoint": %q}`, endpoint))
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
now := time.Now()
accountID := testAccountID
proxyRow := &rpproxy.Proxy{
ID: "proxy-guard",
SessionID: "sess-1",
ClusterAddress: "GW.Dedicated.Example.Com",
AccountID: &accountID,
LastSeen: now,
ConnectedAt: &now,
Status: rpproxy.StatusConnected,
}
require.NoError(t, f.store.SaveProxy(context.Background(), proxyRow))
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
"delete with an active proxy at the endpoint must be refused: got %d body=%s", rec.Code, rec.Body.String())
// Once the proxy disconnects it no longer serves the endpoint, so the
// delete goes through.
require.NoError(t, f.store.DisconnectProxy(context.Background(), proxyRow.ID, proxyRow.SessionID))
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
assert.Equal(t, http.StatusOK, rec.Code,
"delete after the proxy disconnected must succeed: got %d body=%s", rec.Code, rec.Body.String())
}
// TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap pins the
// full-reset semantic that gives replace-on-change clients (e.g. Terraform's
// RequiresReplace) a real path: with both guards clear the delete succeeds,
// the account reads as the defaults again, and a fresh bootstrap draws a
// fresh label. The released hostname is not reserved — a fresh draw may even
// legitimately re-pick it — so the assertions check the new row's shape, not
// that the label differs.
func TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code,
"delete with both guards clear must succeed: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var after api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped defaults")
assert.False(t, after.EnablePromptCollection, "the deleted row's toggles must not linger")
rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
require.Equal(t, http.StatusOK, rec.Code, "re-bootstrap after delete must succeed: %s", rec.Body.String())
var second api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &second))
require.NotEmpty(t, second.Endpoint, "the fresh bootstrap must allocate an endpoint")
assert.True(t, strings.HasSuffix(second.Endpoint, ".eu.proxy.netbird.io"),
"the fresh endpoint must hang beneath the requested proxy address: %s", second.Endpoint)
assert.False(t, second.EnablePromptCollection,
"the fresh row must carry bootstrap defaults, not the deleted row's toggles")
assert.NotNil(t, second.CreatedAt, "the fresh row is persisted and carries timestamps")
} }

View File

@@ -1,37 +0,0 @@
package labelgen
// adjectives is the descriptor half of a generated label. It pairs with the
// noun pool in words.go to form `<adjective>-<noun>` labels, and is kept
// separate because words.go is almost entirely nouns — drawing both halves
// from it produced unreadable pairs like "millet-hammock". Entries are
// lowercase ASCII, 4-12 chars, free of hyphens and digits, screened for
// offensive/brand/region-specific terms, and disjoint from the noun pool
// (enforced by TestAdjectives_AreDisjointFromNouns).
var adjectives = []string{
"able", "active", "adept", "agile", "airy", "alert", "amiable", "ample",
"ancient", "ardent", "artful", "astute", "balmy", "blithe", "bold", "bonny",
"brave", "breezy", "brisk", "bubbly", "buoyant", "bushy", "candid", "canny",
"cheery", "chilly", "chipper", "chunky", "civil", "classic", "clever", "comely",
"compact", "cordial", "cosmic", "courtly", "crafty", "creamy", "crisp", "cuddly",
"curious", "dainty", "dapper", "daring", "dashing", "deft", "dewy", "diligent",
"downy", "dreamy", "dulcet", "durable", "dusky", "eager", "earnest", "earthy",
"easy", "elated", "elegant", "epic", "fabled", "faithful", "fancy", "fearless",
"feisty", "fervent", "fleet", "fluffy", "fond", "frisky", "frosty", "gallant",
"genial", "genteel", "gentle", "giddy", "gilded", "glad", "glassy", "gleaming",
"glossy", "graceful", "grand", "grainy", "hale", "hardy", "hearty", "hefty",
"honest", "hopeful", "humble", "hushed", "immense", "jaunty", "jolly", "jovial",
"joyful", "jubilant", "keen", "kindly", "kindred", "lanky", "leafy", "limber",
"lively", "lofty", "loyal", "lucent", "lucid", "luminous", "lush", "maroon",
"mellow", "merry", "mighty", "mindful", "mirthful", "misty", "modest", "muted",
"nifty", "nimble", "noble", "patient", "peaceful", "pearly", "peppy", "perky",
"petite", "placid", "playful", "pleasant", "plucky", "plush", "polite", "posh",
"prancing", "pristine", "prompt", "proud", "prudent", "quaint", "quick", "quirky",
"radiant", "ready", "regal", "restful", "robust", "rosy", "ruddy", "rugged",
"sandy", "satin", "saucy", "savvy", "sedate", "serene", "shady", "shiny",
"silken", "silky", "sincere", "sleek", "slender", "smart", "smooth", "snappy",
"snug", "soaring", "sparkly", "spiffy", "spirited", "sprightly", "spry", "stalwart",
"stately", "steady", "sterling", "stoic", "stormy", "stout", "sturdy", "sunlit",
"supple", "svelte", "tawny", "tender", "tidy", "timeless", "trusty", "upbeat",
"urbane", "valiant", "vast", "vernal", "vibrant", "vintage", "whimsy", "willing",
"windy", "winsome", "wintry", "witty", "worthy", "zesty", "zippy",
}

View File

@@ -64,20 +64,3 @@ func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string
w := pool[rng.Intn(len(pool))] w := pool[rng.Intn(len(pool))]
return fmt.Sprintf("%s-%s", w, fallbackSuffix) return fmt.Sprintf("%s-%s", w, fallbackSuffix)
} }
// PickTuple returns an adjective-noun label such as "brave-otter". It is still
// a single DNS label.
//
// Unlike PickUnique it takes no `taken` set and has no fallback suffix. The
// noun pool holds 857 entries, which is ample per cluster but a hard ceiling
// once labels must be unique across one shared zone; pairing an adjective with
// a noun spans len(adjectives) * 857 instead. Uniqueness is enforced by a
// database constraint and retried by the caller, rather than guessed from a
// pre-read set that a concurrent allocation can invalidate.
func PickTuple(rng *rand.Rand) string {
nouns := uniqueWords()
if len(nouns) == 0 || len(adjectives) == 0 {
return ""
}
return adjectives[rng.Intn(len(adjectives))] + "-" + nouns[rng.Intn(len(nouns))]
}

View File

@@ -99,82 +99,3 @@ func TestUniqueWords_DropsDuplicates(t *testing.T) {
} }
assert.GreaterOrEqual(t, len(pool), 500, "Pool must contain at least 500 unique words") assert.GreaterOrEqual(t, len(pool), 500, "Pool must contain at least 500 unique words")
} }
// TestPickTuple_ShapeAndPoolMembership locks the wire-visible shape: an
// adjective and a noun, each from its own pool, joined by a single hyphen so
// the result stays one DNS label.
func TestPickTuple_ShapeAndPoolMembership(t *testing.T) {
nouns := uniqueWords()
inNouns := make(map[string]struct{}, len(nouns))
for _, w := range nouns {
inNouns[w] = struct{}{}
}
inAdjectives := make(map[string]struct{}, len(adjectives))
for _, a := range adjectives {
inAdjectives[a] = struct{}{}
}
rng := rand.New(rand.NewSource(7))
for i := 0; i < 200; i++ {
got := PickTuple(rng)
parts := strings.Split(got, "-")
require.Len(t, parts, 2, "PickTuple must produce exactly two hyphen-joined words; got %q", got)
_, adjOK := inAdjectives[parts[0]]
assert.True(t, adjOK, "First half must be an adjective; %q not in adjectives (from %q)", parts[0], got)
_, nounOK := inNouns[parts[1]]
assert.True(t, nounOK, "Second half must be a noun; %q not in words (from %q)", parts[1], got)
assert.LessOrEqual(t, len(got), 63, "Label must fit a DNS label; got %q (%d chars)", got, len(got))
}
}
// TestAdjectives_AreDisjointFromNouns keeps the namespace a clean product and
// prevents nonsense like "azure-azure": a handful of the noun pool's entries
// are adjectival, and any overlap would let the same word land on both sides.
func TestAdjectives_AreDisjointFromNouns(t *testing.T) {
nouns := make(map[string]struct{}, len(uniqueWords()))
for _, w := range uniqueWords() {
nouns[w] = struct{}{}
}
for _, a := range adjectives {
_, clash := nouns[a]
assert.False(t, clash, "Adjective %q also appears in the noun pool; remove it from one list", a)
}
}
// TestAdjectives_AreDNSSafeAndDeduplicated mirrors the curation contract stated
// in words.go: lowercase ASCII, 4-12 chars, no digits or hyphens, no repeats.
func TestAdjectives_AreDNSSafeAndDeduplicated(t *testing.T) {
seen := make(map[string]struct{}, len(adjectives))
for _, a := range adjectives {
_, dup := seen[a]
assert.False(t, dup, "Duplicate adjective %q", a)
seen[a] = struct{}{}
assert.Regexp(t, `^[a-z]{4,12}$`, a, "Adjective %q must be 4-12 lowercase ASCII letters", a)
}
assert.Greater(t, len(adjectives), 150, "Adjective pool too small to give a useful namespace")
}
// TestPickTuple_DeterministicWithSeededRng documents that generation is a pure
// function of the rng, which is what makes allocation retries reproducible in tests.
func TestPickTuple_DeterministicWithSeededRng(t *testing.T) {
a := PickTuple(rand.New(rand.NewSource(42)))
b := PickTuple(rand.New(rand.NewSource(42)))
assert.Equal(t, a, b, "Same seed must yield the same tuple")
}
// TestPickTuple_SpansALargeNamespace guards the reason we moved to tuples: a
// single-word pool caps the GLOBAL namespace at 857. Drawing many tuples must
// yield overwhelmingly distinct values.
func TestPickTuple_SpansALargeNamespace(t *testing.T) {
rng := rand.New(rand.NewSource(11))
seen := make(map[string]struct{}, 2000)
for i := 0; i < 2000; i++ {
seen[PickTuple(rng)] = struct{}{}
}
assert.Greater(t, len(seen), 1900,
"2000 draws should be nearly all distinct across a ~200k namespace; got %d unique", len(seen))
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/shared/management/status"
) )
@@ -47,7 +48,7 @@ func ensureSessionKeys(p *types.Provider) error {
type Manager interface { type Manager interface {
GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error)
GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error)
CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error)
UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error)
DeleteProvider(ctx context.Context, accountID, userID, providerID string) error DeleteProvider(ctx context.Context, accountID, userID, providerID string) error
@@ -70,9 +71,7 @@ type Manager interface {
DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error
GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error)
CreateSettings(ctx context.Context, userID string, settings *types.Settings, proxyAddress, endpoint string) (*types.Settings, error)
UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error)
DeleteSettings(ctx context.Context, accountID, userID string) error
ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error)
ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error)
@@ -124,10 +123,11 @@ type managerImpl struct {
proxyController proxy.Controller proxyController proxy.Controller
// reconcileCache holds the last set of synthesised proxy mappings // reconcileCache holds the last set of synthesised proxy mappings
// per account, each paired with the proxy that served it, so a change // per account so reconcile can emit precise Create/Update/Delete
// of serving proxy can be diffed without re-deriving it. // updates instead of a full re-push on every mutation. Keyed by
// accountID, then by synthesised service ID.
reconcileMu sync.Mutex reconcileMu sync.Mutex
reconcileCache map[string]map[string]syntheticMapping reconcileCache map[string]map[string]*proto.ProxyMapping
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source // labelRngMu guards labelRng. PickUnique consumes math/rand.Source
// state; concurrent provider creates would otherwise race. // state; concurrent provider creates would otherwise race.
@@ -151,7 +151,7 @@ func NewManager(
accountManager: accountManager, accountManager: accountManager,
permissionsManager: permissionsManager, permissionsManager: permissionsManager,
proxyController: proxyController, proxyController: proxyController,
reconcileCache: make(map[string]map[string]syntheticMapping), reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())), labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
} }
} }
@@ -170,14 +170,19 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID) return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
} }
// CreateProvider persists a new provider for the account. Providers have no // CreateProvider persists a new provider for the account. bootstrapCluster
// settings side effects: the account's endpoint is bootstrapped separately and // is used only when the per-account agent-network Settings row hasn't
// explicitly via CreateSettings, and every provider in the account routes // been created yet; otherwise it is ignored (the cluster is pinned on
// through it. // Settings and every provider in the account routes through it).
func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) { func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) {
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil { if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil {
return nil, err return nil, err
} }
if strings.TrimSpace(bootstrapCluster) != "" {
if err := m.requireSettingsBootstrapPermission(ctx, provider.AccountID, userID); err != nil {
return nil, err
}
}
// An empty api_key would silently produce a synthesised service // An empty api_key would silently produce a synthesised service
// that 401s on every upstream request. Surface the misconfiguration // that 401s on every upstream request. Surface the misconfiguration
@@ -201,6 +206,16 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
return nil, fmt.Errorf("save agent network provider: %w", err) return nil, fmt.Errorf("save agent network provider: %w", err)
} }
if strings.TrimSpace(bootstrapCluster) != "" {
if _, err := m.bootstrapSettingsIfNeeded(ctx, m.store, provider.AccountID, bootstrapCluster); err != nil {
// The provider create has already succeeded; logging the
// bootstrap miss matches the plan's PoC behaviour. The synth
// path treats a missing settings row as a no-op, and the next
// provider create retries the bootstrap.
log.WithContext(ctx).Debugf("agent-network bootstrap settings for account %s on cluster %s: %v", provider.AccountID, bootstrapCluster, err)
}
}
m.accountManager.StoreEvent(ctx, userID, provider.ID, provider.AccountID, activity.AgentNetworkProviderCreated, provider.EventMeta()) m.accountManager.StoreEvent(ctx, userID, provider.ID, provider.AccountID, activity.AgentNetworkProviderCreated, provider.EventMeta())
m.reconcile(ctx, provider.AccountID) m.reconcile(ctx, provider.AccountID)
@@ -545,44 +560,52 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
} }
// UpdateSettings replaces the mutable account-level settings — the collection // UpdateSettings replaces the mutable account-level settings — the collection
// toggles and retention — on the account's row. The identity fields (Domain, // toggles and retention — on the account's row. When the account has no
// ProxyAddress) are assigned at bootstrap (CreateSettings) and immutable: the // settings row yet, a non-empty settings.Cluster bootstraps one (same path as
// request carries them, matching the PUT convention of every other endpoint, // first provider create); without it the update fails with NotFound. On an
// but they are only compared against the stored row — a request carrying // existing row the cluster and subdomain are immutable: a differing
// different values is rejected, and the stored values are never overwritten. // settings.Cluster is rejected rather than silently ignored so callers never
// When the account has no settings row yet the update fails with NotFound. // observe a value other than what they sent. Because the collection toggles
// Because the collection toggles change the synthesised service config // change the synthesised service config (prompt-capture gating, access-log
// (prompt-capture gating, access-log emission), a reconcile is triggered so // emission), a reconcile is triggered so the proxy and peer network maps
// the proxy and peer network maps converge on the new state. // converge on the new state.
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) { func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil { if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil {
return nil, err return nil, err
} }
requestedCluster := strings.TrimSpace(settings.Cluster)
// The row lock from LockingStrengthUpdate only holds for the duration of // The row lock from LockingStrengthUpdate only holds for the duration of
// the surrounding transaction, so the read and the save must share one — // the surrounding transaction, so the read, the cluster-immutability
// otherwise concurrent PUTs could interleave between them. // check, and the save must share one — otherwise concurrent PUTs could
// interleave between them.
var updated *types.Settings var updated *types.Settings
err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error { err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID) existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
switch { switch {
case err == nil: case err == nil:
if requestedCluster != "" && requestedCluster != existing.Cluster {
return status.Errorf(status.InvalidArgument, "cluster is immutable once assigned (current: %s)", existing.Cluster)
}
case isNotFound(err): case isNotFound(err):
return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; POST /api/agent-network/settings to bootstrap them") if requestedCluster == "" {
return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; pass cluster to bootstrap them, or create a provider with bootstrap_cluster set")
}
// Bootstrapping pins the cluster and subdomain — a settings
// create on top of the update the caller already passed, matching
// the gate on the provider-create bootstrap path.
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil {
return err
}
existing, err = m.bootstrapSettingsIfNeeded(ctx, tx, settings.AccountID, requestedCluster)
if err != nil {
return err
}
default: default:
return fmt.Errorf("get agent network settings: %w", err) return fmt.Errorf("get agent network settings: %w", err)
} }
// The identity echo is compared leniently (trimmed, case-insensitive):
// the stored values are normalized lowercase, and a client replaying a
// GET response must never be rejected over casing it didn't choose.
if !hostnamesEquivalent(settings.Domain, existing.Domain) {
return status.Errorf(status.InvalidArgument, "endpoint is immutable: it must match the assigned endpoint %q; delete the settings to release it and bootstrap again", existing.Domain)
}
if !hostnamesEquivalent(settings.ProxyAddress, existing.ProxyAddress) {
return status.Errorf(status.InvalidArgument, "proxy_address is immutable: it must match the assigned proxy address %q; delete the settings to release it and bootstrap again", existing.ProxyAddress)
}
existing.EnableLogCollection = settings.EnableLogCollection existing.EnableLogCollection = settings.EnableLogCollection
existing.EnablePromptCollection = settings.EnablePromptCollection existing.EnablePromptCollection = settings.EnablePromptCollection
existing.RedactPii = settings.RedactPii existing.RedactPii = settings.RedactPii
@@ -609,83 +632,6 @@ func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, setting
return updated, nil return updated, nil
} }
// hostnamesEquivalent reports whether a caller-supplied hostname names the
// same host as a stored (normalized, lowercase) one: equal after trimming and
// case folding. No structural validation — an arbitrary mismatch and a
// malformed value are both simply "not the assigned value".
func hostnamesEquivalent(supplied, stored string) bool {
return strings.EqualFold(strings.TrimSpace(supplied), stored)
}
// DeleteSettings removes the account's settings row, releasing the endpoint.
// Two guards make this a bootstrap-repair operation rather than a way to tear
// down a serving gateway, both re-checked under the row lock:
//
// - No Agent Network providers may exist for the account. Providers route
// through the endpoint; delete them first.
// - No proxy may be actively serving the endpoint — that is, no active proxy
// declares the endpoint hostname as its cluster address. This is the
// dedicated (self-addressed) shape's guard: the proxy at the address IS
// this account's gateway. A labeled endpoint hangs beneath a shared
// cluster's address, and with the account's providers already gone the
// shared proxy serves nothing of the account's, so the parent cluster
// being up does not block the delete.
//
// Bootstrapping again after a delete allocates fresh — the released hostname
// is not reserved. That full-reset semantic is what gives clients that model
// immutability as replace-on-change (e.g. Terraform's RequiresReplace) a real
// path: tear down providers, delete, re-create.
func (m *managerImpl) DeleteSettings(ctx context.Context, accountID, userID string) error {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Delete); err != nil {
return err
}
var deleted *types.Settings
err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, accountID)
switch {
case err == nil:
case isNotFound(err):
return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; there is nothing to delete")
default:
return fmt.Errorf("get agent network settings: %w", err)
}
providers, err := tx.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return fmt.Errorf("get agent network providers: %w", err)
}
if len(providers) > 0 {
return status.Errorf(status.PreconditionFailed, "agent network settings cannot be deleted while %d provider(s) exist; delete the providers first", len(providers))
}
serving, err := tx.HasActiveProxyAtClusterAddress(ctx, existing.Domain)
if err != nil {
return fmt.Errorf("check for a proxy serving the endpoint: %w", err)
}
if serving {
return status.Errorf(status.PreconditionFailed, "agent network settings cannot be deleted while a proxy is actively serving the endpoint %q", existing.Domain)
}
if err := tx.DeleteAgentNetworkSettings(ctx, accountID); err != nil {
return fmt.Errorf("delete agent network settings: %w", err)
}
deleted = existing
return nil
})
if err != nil {
return err
}
m.accountManager.StoreEvent(ctx, userID, accountID, accountID, activity.AgentNetworkSettingsDeleted, map[string]any{
"endpoint": deleted.Domain,
"proxy_address": deleted.ProxyAddress,
})
m.reconcile(ctx, accountID)
return nil
}
// isNotFound reports whether err is a status.NotFound error. // isNotFound reports whether err is a status.NotFound error.
func isNotFound(err error) bool { func isNotFound(err error) bool {
var sErr *status.Error var sErr *status.Error
@@ -732,162 +678,74 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string)
} }
} }
// maxDomainAllocationAttempts bounds the label search when bootstrapping a // requireSettingsBootstrapPermission gates the one-time settings bootstrap a
// labeled endpoint. Package-level (rather than function-local) so tests can // first provider create performs. Pinning the account's cluster and subdomain
// assert on the exhaustion path without duplicating the literal. // is a settings write, so it needs the settings permission on top of the
const maxDomainAllocationAttempts = 10 // provider one. No-op once the settings row exists.
func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, accountID, userID string) error {
// CreateSettings bootstraps the per-account settings row, assigning the _, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
// account's immutable endpoint. Exactly one of proxyAddress and endpoint must if err == nil {
// be non-empty: proxyAddress allocates a labeled endpoint one label beneath return nil
// the given cluster address; endpoint claims the given hostname verbatim as a
// self-addressed (dedicated) endpoint — a legitimate claim before any proxy
// declares the address (address-first). settings carries the account ID and
// the initial collection toggles; its identity fields are assigned here.
func (m *managerImpl) CreateSettings(ctx context.Context, userID string, settings *types.Settings, proxyAddress, endpoint string) (*types.Settings, error) {
if settings == nil || settings.AccountID == "" {
return nil, status.Errorf(status.InvalidArgument, "account id is required")
} }
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil { if !isNotFound(err) {
return nil, err return fmt.Errorf("get agent network settings: %w", err)
}
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
}
// bootstrapSettingsIfNeeded creates the per-account agent-network
// settings row when missing. The cluster comes from the create-time
// hint the dashboard sends (auto-picked from the active cluster list);
// the subdomain is picked from the curated wordlist avoiding
// collisions on the same cluster. Idempotent: if a row already exists
// it is returned untouched and the hint is ignored. st is the store to
// operate on — pass the transaction store when calling from within one.
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.Store, accountID, providerCluster string) (*types.Settings, error) {
if accountID == "" {
return nil, fmt.Errorf("bootstrap settings: account id is required")
}
if strings.TrimSpace(providerCluster) == "" {
return nil, fmt.Errorf("bootstrap settings: provider cluster is required")
} }
hasProxyAddress := strings.TrimSpace(proxyAddress) != "" existing, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
hasEndpoint := strings.TrimSpace(endpoint) != "" if err == nil {
if hasProxyAddress == hasEndpoint { return existing, nil
return nil, status.Errorf(status.InvalidArgument, "exactly one of proxy_address and endpoint is required")
} }
if !isNotFound(err) {
// Fail fast on an existing row for a clean 409; the insert below stays
// the authority against concurrent bootstraps (the primary key wins).
if _, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, settings.AccountID); err == nil {
return nil, status.Errorf(status.AlreadyExists, "agent network settings already bootstrapped for account %s", settings.AccountID)
} else if !isNotFound(err) {
return nil, fmt.Errorf("get agent network settings: %w", err) return nil, fmt.Errorf("get agent network settings: %w", err)
} }
siblings, err := st.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
if err != nil {
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
}
taken := make(map[string]struct{}, len(siblings))
for _, s := range siblings {
taken[s.Subdomain] = struct{}{}
}
suffix := accountID
if len(suffix) > 4 {
suffix = suffix[:4]
}
m.labelRngMu.Lock()
subdomain := labelgen.PickUnique(m.labelRng, taken, suffix)
m.labelRngMu.Unlock()
now := time.Now().UTC() now := time.Now().UTC()
settings := types.DefaultSettings(accountID)
settings.Cluster = providerCluster
settings.Subdomain = subdomain
settings.CreatedAt = now settings.CreatedAt = now
settings.UpdatedAt = now settings.UpdatedAt = now
if err := st.SaveAgentNetworkSettings(ctx, settings); err != nil {
var err error return nil, fmt.Errorf("save agent network settings: %w", err)
if hasEndpoint {
err = m.bootstrapSelfAddressed(ctx, settings, endpoint)
} else {
err = m.bootstrapLabeled(ctx, settings, proxyAddress)
} }
if err != nil {
return nil, err
}
m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
"bootstrapped": true,
"endpoint": settings.Domain,
"dedicated": settings.Dedicated(),
})
m.reconcile(ctx, settings.AccountID)
return settings, nil return settings, nil
} }
// bootstrapSelfAddressed claims the given hostname as the account's endpoint,
// served only by a proxy declaring exactly that address (Domain ==
// ProxyAddress). The domain unique index is the arbiter of availability.
func (m *managerImpl) bootstrapSelfAddressed(ctx context.Context, settings *types.Settings, endpoint string) error {
hostname, err := types.NormalizeHostname(endpoint)
if err != nil {
return status.Errorf(status.InvalidArgument, "invalid endpoint: %s", err)
}
settings.Domain = hostname
settings.ProxyAddress = hostname
if err := m.store.CreateAgentNetworkSettings(ctx, settings); err != nil {
if isUniqueConstraintError(err) {
// The violation is either the account primary key (a concurrent
// bootstrap for the same account won) or the domain index
// (another account holds the hostname). Distinguish by re-read.
if _, getErr := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, settings.AccountID); getErr == nil {
return status.Errorf(status.AlreadyExists, "agent network settings already bootstrapped for account %s", settings.AccountID)
}
return status.Errorf(status.AlreadyExists, "endpoint %s is already taken", hostname)
}
return fmt.Errorf("create agent network settings: %w", err)
}
return nil
}
// bootstrapLabeled allocates a labeled endpoint one label beneath the given
// cluster address: Domain = <label>.<proxyAddress>, served by whichever proxy
// declares the parent. Labels are adjective-noun tuples; a candidate is
// checked by read and the domain unique index stays the authority, so a
// concurrent allocation of the same tuple surfaces as a unique violation and
// another tuple is drawn.
func (m *managerImpl) bootstrapLabeled(ctx context.Context, settings *types.Settings, proxyAddress string) error {
parent, err := types.NormalizeHostname(proxyAddress)
if err != nil {
return status.Errorf(status.InvalidArgument, "invalid proxy_address: %s", err)
}
for attempt := 1; attempt <= maxDomainAllocationAttempts; attempt++ {
m.labelRngMu.Lock()
label := labelgen.PickTuple(m.labelRng)
m.labelRngMu.Unlock()
if label == "" {
// Only reachable if either word pool were emptied. An empty label
// would produce a broken endpoint like ".example.com", so fail
// loudly rather than looping or inserting.
return fmt.Errorf("allocate agent network endpoint for account %s: label generator returned an empty label", settings.AccountID)
}
candidate, err := types.NormalizeHostname(label + "." + parent)
if err != nil {
return status.Errorf(status.InvalidArgument, "proxy_address leaves no room for a label: %s", err)
}
_, err = m.store.GetAgentNetworkSettingsByDomain(ctx, store.LockingStrengthNone, candidate)
if err == nil {
log.WithContext(ctx).Tracef("agent-network endpoint %q taken, retrying (attempt %d/%d)", candidate, attempt, maxDomainAllocationAttempts)
continue
}
if !isNotFound(err) {
return fmt.Errorf("check agent network endpoint availability: %w", err)
}
settings.Domain = candidate
settings.ProxyAddress = parent
if err := m.store.CreateAgentNetworkSettings(ctx, settings); err != nil {
if isUniqueConstraintError(err) {
// A concurrent bootstrap for the same account may have won on
// the primary key — return the conflict. A lost race on the
// domain index just means the tuple was taken between the
// read and the insert: draw another.
if _, getErr := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, settings.AccountID); getErr == nil {
return status.Errorf(status.AlreadyExists, "agent network settings already bootstrapped for account %s", settings.AccountID)
}
log.WithContext(ctx).Tracef("agent-network endpoint %q lost an allocation race, retrying (attempt %d/%d)", candidate, attempt, maxDomainAllocationAttempts)
continue
}
return fmt.Errorf("create agent network settings: %w", err)
}
return nil
}
return fmt.Errorf("allocate agent network endpoint for account %s: %d attempts exhausted", settings.AccountID, maxDomainAllocationAttempts)
}
// isUniqueConstraintError reports whether err is a database unique-constraint
// violation, matched on the driver message because CreateAgentNetworkSettings
// deliberately returns the driver error unwrapped.
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres
strings.Contains(msg, "Error 1062 (23000)") || // mysql
strings.Contains(msg, "UNIQUE constraint failed") // sqlite
}
// ListConsumption returns every consumption row recorded for the // ListConsumption returns every consumption row recorded for the
// account, ordered window-newest-first. Backs the dashboard's basic // account, ordered window-newest-first. Backs the dashboard's basic
// counter view; permission gate is the same Read role that gates // counter view; permission gate is the same Read role that gates
@@ -1021,7 +879,7 @@ func (*mockManager) GetProvider(_ context.Context, _, _, _ string) (*types.Provi
return &types.Provider{}, nil return &types.Provider{}, nil
} }
func (*mockManager) CreateProvider(_ context.Context, _ string, p *types.Provider) (*types.Provider, error) { func (*mockManager) CreateProvider(_ context.Context, _ string, p *types.Provider, _ string) (*types.Provider, error) {
return p, nil return p, nil
} }
@@ -1089,23 +947,10 @@ func (*mockManager) GetSettings(_ context.Context, accountID, _ string) (*types.
return types.DefaultSettings(accountID), nil return types.DefaultSettings(accountID), nil
} }
func (*mockManager) CreateSettings(_ context.Context, _ string, s *types.Settings, proxyAddress, endpoint string) (*types.Settings, error) {
if endpoint != "" {
s.Domain = endpoint
s.ProxyAddress = endpoint
} else {
s.Domain = "mock." + proxyAddress
s.ProxyAddress = proxyAddress
}
return s, nil
}
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) { func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {
return s, nil return s, nil
} }
func (*mockManager) DeleteSettings(_ context.Context, _, _ string) error { return nil }
func (*mockManager) ListConsumption(_ context.Context, _, _ string) ([]*types.Consumption, error) { func (*mockManager) ListConsumption(_ context.Context, _, _ string) ([]*types.Consumption, error) {
return nil, nil return nil, nil
} }

Some files were not shown because too many files have changed in this diff Show More