mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-12 18:51:28 +02:00
Compare commits
5 Commits
android-ai
...
ssh-settin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee83a795e | ||
|
|
e403567b88 | ||
|
|
1ea9405557 | ||
|
|
c4d01f0c8e | ||
|
|
6495ad8687 |
133
.github/workflows/release.yml
vendored
133
.github/workflows/release.yml
vendored
@@ -475,132 +475,6 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 3
|
||||
|
||||
release_ui_gtk3:
|
||||
# Legacy GTK3/WebKit2GTK 4.1 UI build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). Runs on ubuntu-22.04 so
|
||||
# the binary links against the oldest supported glibc.
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release_ui_gtk3_artifact_url: ${{ steps.upload_release_ui_gtk3.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||
persist-credentials: false
|
||||
|
||||
- name: Parse semver string
|
||||
id: semver_parser
|
||||
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
|
||||
- name: Set snapshot flag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
|
||||
- name: Set build vars
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
- name: Cache Go modules
|
||||
# Restore-only from the release_ui cache written by trusted runs; the
|
||||
# module cache is identical (same go.sum) and stale build-cache
|
||||
# entries just miss.
|
||||
uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: ${{ runner.os }}-ui-go-releaser-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ui-go-releaser-
|
||||
|
||||
- name: Install modules
|
||||
run: go mod tidy
|
||||
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- name: Decode GPG signing key
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
env:
|
||||
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
|
||||
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||
|
||||
- name: Install wails3 CLI
|
||||
# Version derived from go.mod so the binding generator always matches
|
||||
# the wails runtime the binary links against.
|
||||
# -tags gtk3: the CLI links the wails runtime's cgo packages, and the
|
||||
# default tags request gtk4/webkitgtk-6.0 pkg-config entries that do
|
||||
# not exist on ubuntu-22.04.
|
||||
run: |
|
||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||
go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||
with:
|
||||
version: ${{ env.GORELEASER_VER }}
|
||||
args: release --config .goreleaser_ui_gtk3.yaml --clean ${{ env.flags }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||
NFPM_NETBIRD_UI_RPM_GTK3_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||
- name: Verify RPM signatures
|
||||
run: |
|
||||
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||
dnf install -y -q rpm-sign curl >/dev/null 2>&1
|
||||
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
|
||||
rpm --import /tmp/rpm-pub.key
|
||||
echo "=== Verifying RPM signatures ==="
|
||||
for rpm_file in /dist/*.rpm; do
|
||||
[ -f "$rpm_file" ] || continue
|
||||
echo "--- $(basename $rpm_file) ---"
|
||||
rpm -K "$rpm_file"
|
||||
done
|
||||
'
|
||||
- name: Clean up GPG key
|
||||
if: always()
|
||||
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||
- name: upload non tags for debug purposes
|
||||
id: upload_release_ui_gtk3
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
name: release-ui-gtk3
|
||||
path: dist/
|
||||
retention-days: 3
|
||||
|
||||
release_ui_darwin:
|
||||
runs-on: macos-latest
|
||||
outputs:
|
||||
@@ -814,7 +688,7 @@ jobs:
|
||||
comment_release_artifacts:
|
||||
name: Comment release artifacts
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin]
|
||||
needs: [release, release_ui, release_ui_darwin]
|
||||
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -826,14 +700,12 @@ jobs:
|
||||
env:
|
||||
RELEASE_RESULT: ${{ needs.release.result }}
|
||||
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
||||
RELEASE_UI_GTK3_RESULT: ${{ needs.release_ui_gtk3.result }}
|
||||
RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }}
|
||||
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
|
||||
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
|
||||
WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }}
|
||||
MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }}
|
||||
RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }}
|
||||
RELEASE_UI_GTK3_ARTIFACT_URL: ${{ needs.release_ui_gtk3.outputs.release_ui_gtk3_artifact_url }}
|
||||
RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }}
|
||||
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
|
||||
with:
|
||||
@@ -856,7 +728,6 @@ jobs:
|
||||
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||
['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||
['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT],
|
||||
['UI GTK3 artifacts', process.env.RELEASE_UI_GTK3_ARTIFACT_URL, process.env.RELEASE_UI_GTK3_RESULT],
|
||||
['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT],
|
||||
];
|
||||
|
||||
@@ -913,7 +784,7 @@ jobs:
|
||||
|
||||
trigger_signer:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin, test_windows_installer]
|
||||
needs: [release, release_ui, release_ui_darwin, test_windows_installer]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Trigger binaries sign pipelines
|
||||
|
||||
@@ -257,15 +257,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify fresh-install session cookie key hardening
|
||||
run: |
|
||||
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' install -m 600 /dev/null config.yaml' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' openssl rand -base64 32' infrastructure_files/getting-started-enterprise.sh
|
||||
grep -Fxq ' NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)' infrastructure_files/getting-started-enterprise.sh
|
||||
grep -Fxq ' sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"' infrastructure_files/getting-started-enterprise.sh
|
||||
|
||||
- name: Verify Dex retirement notice
|
||||
run: |
|
||||
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
|
||||
|
||||
@@ -92,11 +92,15 @@ nfpms:
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
# Names the polkit action for the elevation prompt the app raises when an
|
||||
# unprivileged user changes a privileged setting; without it the dialog
|
||||
# shows a raw command line.
|
||||
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
|
||||
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
|
||||
dependencies:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
- libwebkitgtk-6.0-4
|
||||
- xdg-utils
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
@@ -116,11 +120,15 @@ nfpms:
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
# Names the polkit action for the elevation prompt the app raises when an
|
||||
# unprivileged user changes a privileged setting; without it the dialog
|
||||
# shows a raw command line.
|
||||
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
|
||||
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
|
||||
dependencies:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||
- xdg-utils
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
|
||||
@@ -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
317
AGENTS.md
@@ -14,22 +14,20 @@ in this file, not duplicated there.
|
||||
|
||||
## Contents
|
||||
|
||||
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
||||
- [Quick reference](#quick-reference)
|
||||
- [Structure](#structure)
|
||||
- [Where to look](#where-to-look)
|
||||
- [Security](#security)
|
||||
- [Agent conventions](#agent-conventions)
|
||||
- [Repo-wide principles](#repo-wide-principles)
|
||||
- [Type safety](#type-safety)
|
||||
- [Concurrency and lifecycle](#concurrency-and-lifecycle)
|
||||
- [Error handling](#error-handling)
|
||||
- [Comments](#comments)
|
||||
- [Testing](#testing)
|
||||
- [Pitfalls](#pitfalls)
|
||||
- [Commits, PRs, releases](#commits-prs-releases)
|
||||
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
|
||||
- [Discussion and support](#discussion-and-support)
|
||||
- [NetBird Agent Guidelines](#netbird-agent-guidelines)
|
||||
- [Contents](#contents)
|
||||
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
||||
- [Quick reference](#quick-reference)
|
||||
- [Structure](#structure)
|
||||
- [Where to look](#where-to-look)
|
||||
- [Repo-wide principles](#repo-wide-principles)
|
||||
- [Error handling](#error-handling)
|
||||
- [Comments](#comments)
|
||||
- [Testing](#testing)
|
||||
- [Pitfalls](#pitfalls)
|
||||
- [Commits, PRs, releases](#commits-prs-releases)
|
||||
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
|
||||
- [Discussion and support](#discussion-and-support)
|
||||
|
||||
## STOP and ask the user before
|
||||
|
||||
@@ -159,125 +157,11 @@ netbird/
|
||||
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
|
||||
| End-to-end tests | `e2e/` |
|
||||
|
||||
## Security
|
||||
|
||||
### Never fail open
|
||||
|
||||
When a security check — access control, an IP restriction, an auth decision —
|
||||
hits an error such as an unparseable value, an unavailable lookup, or a state it
|
||||
does not recognize, it must **deny**. Never skip the check or allow the request
|
||||
through because the check itself failed, and make the `default` and unknown cases
|
||||
of a security-related `switch` deny rather than fall through.
|
||||
|
||||
### Daemon RPC input is untrusted
|
||||
|
||||
The agent runs as root (LocalSystem on Windows), so a daemon RPC crosses a
|
||||
privilege boundary: treat every field as untrusted input rather than as something
|
||||
the UI or CLI validated on the way in.
|
||||
|
||||
When you add or change an RPC, ask what the handler does with caller input while
|
||||
running as root. If the answer touches a filesystem path, a URL or host, or a
|
||||
privileged state change, it needs a gate **in the handler** — a check in the client
|
||||
that normally calls it is not a check at all.
|
||||
|
||||
- **A caller-supplied path the daemon opens.** Never `os.Open` it as root.
|
||||
Constrain it, then open it *as the caller* with `ipcauth.OpenOwnedFile`, which
|
||||
opens `O_NOFOLLOW`, requires a regular file, and refuses a file the caller does
|
||||
not own — so a symlink or hardlink aimed at a root-only file is rejected.
|
||||
- **A caller-supplied URL or host the daemon fetches.** Restrict the scheme and
|
||||
allow only known hosts for unprivileged callers. Prefer a lexical host
|
||||
allowlist plus TLS verification over "resolve the host, then reject private
|
||||
IPs": the resolve-then-trust pattern has a DNS-rebinding race (public IP at
|
||||
check time, attacker IP at connect time), while a name allowlist has no IP
|
||||
check to race. Never accept `http://` where `https://` is expected.
|
||||
- **A privileged state change** (SSH root login, management URL, deregistration)
|
||||
gates on the caller identity from `ipcauth.CallerIdentity(ctx)`.
|
||||
|
||||
Caller identity comes from the kernel — `SO_PEERCRED`, `LOCAL_PEERCRED`, or the
|
||||
named-pipe client token — and never from an RPC field. When
|
||||
`ipcauth.CallerIdentity` reports that it could not determine an identity, **deny**;
|
||||
do not fall back to treating the caller as the transport peer.
|
||||
|
||||
## Agent conventions
|
||||
|
||||
### Three networking modes
|
||||
|
||||
Where packets actually flow depends on the mode the agent is running in. The
|
||||
three are not interchangeable, so establish which one a change applies to — and
|
||||
what it should do in the other two — before you write it.
|
||||
|
||||
- **kernel mode** (Linux only): in-kernel WireGuard®. The kernel handles both
|
||||
peer-to-peer and routed traffic, and ACLs are iptables or nftables rules. The
|
||||
client programs kernel facilities but never sees the traffic itself.
|
||||
- **userspace mode** (wireguard-go with a TUN): wireguard-go runs in-process. The
|
||||
kernel handles peer-to-peer traffic once it leaves the TUN, while routed traffic
|
||||
— exit nodes and network routes — goes through the userspace forwarder, which
|
||||
terminates the connection and re-establishes it over OS sockets. Used on
|
||||
platforms without kernel WireGuard® or when the user opts out.
|
||||
- **netstack mode**: wireguard-go in-process with no TUN and no kernel
|
||||
networking. The forwarder does all routing by stitching userspace sockets, and
|
||||
listeners such as the embedded SSH and DNS servers bind on a gVisor netstack.
|
||||
Used where the process cannot create a TUN device, such as the embedded client
|
||||
(`client/embed/`) and the WASM build.
|
||||
|
||||
### The overlay interface is not "WireGuard"
|
||||
|
||||
Do not put "WireGuard" in identifiers or comments unless the code is genuinely
|
||||
coupled to WireGuard® specifically — a wireguard-go call, a handshake field, a
|
||||
kernel WireGuard® netlink attribute. For the interface, the host, peers, or
|
||||
traffic in general, say "the NetBird interface", "the interface", or "the overlay".
|
||||
Most firewall, routing, and DNS code is transport-agnostic, so a WireGuard®
|
||||
reference there is simply inaccurate and rots as the transports change.
|
||||
|
||||
### IPv6 is a soft feature
|
||||
|
||||
The IPv6 overlay is opt-in dual-stack, and capability can change at runtime. Treat
|
||||
it as soft rather than a requirement:
|
||||
|
||||
- Gate local v6 paths on the interface accessor (`wgIface.Address().HasIPv6()`),
|
||||
not on raw state fields, and skip the v6 path when the host has no v6 rather
|
||||
than returning an error.
|
||||
- Treat an empty or unparseable peer v6 address as "no v6 for that peer" and skip
|
||||
it, keeping the v4 path working.
|
||||
- Never let a missing v6 break v4. Fail-closed is for security checks; a
|
||||
capability mismatch skips the v6 work and carries on.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Name the variable in a constant and parse booleans with `strconv.ParseBool` rather
|
||||
than comparing strings inline, so an unexpected value is logged instead of
|
||||
silently meaning false:
|
||||
|
||||
```go
|
||||
const EnvDisableFeature = "NB_DISABLE_FEATURE"
|
||||
|
||||
func isDisabledByEnv() bool {
|
||||
val := os.Getenv(EnvDisableFeature)
|
||||
if val == "" {
|
||||
return false
|
||||
}
|
||||
disabled, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s: %v", EnvDisableFeature, err)
|
||||
return false
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
```
|
||||
|
||||
### Validating against protocol specs
|
||||
|
||||
When a change depends on what a protocol actually mandates, read the specification
|
||||
text from the [IETF datatracker](https://datatracker.ietf.org/) rather than a
|
||||
summary, and check that you have the current RFC — the widely cited one for a
|
||||
protocol is often superseded. Cite the section, not just the document, so a
|
||||
reviewer can jump straight to the rule.
|
||||
|
||||
## Repo-wide principles
|
||||
|
||||
1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
|
||||
2. **Zero unaddressed linter warnings.** Fix what `golangci-lint` reports on code
|
||||
you touch, and delete imports, helpers, and parameters your refactor orphaned.
|
||||
2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings on code you
|
||||
touch, and delete imports, helpers, and parameters your refactor orphaned.
|
||||
Exception: unused parameters in shared code may be consumed by builds outside
|
||||
this repository — do not remove them, ask instead.
|
||||
3. **Function comments are mandatory for exported functions**, written as full
|
||||
@@ -291,12 +175,9 @@ reviewer can jump straight to the rule.
|
||||
7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
|
||||
prose, trailing summaries. Defaults, not absolute bans. Applies to code,
|
||||
comments, commit messages, and PR descriptions alike.
|
||||
8. **Concurrency: do a two-pass race analysis after every change** that touches
|
||||
shared state, including reads of existing maps and slices. Guard them with a
|
||||
mutex (or an atomic or channel where that fits better), keep critical
|
||||
sections short, and run `go test -race` on the touched packages. See
|
||||
[Concurrency and lifecycle](#concurrency-and-lifecycle) for the failure modes
|
||||
to check for.
|
||||
8. **Concurrency: do a two-pass race analysis after every change** that adds
|
||||
shared state. Guard maps and slices with a mutex, keep critical sections
|
||||
short, and run `go test -race` on the touched packages.
|
||||
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
|
||||
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
|
||||
add the counterpart or a build-tagged fallback for the others.
|
||||
@@ -304,93 +185,6 @@ reviewer can jump straight to the rule.
|
||||
11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
|
||||
keep peer IPs and hostnames out of logs above debug level.
|
||||
|
||||
## Type safety
|
||||
|
||||
**No bare primitives for domain concepts.** A `string` parameter for an account
|
||||
ID next to a `string` parameter for a peer ID is two bugs waiting to happen,
|
||||
because the compiler cannot catch the swap. Declare the type once and use it
|
||||
throughout, converting only at the boundaries where data enters or leaves —
|
||||
protobuf, gRPC, HTTP, an external library.
|
||||
|
||||
```go
|
||||
type ServiceID string
|
||||
type AccountID string
|
||||
|
||||
// Internal: typed all the way through
|
||||
func (r *Router) RemoveRoute(host SNIHost, svcID ServiceID) { ... }
|
||||
|
||||
// Proto boundary: convert once, on the way in and on the way out
|
||||
svcID := ServiceID(mapping.GetId())
|
||||
req.ServiceId = string(svcID)
|
||||
```
|
||||
|
||||
- **IP addresses are `netip.Addr`**, not `string` and not `net.IP`. Parse at the
|
||||
boundary and pass the typed value inward.
|
||||
- **Always `Unmap()`** after parsing an address, after converting from `net.IP`,
|
||||
and after extracting one from `RemoteAddr()`. This normalizes a v4-mapped v6
|
||||
address (`::ffff:10.1.2.3`) to plain v4 so IPv4 rules match it. A stored or
|
||||
compared mapped address silently fails to match those rules.
|
||||
- **Ports are `uint16`** internally; use `int` only where a library forces it and
|
||||
convert immediately.
|
||||
- **Enums are a typed string with constants**, so the valid set is discoverable
|
||||
and a typo fails to compile.
|
||||
- **Map keys follow the same rule**, and must be a real type (`type ServiceID
|
||||
string`) rather than an alias (`type serviceID = string`) — an alias silently
|
||||
accepts bare strings.
|
||||
|
||||
## Concurrency and lifecycle
|
||||
|
||||
Beyond the mutex hygiene in the principles above, check for these failure
|
||||
modes.
|
||||
|
||||
- **Never read a struct field inside a goroutine** when another goroutine may nil
|
||||
or reassign it. Pass the value as a parameter, or capture it into a local before
|
||||
launching. This matters most when `Stop()` nils a field without waiting for the
|
||||
goroutine to finish.
|
||||
|
||||
```go
|
||||
go func(ifaceName string) { // good: passed in, cannot be nilled underneath
|
||||
m.Start(ctx, ifaceName)
|
||||
}(iface.Name())
|
||||
```
|
||||
|
||||
- **Never wait on a channel while holding a lock the sender needs.** Copy what you
|
||||
need out from under the lock, release it, then wait.
|
||||
|
||||
```go
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
cancel, done := m.cancel, m.done
|
||||
m.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`Stop`/`Close` must be idempotent** — guard on an already-stopped flag or a
|
||||
nil cancel — and must release the state they guarded. Clear maps and caches;
|
||||
a cancelled goroutine holding a live map still pins that memory. Note that a
|
||||
nil map only panics on writes; reads and iteration behave like an empty map,
|
||||
so where post-close use must be rejected, check the stopped flag explicitly.
|
||||
- **Publish coupled state only after every fallible step succeeds.** When several
|
||||
fields form an invariant, build them into locals and assign them to the receiver
|
||||
at the end. Assigning as you go leaves the object half-initialized when a later
|
||||
step fails, so a readiness predicate reports ready while a coupled field is nil.
|
||||
If an earlier step already had an external side effect — a created chain, an
|
||||
opened handle, an inserted rule — roll it back before returning the error.
|
||||
- **Clean up what you own on constructor error paths.** Once a constructor has
|
||||
started something, every later error path must undo it: cancel a goroutine and
|
||||
wait for it to exit, stop a ticker, close a watcher. The object is never
|
||||
returned, so its `Close` will never run.
|
||||
- **A failed `Start` must undo everything it started.** When a component brings up
|
||||
several subsystems in sequence — connection manager, watchers, routing, DNS,
|
||||
flow, persisted state — a failure partway through has to tear down the ones
|
||||
already running, not just close the handle the error came from. Put the
|
||||
already-started guard *before* that teardown path, so a rejected second `Start`
|
||||
cannot dismantle the one that is running.
|
||||
|
||||
## Error handling
|
||||
|
||||
Use single-assignment form when the error is only needed inside the `if`:
|
||||
@@ -454,45 +248,6 @@ Log the errors you choose not to act on:
|
||||
- Close errors may be ignored for read-only operations; log them at debug for
|
||||
writes.
|
||||
|
||||
**Do not log and return the same error.** It gets reported twice, from two places,
|
||||
and the second reader cannot tell whether it happened once or twice. Return it and
|
||||
let the caller decide. The exception is an API handler that has already written a
|
||||
response. Internal helpers return errors rather than logging and swallowing them.
|
||||
|
||||
**Never return a typed nil as an error.** A nil `*MyError` stored in an `error`
|
||||
interface is not nil, so `err != nil` is true and callers take the failure path on
|
||||
success. Return the error only where it is actually set:
|
||||
|
||||
```go
|
||||
if _, err := conn.Write(buf); err != nil { // good
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
```
|
||||
|
||||
**Accumulate with `multierror` when an operation should continue past individual
|
||||
failures** — teardown, cleanup, or setup where partial success is acceptable.
|
||||
`client/errors.FormatErrorOrNil` returns nil for an empty accumulator, so callers
|
||||
still see a plain nil on full success:
|
||||
|
||||
```go
|
||||
func (m *Manager) Cleanup() error {
|
||||
var merr *multierror.Error
|
||||
for _, r := range m.resources {
|
||||
if err := r.Close(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("close %s: %w", r.Name, err))
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
```
|
||||
|
||||
| Scenario | Approach | Why |
|
||||
| --------------------- | --------------------- | ----------------------------------------- |
|
||||
| Cleanup / teardown | Accumulate | Clean up as much as possible |
|
||||
| Setup with rollback | Abort on first error | Partial state is invalid; undo what stuck |
|
||||
| Setup with partial OK | Accumulate | Degraded operation is still useful |
|
||||
|
||||
## Comments
|
||||
|
||||
Comment the **why**, never the **what**. Default to no comment, and add one only
|
||||
@@ -514,14 +269,10 @@ checksum = updateChecksum(checksum, oldPort, newPort)
|
||||
|
||||
### Length budget
|
||||
|
||||
Neither of these is linter-enforced, so they are conventions the surrounding code
|
||||
mostly follows rather than hard limits:
|
||||
|
||||
- **Around 90 characters per line.** Wrap the comment rather than running well past
|
||||
it.
|
||||
- **Roughly 250 characters per comment**, about three wrapped lines. Doc comments
|
||||
on exported identifiers may exceed it when the API genuinely needs the
|
||||
explanation; inline comments inside a function body rarely should.
|
||||
- **90 characters per line.** Wrap the comment, do not run past it.
|
||||
- **250 characters per comment**, roughly three wrapped lines. Doc comments on
|
||||
exported identifiers may exceed it when the API genuinely needs the
|
||||
explanation; inline comments inside a function body may not.
|
||||
|
||||
The budget is a smell detector, not a rule to game. Do not compress a needed
|
||||
explanation into cryptic shorthand to fit — if a block of code needs more than
|
||||
@@ -578,19 +329,6 @@ up, and the 250-character budget does not apply to them.
|
||||
otherwise.
|
||||
- **Message guidance:** optional for `NoError`/`Error`; always give context for
|
||||
comparison, boolean, and collection assertions.
|
||||
- **Reproduce a bug before fixing it.** Write the test, watch it fail *for the
|
||||
reason you expect* — a test that fails for an unrelated reason proves nothing —
|
||||
then apply the fix and confirm it passes. Add the thin surrounding cases while
|
||||
you are there.
|
||||
- **Use `t.Setenv`** rather than `os.Setenv` so the previous value is restored on
|
||||
cleanup. To test the unset case, call `t.Setenv` first to register the restore,
|
||||
then `os.Unsetenv`.
|
||||
- **Prefer `t.Cleanup` over `defer`** in any test with parallel subtests: the
|
||||
parent function returns, running its `defer`s, while parallel subtests are
|
||||
still suspended. Sequential subtests finish inside `t.Run`, so `defer` is safe
|
||||
there, but `t.Cleanup` works in both cases.
|
||||
- **Explanatory comments in tests are welcome.** Describe the scenario being set
|
||||
up; the comment budget below does not apply to them.
|
||||
|
||||
```go
|
||||
server, err := StartTestServer()
|
||||
@@ -642,8 +380,7 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
|
||||
than replacing it with your own summary: describe the change, link the issue,
|
||||
tick the checklist honestly (including "ran locally" and "single purpose"),
|
||||
and complete the documentation section. Do not tick a box you have not
|
||||
verified, and do not delete rows that do not apply — the docs gate in CI reads
|
||||
that section and fails when it is missing.
|
||||
verified, and do not delete rows that do not apply.
|
||||
|
||||
- **Keep the PR description short.** Under 1000 words on top of the template's
|
||||
own text, and usually far less — a few paragraphs. Reviewers read the diff;
|
||||
@@ -702,12 +439,6 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
|
||||
on their own. Propose that split to the user rather than opening one large PR
|
||||
and hoping.
|
||||
|
||||
Prefer GitHub's stacked pull requests for such a sequence, rather than
|
||||
hand-managing base branches: open each PR against the branch below it instead of
|
||||
`main`, so every PR's diff shows only its own change. Merging a layer retargets
|
||||
the PRs above it, and branch protections and required checks on the base branch
|
||||
still apply to each one.
|
||||
|
||||
- **User-facing changes need a docs PR** in
|
||||
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
|
||||
description.
|
||||
|
||||
@@ -112,7 +112,6 @@ aligns with our security standards and design expectations.
|
||||
- [Test suite](#test-suite)
|
||||
- [Checklist before submitting a PR](#checklist-before-submitting-a-pr)
|
||||
- [When we close a PR](#when-we-close-a-pr)
|
||||
- [Translations](#translations)
|
||||
- [Other project repositories](#other-project-repositories)
|
||||
- [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
|
||||
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
|
||||
|
||||
NetBird project is composed of 3 main repositories:
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
@@ -26,8 +25,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -35,12 +32,10 @@ import (
|
||||
types "github.com/netbirdio/netbird/upload-server/types"
|
||||
)
|
||||
|
||||
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
|
||||
// anonymizeLevel values for DebugBundle.
|
||||
const (
|
||||
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
}
|
||||
|
||||
// TunAdapter export internal TunAdapter for mobile
|
||||
type TunAdapter interface {
|
||||
@@ -82,13 +77,6 @@ type Client struct {
|
||||
deviceName string
|
||||
uiVersion string
|
||||
networkChangeListener listener.NetworkChangeListener
|
||||
// netState outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run and RunWithoutLogin inject it into each new
|
||||
// ConnectClient, which distributes it to every reconnection loop.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
|
||||
sweeper *netsweep.Sweeper
|
||||
|
||||
stateMu sync.RWMutex
|
||||
connectClient *internal.ConnectClient
|
||||
@@ -160,8 +148,6 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
|
||||
recorder: peer.NewRecorder(""),
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
netState: netstate.New(),
|
||||
sweeper: netsweep.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,8 +188,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
}
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
@@ -244,8 +229,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
@@ -293,28 +277,9 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetNetworkAvailable feeds OS-reported network availability into the client.
|
||||
// While unavailable, the internal reconnect loops suspend their attempts and
|
||||
// the connection listener reports NoNetwork instead of Connecting; when
|
||||
// availability returns, the loops resume immediately with a fresh backoff.
|
||||
func (c *Client) SetNetworkAvailable(available bool) {
|
||||
c.netState.Set(available)
|
||||
c.recorder.SetNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// NotifyNetworkChange cuts the management, signal and relay connections
|
||||
// after the OS switched networks, so the reconnect loops redial immediately
|
||||
// on the new one. The engine and the TUN device stay untouched.
|
||||
func (c *Client) NotifyNetworkChange() {
|
||||
n := c.sweeper.Sweep()
|
||||
log.Infof("network change: swept %d connections", n)
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||
// It works both with and without a running engine. anonymizeLevel is "default"
|
||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys, and implies anonymize.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) {
|
||||
// It works both with and without a running engine.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||
cfg, cacheDir, cc := c.stateSnapshot()
|
||||
|
||||
// If the engine hasn't been started, load config from disk
|
||||
@@ -333,7 +298,6 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
|
||||
InternalConfig: cfg,
|
||||
StatusRecorder: c.recorder,
|
||||
TempDir: cacheDir,
|
||||
StatePath: platformFiles.StateFilePath(),
|
||||
}
|
||||
|
||||
if cc != nil {
|
||||
@@ -357,7 +321,6 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym
|
||||
deps,
|
||||
debug.BundleConfig{
|
||||
Anonymize: anonymize,
|
||||
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
},
|
||||
)
|
||||
@@ -550,11 +513,7 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
|
||||
|
||||
// SetConnectionListener set the network connection listener
|
||||
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
||||
if listener == nil {
|
||||
c.recorder.RemoveConnectionListener()
|
||||
return
|
||||
}
|
||||
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
|
||||
c.recorder.SetConnectionListener(listener)
|
||||
}
|
||||
|
||||
// RemoveConnectionListener remove connection listener
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// Client state values delivered via ConnectionListener.OnStateChanged,
|
||||
// re-exported as basic constants so gomobile emits them into the generated
|
||||
// Java bindings. They mirror peer.ClientState*: append-only, never reorder.
|
||||
const (
|
||||
ClientStateDisconnected = int(peer.ClientStateDisconnected)
|
||||
ClientStateConnected = int(peer.ClientStateConnected)
|
||||
ClientStateConnecting = int(peer.ClientStateConnecting)
|
||||
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
|
||||
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile. It mirrors
|
||||
// peer.Listener with OnStateChanged taking a plain int (one of the
|
||||
// ClientState* constants), because gomobile cannot bind named types.
|
||||
type ConnectionListener interface {
|
||||
OnStateChanged(state int)
|
||||
OnConnected()
|
||||
OnDisconnected()
|
||||
OnConnecting()
|
||||
OnDisconnecting()
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
|
||||
// peer.Listener, converting the typed state to the int the binding carries.
|
||||
type connectionListenerAdapter struct {
|
||||
ConnectionListener
|
||||
}
|
||||
|
||||
func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) {
|
||||
a.ConnectionListener.OnStateChanged(int(state))
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package anonymize
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
@@ -16,88 +15,13 @@ import (
|
||||
|
||||
const anonTLD = ".domain"
|
||||
|
||||
// Level selects how much the anonymizer redacts. Levels are ordered: a higher
|
||||
// level redacts strictly more. On the wire (protos, flags) levels travel as
|
||||
// their string form.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC
|
||||
// addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are
|
||||
// preserved so support can reason about the real topology.
|
||||
LevelDefault Level = iota
|
||||
// LevelStrict additionally anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys.
|
||||
LevelStrict
|
||||
)
|
||||
|
||||
// LevelDefaultString and LevelStrictString are the wire forms of the levels,
|
||||
// for boundaries that pass levels as strings (flags, protos, mobile bindings).
|
||||
const (
|
||||
LevelDefaultString = "default"
|
||||
LevelStrictString = "strict"
|
||||
)
|
||||
|
||||
// ParseLevel maps s to a Level. Empty means LevelDefault; anything
|
||||
// unrecognized maps to LevelStrict so an unknown request never yields less
|
||||
// anonymization than intended.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(s) {
|
||||
case "", LevelDefaultString:
|
||||
return LevelDefault
|
||||
default:
|
||||
return LevelStrict
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the wire form of the level: "default" or "strict".
|
||||
func (l Level) String() string {
|
||||
if l >= LevelStrict {
|
||||
return LevelStrictString
|
||||
}
|
||||
return LevelDefaultString
|
||||
}
|
||||
|
||||
// protectedDomains are NetBird-operated suffixes that stay recognizable in an
|
||||
// anonymized bundle. At LevelStrict the labels in front of them (the peer
|
||||
// name) are still replaced, except under netbird.io, which only hosts
|
||||
// NetBird infrastructure (api, signal, flow), never peer names.
|
||||
var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"}
|
||||
|
||||
const infraDomain = "netbird.io"
|
||||
|
||||
var (
|
||||
macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`)
|
||||
macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`)
|
||||
wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`)
|
||||
)
|
||||
|
||||
type Anonymizer struct {
|
||||
ipAnonymizer map[netip.Addr]netip.Addr
|
||||
domainAnonymizer map[string]string
|
||||
// domainOrder caches the keys of domainAnonymizer sorted longest-first
|
||||
// for AnonymizeString; it is rebuilt when the map gains entries.
|
||||
domainOrder []string
|
||||
labelAnonymizer map[string]string
|
||||
labelAnonymized map[string]struct{}
|
||||
labelCounter uint32
|
||||
macAnonymizer map[string]string
|
||||
macCounter uint32
|
||||
wgKeyAnonymizer map[string]string
|
||||
wgKeyAnonymized map[string]struct{}
|
||||
currentAnonIPv4 netip.Addr
|
||||
currentAnonIPv6 netip.Addr
|
||||
startAnonIPv4 netip.Addr
|
||||
startAnonIPv6 netip.Addr
|
||||
|
||||
// LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT,
|
||||
// link-local), replacing them from the dedicated internal pools below so
|
||||
// a reader can still tell an internal address from a public one.
|
||||
level Level
|
||||
currentAnonInternalIPv4 netip.Addr
|
||||
currentAnonInternalIPv6 netip.Addr
|
||||
startAnonInternalIPv4 netip.Addr
|
||||
startAnonInternalIPv6 netip.Addr
|
||||
currentAnonIPv4 netip.Addr
|
||||
currentAnonIPv6 netip.Addr
|
||||
startAnonIPv4 netip.Addr
|
||||
startAnonIPv6 netip.Addr
|
||||
|
||||
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::")
|
||||
}
|
||||
|
||||
// InternalAddresses returns the pool starts used in strict mode for internal
|
||||
// ranges. Both are reserved ranges that cannot collide with real addressing:
|
||||
// 198.18.0.0 (RFC 2544 benchmarking), 2001:db8:1:: (RFC 3849 documentation).
|
||||
func InternalAddresses() (netip.Addr, netip.Addr) {
|
||||
return netip.AddrFrom4([4]byte{198, 18, 0, 0}), netip.MustParseAddr("2001:db8:1::")
|
||||
}
|
||||
|
||||
func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
|
||||
internalIPv4, internalIPv6 := InternalAddresses()
|
||||
return &Anonymizer{
|
||||
ipAnonymizer: map[netip.Addr]netip.Addr{},
|
||||
domainAnonymizer: map[string]string{},
|
||||
labelAnonymizer: map[string]string{},
|
||||
labelAnonymized: map[string]struct{}{},
|
||||
macAnonymizer: map[string]string{},
|
||||
wgKeyAnonymizer: map[string]string{},
|
||||
wgKeyAnonymized: map[string]struct{}{},
|
||||
currentAnonIPv4: startIPv4,
|
||||
currentAnonIPv6: startIPv6,
|
||||
startAnonIPv4: startIPv4,
|
||||
startAnonIPv6: startIPv6,
|
||||
|
||||
level: LevelDefault,
|
||||
currentAnonInternalIPv4: internalIPv4,
|
||||
currentAnonInternalIPv6: internalIPv6,
|
||||
startAnonInternalIPv4: internalIPv4,
|
||||
startAnonInternalIPv6: internalIPv6,
|
||||
|
||||
domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`),
|
||||
}
|
||||
}
|
||||
|
||||
// SetLevel selects the anonymization level. The zero value of a new
|
||||
// Anonymizer is LevelDefault.
|
||||
func (a *Anonymizer) SetLevel(level Level) {
|
||||
a.level = level
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
|
||||
// Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps
|
||||
// like 192.168.1.1.
|
||||
ip = ip.Unmap()
|
||||
|
||||
if ip.IsLoopback() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
(ip.Is4() && ip.IsPrivate()) ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsMulticast() ||
|
||||
isWellKnown(ip) ||
|
||||
@@ -160,100 +59,18 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
|
||||
return ip
|
||||
}
|
||||
|
||||
if isInternal(ip) && a.level < LevelStrict {
|
||||
return ip
|
||||
}
|
||||
|
||||
if _, ok := a.ipAnonymizer[ip]; !ok {
|
||||
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]
|
||||
}
|
||||
|
||||
func (a *Anonymizer) nextAnonIP(ip netip.Addr) netip.Addr {
|
||||
// At the strict level, internal addresses (including IPv6 ULA, matched
|
||||
// by IsPrivate) come from the internal pools so they remain recognizable
|
||||
// as internal without disclosing the real values.
|
||||
if a.level >= LevelStrict && (isInternal(ip) || ip.IsPrivate()) {
|
||||
if ip.Is4() {
|
||||
anon := a.currentAnonInternalIPv4
|
||||
a.currentAnonInternalIPv4 = a.currentAnonInternalIPv4.Next()
|
||||
return anon
|
||||
}
|
||||
anon := a.currentAnonInternalIPv6
|
||||
a.currentAnonInternalIPv6 = a.currentAnonInternalIPv6.Next()
|
||||
return anon
|
||||
}
|
||||
|
||||
if ip.Is4() {
|
||||
anon := a.currentAnonIPv4
|
||||
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
|
||||
return anon
|
||||
}
|
||||
anon := a.currentAnonIPv6
|
||||
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
|
||||
return anon
|
||||
}
|
||||
|
||||
// AnonymizeMAC replaces a MAC address with a consistent placeholder from the
|
||||
// locally administered range starting at 02:00:00:00:00:01, at every
|
||||
// anonymization level. Broadcast, multicast, all-zero, and already assigned
|
||||
// placeholder addresses are preserved. The colon and dash spellings of the
|
||||
// same address share one placeholder; the output keeps the input's separator.
|
||||
func (a *Anonymizer) AnonymizeMAC(mac string) string {
|
||||
hw, err := net.ParseMAC(mac)
|
||||
if err != nil || len(hw) != 6 {
|
||||
return mac
|
||||
}
|
||||
|
||||
if isWellKnownMAC(hw) || a.isAnonymizedMAC(hw) {
|
||||
return mac
|
||||
}
|
||||
|
||||
key := hw.String()
|
||||
anon, ok := a.macAnonymizer[key]
|
||||
if !ok {
|
||||
a.macCounter++
|
||||
anon = fmt.Sprintf("02:00:00:%02x:%02x:%02x", byte(a.macCounter>>16), byte(a.macCounter>>8), byte(a.macCounter))
|
||||
a.macAnonymizer[key] = anon
|
||||
}
|
||||
|
||||
if strings.Contains(mac, "-") {
|
||||
anon = strings.ReplaceAll(anon, ":", "-")
|
||||
}
|
||||
return anon
|
||||
}
|
||||
|
||||
// isAnonymizedMAC reports whether hw is a placeholder this anonymizer already
|
||||
// handed out, so a second pass over anonymized output leaves it unchanged.
|
||||
func (a *Anonymizer) isAnonymizedMAC(hw net.HardwareAddr) bool {
|
||||
if hw[0] != 0x02 || hw[1] != 0 || hw[2] != 0 {
|
||||
return false
|
||||
}
|
||||
value := uint32(hw[3])<<16 | uint32(hw[4])<<8 | uint32(hw[5])
|
||||
return value <= a.macCounter
|
||||
}
|
||||
|
||||
// AnonymizeWGKey replaces a WireGuard public key with a consistent random
|
||||
// placeholder of the same shape. Keys are only anonymized at LevelStrict;
|
||||
// placeholders already handed out pass through unchanged.
|
||||
func (a *Anonymizer) AnonymizeWGKey(key string) string {
|
||||
if a.level < LevelStrict || !looksLikeWGKey(key) {
|
||||
return key
|
||||
}
|
||||
if _, ok := a.wgKeyAnonymized[key]; ok {
|
||||
return key
|
||||
}
|
||||
|
||||
anon, ok := a.wgKeyAnonymizer[key]
|
||||
if !ok {
|
||||
anon = generateAnonymousKey()
|
||||
a.wgKeyAnonymizer[key] = anon
|
||||
a.wgKeyAnonymized[anon] = struct{}{}
|
||||
}
|
||||
return anon
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
|
||||
// Convert IP to netip.Addr
|
||||
ip, ok := netip.AddrFromSlice(addr.IP)
|
||||
@@ -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
|
||||
func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
|
||||
if ip.Is4() {
|
||||
return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) ||
|
||||
inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4)
|
||||
if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 {
|
||||
return true
|
||||
} else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 {
|
||||
return true
|
||||
}
|
||||
return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) ||
|
||||
inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6)
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeIPString(ip string) string {
|
||||
@@ -301,17 +118,14 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
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
|
||||
}
|
||||
|
||||
if suffix := protectedSuffix(baseDomain); suffix != "" {
|
||||
if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
|
||||
return domain
|
||||
}
|
||||
return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot)
|
||||
}
|
||||
|
||||
parts := strings.Split(baseDomain, ".")
|
||||
if len(parts) < 2 {
|
||||
return domain
|
||||
@@ -327,53 +141,12 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
}
|
||||
|
||||
result := strings.Replace(baseDomain, baseForLookup, anonymized, 1)
|
||||
if a.level >= LevelStrict && len(parts) > 2 {
|
||||
prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup)
|
||||
result = a.anonymizeLabels(prefix, "host") + "." + anonymized
|
||||
// The full mapping feeds AnonymizeString so seeded FQDNs are caught
|
||||
// in log lines as a whole, labels included.
|
||||
a.domainAnonymizer[baseDomain] = result
|
||||
}
|
||||
return withTrailingDot(result, hasDot)
|
||||
}
|
||||
|
||||
// anonymizePeerName replaces the labels in front of a protected suffix with
|
||||
// numbered peer placeholders, keeping the suffix, and records the full
|
||||
// mapping for string replacement in logs. The numbering keeps a peer
|
||||
// recognizable across the whole bundle without disclosing its name.
|
||||
func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string {
|
||||
prefix := strings.TrimSuffix(baseDomain, "."+suffix)
|
||||
result := a.anonymizeLabels(prefix, "peer") + "." + suffix
|
||||
if result != baseDomain {
|
||||
a.domainAnonymizer[baseDomain] = result
|
||||
if hasDot {
|
||||
result += "."
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// anonymizeLabels replaces each dot-separated label with a consistent
|
||||
// numbered placeholder ("<placeholder>-<n>"). Wildcard labels and
|
||||
// placeholders already handed out pass through unchanged.
|
||||
func (a *Anonymizer) anonymizeLabels(prefix, placeholder string) string {
|
||||
labels := strings.Split(prefix, ".")
|
||||
for i, label := range labels {
|
||||
if label == "*" {
|
||||
continue
|
||||
}
|
||||
if _, ok := a.labelAnonymized[label]; ok {
|
||||
continue
|
||||
}
|
||||
anon, ok := a.labelAnonymizer[label]
|
||||
if !ok {
|
||||
a.labelCounter++
|
||||
anon = fmt.Sprintf("%s-%d", placeholder, a.labelCounter)
|
||||
a.labelAnonymizer[label] = anon
|
||||
a.labelAnonymized[anon] = struct{}{}
|
||||
}
|
||||
labels[i] = anon
|
||||
}
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
func (a *Anonymizer) AnonymizeURI(uri string) string {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
@@ -408,70 +181,16 @@ func (a *Anonymizer) AnonymizeString(str string) string {
|
||||
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
|
||||
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
|
||||
|
||||
for _, domain := range a.sortedDomains() {
|
||||
str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain])
|
||||
for domain, anonDomain := range a.domainAnonymizer {
|
||||
str = strings.ReplaceAll(str, domain, anonDomain)
|
||||
}
|
||||
|
||||
str = a.AnonymizeSchemeURI(str)
|
||||
str = a.AnonymizeDNSLogLine(str)
|
||||
|
||||
// MAC handling runs after the IP passes so preserved IPv6 addresses are
|
||||
// already out of the way; the separator guard skips matches embedded in a
|
||||
// longer colon- or dash-separated sequence (such as an IPv6 tail).
|
||||
str = a.anonymizeMACsInString(str, macColonRegex, ':')
|
||||
str = a.anonymizeMACsInString(str, macDashRegex, '-')
|
||||
|
||||
if a.level >= LevelStrict {
|
||||
str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// sortedDomains returns the domain mappings longest-first, so a full-FQDN
|
||||
// mapping (strict level) is applied before the base-domain mapping it
|
||||
// contains. The order is rebuilt only when domainAnonymizer has grown.
|
||||
func (a *Anonymizer) sortedDomains() []string {
|
||||
if len(a.domainOrder) == len(a.domainAnonymizer) {
|
||||
return a.domainOrder
|
||||
}
|
||||
|
||||
a.domainOrder = a.domainOrder[:0]
|
||||
for domain := range a.domainAnonymizer {
|
||||
a.domainOrder = append(a.domainOrder, domain)
|
||||
}
|
||||
slices.SortFunc(a.domainOrder, func(x, y string) int {
|
||||
if d := len(y) - len(x); d != 0 {
|
||||
return d
|
||||
}
|
||||
return strings.Compare(x, y)
|
||||
})
|
||||
return a.domainOrder
|
||||
}
|
||||
|
||||
// anonymizeMACsInString replaces MAC addresses matched by re, skipping
|
||||
// matches that directly adjoin another sep so a six-group run inside a longer
|
||||
// separated sequence is left alone.
|
||||
func (a *Anonymizer) anonymizeMACsInString(str string, re *regexp.Regexp, sep byte) string {
|
||||
matches := re.FindAllStringIndex(str, -1)
|
||||
if len(matches) == 0 {
|
||||
return str
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, m := range matches {
|
||||
if (m[0] > 0 && str[m[0]-1] == sep) || (m[1] < len(str) && str[m[1]] == sep) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(str[last:m[0]])
|
||||
b.WriteString(a.AnonymizeMAC(str[m[0]:m[1]]))
|
||||
last = m[1]
|
||||
}
|
||||
b.WriteString(str[last:])
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes.
|
||||
func (a *Anonymizer) AnonymizeSchemeURI(text string) string {
|
||||
re := regexp.MustCompile(`(?i)\b(wss?://|rels?://|stuns?:|turns?:|https?://)\S+\b`)
|
||||
@@ -520,79 +239,10 @@ func isWellKnown(addr netip.Addr) bool {
|
||||
"128.0.0.0", "8000::", // 2nd split subnet for default routes
|
||||
}
|
||||
|
||||
return slices.Contains(wellKnown, addr.String())
|
||||
}
|
||||
|
||||
// isInternal reports whether ip identifies a host only within the local
|
||||
// network: IPv4 private (RFC 1918), CGNAT (RFC 6598), and link-local (v4 and
|
||||
// v6). These are preserved at the default level so support can reason about
|
||||
// the real topology, and replaced from the internal pools at the strict
|
||||
// level. IPv6 ULA is deliberately not internal: its random global ID uniquely
|
||||
// fingerprints the network, so it is anonymized at every level.
|
||||
func isInternal(ip netip.Addr) bool {
|
||||
return (ip.Is4() && ip.IsPrivate()) ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
isCGNAT(ip)
|
||||
}
|
||||
|
||||
func inPoolRange(ip, start, current netip.Addr) bool {
|
||||
return ip.Compare(start) >= 0 && ip.Compare(current) <= 0
|
||||
}
|
||||
|
||||
// isWellKnownMAC reports whether hw carries no stable host identity: all-zero
|
||||
// or a group address (broadcast and multicast).
|
||||
func isWellKnownMAC(hw net.HardwareAddr) bool {
|
||||
if hw[0]&1 == 1 {
|
||||
if slices.Contains(wellKnown, addr.String()) {
|
||||
return true
|
||||
}
|
||||
for _, b := range hw {
|
||||
if b != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// looksLikeWGKey reports whether s has the shape of a WireGuard key:
|
||||
// 44 base64 characters decoding to 32 bytes.
|
||||
func looksLikeWGKey(s string) bool {
|
||||
if len(s) != 44 || s[43] != '=' {
|
||||
return false
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(s)
|
||||
return err == nil && len(decoded) == 32
|
||||
}
|
||||
|
||||
func generateAnonymousKey() string {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return strings.Repeat("A", 43) + "="
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// protectedSuffix returns the protected NetBird suffix baseDomain ends with,
|
||||
// or empty. The match is label-anchored so an unrelated domain that merely
|
||||
// ends in the same characters is not preserved.
|
||||
func protectedSuffix(baseDomain string) string {
|
||||
for _, d := range protectedDomains {
|
||||
if baseDomain == d || strings.HasSuffix(baseDomain, "."+d) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withTrailingDot(domain string, hasDot bool) string {
|
||||
if hasDot {
|
||||
return domain + "."
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
// isCGNAT reports whether addr is in 100.64.0.0/10 (RFC 6598), the range
|
||||
// NetBird assigns overlay peer addresses from.
|
||||
func isCGNAT(addr netip.Addr) bool {
|
||||
cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0})
|
||||
cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10)
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package anonymize_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{})
|
||||
tests := []struct {
|
||||
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
const errCloseConnection = "Failed to close connection: %v"
|
||||
|
||||
var (
|
||||
logFileCount uint32
|
||||
systemInfoFlag bool
|
||||
logFileCount uint32
|
||||
systemInfoFlag bool
|
||||
uploadBundleFlag bool
|
||||
uploadBundleURLFlag string
|
||||
uploadBundleInsecureFlag bool
|
||||
@@ -156,11 +156,6 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
|
||||
// request. Returns an error if the RPC fails or if the daemon reports
|
||||
// an upload failure reason.
|
||||
func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -173,11 +168,10 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
request := &proto.DebugBundleRequest{
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel.String(),
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -235,11 +229,6 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("invalid duration format: %v", err)
|
||||
}
|
||||
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -379,11 +368,10 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
cmd.Println("Creating debug bundle...")
|
||||
|
||||
request := &proto.DebugBundleRequest{
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel.String(),
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
@@ -70,7 +69,6 @@ var (
|
||||
autoConnectDisabled bool
|
||||
extraIFaceBlackList []string
|
||||
anonymizeFlag bool
|
||||
anonymizeLevelFlag string
|
||||
dnsRouteInterval time.Duration
|
||||
// lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection
|
||||
// flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead).
|
||||
@@ -158,8 +156,7 @@ func init() {
|
||||
rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file")
|
||||
rootCmd.PersistentFlags().StringVar(&preSharedKey, preSharedKeyFlag, "", "Sets WireGuard PreSharedKey property. If set, then only peers that have the same key can communicate.")
|
||||
rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device")
|
||||
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize public IP addresses, MAC addresses, and non-netbird.io domains in logs and status output; private, CGNAT, and link-local IP ranges are kept (see --anonymize-level strict)")
|
||||
rootCmd.PersistentFlags().StringVar(&anonymizeLevelFlag, "anonymize-level", "", "anonymization level: \"default\" or \"strict\"; strict also anonymizes private, CGNAT, and link-local IP ranges, peer names, and WireGuard public keys. Setting this flag implies --anonymize")
|
||||
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output")
|
||||
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location")
|
||||
|
||||
rootCmd.AddCommand(upCmd)
|
||||
@@ -296,19 +293,6 @@ var CLIBackOffSettings = &backoff.ExponentialBackOff{
|
||||
Clock: backoff.SystemClock,
|
||||
}
|
||||
|
||||
// effectiveAnonymize resolves the --anonymize and --anonymize-level flags:
|
||||
// setting a level implies anonymization, and an invalid level is rejected.
|
||||
func effectiveAnonymize() (bool, anonymize.Level, error) {
|
||||
if anonymizeLevelFlag == "" {
|
||||
return anonymizeFlag, anonymize.LevelDefault, nil
|
||||
}
|
||||
level := anonymize.ParseLevel(anonymizeLevelFlag)
|
||||
if !strings.EqualFold(anonymizeLevelFlag, level.String()) {
|
||||
return false, anonymize.LevelDefault, fmt.Errorf("invalid anonymize level %q: use %q or %q", anonymizeLevelFlag, anonymize.LevelDefault.String(), anonymize.LevelStrict.String())
|
||||
}
|
||||
return true, level, nil
|
||||
}
|
||||
|
||||
func getSetupKey() (string, error) {
|
||||
if setupKeyPath != "" && setupKey == "" {
|
||||
return getSetupKeyFromFile(setupKeyPath)
|
||||
|
||||
@@ -121,14 +121,8 @@ func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
sessionExpiresAt = ts.AsTime().UTC()
|
||||
}
|
||||
|
||||
anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
||||
Anonymize: anonymizeEnabled,
|
||||
AnonymizeLevel: anonymizeLevel,
|
||||
Anonymize: anonymizeFlag,
|
||||
DaemonVersion: resp.GetDaemonVersion(),
|
||||
DaemonStatus: nbstatus.ParseDaemonStatus(status),
|
||||
StatusFilter: statusFilter,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// its wg interface into firewalld's "trusted" zone. This is required because
|
||||
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
|
||||
// versions, which returns EPERM to any other process that tries to insert
|
||||
// rules into them. Trusting the interface makes firewalld itself add the
|
||||
// accept rules to its own chains instead.
|
||||
// rules into them. The workaround mirrors what Tailscale does: let firewalld
|
||||
// itself add the accept rules to its own chains by trusting the interface.
|
||||
package firewalld
|
||||
|
||||
// TrustedZone is the firewalld zone name used for interfaces whose traffic
|
||||
|
||||
@@ -16,47 +16,28 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
)
|
||||
|
||||
func WithCustomDialer(_ bool, _ string) grpc.DialOption {
|
||||
return grpc.WithContextDialer(dialContext)
|
||||
}
|
||||
|
||||
// WithSweeper dials like WithCustomDialer but registers connections and
|
||||
// dials with the sweeper. Append it after WithCustomDialer: gRPC applies
|
||||
// dial options in order, so the later context dialer wins.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) grpc.DialOption {
|
||||
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
dial := sweeper.StartDial(ctx)
|
||||
defer dial.Release()
|
||||
if runtime.GOOS == "linux" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
||||
}
|
||||
|
||||
conn, err := dialContext(dial.Ctx(), addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
||||
if currentUser.Uid != "0" {
|
||||
log.Debug("Not running as root, using standard dialer")
|
||||
dialer := &net.Dialer{}
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
return dial.WrapConn(conn)
|
||||
|
||||
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
})
|
||||
}
|
||||
|
||||
func dialContext(ctx context.Context, addr string) (net.Conn, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
||||
}
|
||||
|
||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
||||
if currentUser.Uid != "0" {
|
||||
log.Debug("Not running as root, using standard dialer")
|
||||
dialer := &net.Dialer{}
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package grpc
|
||||
import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/util/wsproxy/client"
|
||||
)
|
||||
|
||||
@@ -12,8 +11,3 @@ import (
|
||||
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
|
||||
return client.WithWebSocketDialer(tlsEnabled, component)
|
||||
}
|
||||
|
||||
// WithSweeper is a no-op on WASM/JS: there is no network change signal.
|
||||
func WithSweeper(_ *netsweep.Sweeper) grpc.DialOption {
|
||||
return grpc.EmptyDialOption{}
|
||||
}
|
||||
|
||||
@@ -22,16 +22,6 @@ import (
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
)
|
||||
|
||||
const (
|
||||
// wgMsgTypeHandshakeInitiation is the lowest WireGuard message type.
|
||||
wgMsgTypeHandshakeInitiation uint32 = 1
|
||||
// wgMsgTypeTransport is the highest WireGuard message type.
|
||||
wgMsgTypeTransport uint32 = 4
|
||||
// wgMinMsgSize is the smallest WireGuard message: transport data with an empty
|
||||
// payload, which is what a keepalive is.
|
||||
wgMinMsgSize = 32
|
||||
)
|
||||
|
||||
type receiverCreator struct {
|
||||
iceBind *ICEBind
|
||||
}
|
||||
@@ -226,15 +216,8 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO
|
||||
for i := 0; i < numMsgs; i++ {
|
||||
msg := &(*msgs)[i]
|
||||
|
||||
if ok, err := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
|
||||
if err != nil {
|
||||
log.Debugf("failed to handle STUN packet from %s: %v", msg.Addr, err)
|
||||
}
|
||||
// WireGuard reuses sizes and eps across reads and only skips a slot
|
||||
// whose size is below the minimum message size. Leaving a consumed
|
||||
// slot untouched makes it process this buffer again under the
|
||||
// previous packet's length and endpoint.
|
||||
sizes[i] = 0
|
||||
// todo: handle err
|
||||
if ok, _ := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok {
|
||||
continue
|
||||
}
|
||||
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) {
|
||||
for i := range buffers {
|
||||
if n > len(buffers[i]) {
|
||||
continue
|
||||
}
|
||||
pkt := buffers[i][:n]
|
||||
|
||||
if isWireGuardMsg(pkt) || !stun.IsMessage(pkt) {
|
||||
if !stun.IsMessage(buffers[i]) {
|
||||
continue
|
||||
}
|
||||
|
||||
msg, err := s.parseSTUNMessage(pkt)
|
||||
msg, err := s.parseSTUNMessage(buffers[i][:n])
|
||||
if err != nil {
|
||||
buffers[i] = []byte{}
|
||||
return true, err
|
||||
@@ -369,34 +347,18 @@ func putMessages(msgs *[]ipv6.Message, msgsPool *sync.Pool) {
|
||||
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 {
|
||||
if n < 4 || n > len(buffers[0]) {
|
||||
return false
|
||||
// The first buffer should contain at least 4 bytes for type
|
||||
if len(buffers[0]) < 4 {
|
||||
return true
|
||||
}
|
||||
|
||||
msgType := binary.LittleEndian.Uint32(buffers[0][:4])
|
||||
return msgType == wgMsgTypeTransport && n > wgMinMsgSize
|
||||
// WireGuard packet type is a little-endian uint32 at start
|
||||
packetType := binary.LittleEndian.Uint32(buffers[0][:4])
|
||||
|
||||
// Check if packetType matches known WireGuard message types
|
||||
if packetType == 4 && n > 32 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -38,8 +38,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
||||
@@ -72,42 +70,18 @@ type ConnectClient struct {
|
||||
updateManager *updater.Manager
|
||||
|
||||
persistSyncResponse bool
|
||||
|
||||
// netState gates every reconnection loop on OS-reported network
|
||||
// availability. Nil (the default) disables gating; mobile platforms
|
||||
// inject it via WithNetworkState.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper cuts the management, signal and relay connections on network
|
||||
// change; nil disables it.
|
||||
sweeper *netsweep.Sweeper
|
||||
}
|
||||
|
||||
// ConnectClientOption configures optional ConnectClient behavior.
|
||||
type ConnectClientOption func(*ConnectClient)
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates every
|
||||
// reconnection loop; without it gating is disabled.
|
||||
func WithNetworkState(netState *netstate.State) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.sweeper = sweeper }
|
||||
}
|
||||
|
||||
func NewConnectClient(
|
||||
ctx context.Context,
|
||||
config *profilemanager.Config,
|
||||
statusRecorder *peer.Status,
|
||||
opts ...ConnectClientOption,
|
||||
) *ConnectClient {
|
||||
// Derive the run context here so Stop owns the cancel that unblocks the run
|
||||
// loop. runCancel is set once at construction, so Stop can call it without
|
||||
// racing the run loop's startup. Callers therefore need not cancel before Stop.
|
||||
runCtx, runCancel := context.WithCancel(ctx)
|
||||
c := &ConnectClient{
|
||||
return &ConnectClient{
|
||||
ctx: runCtx,
|
||||
runCancel: runCancel,
|
||||
runExited: make(chan struct{}),
|
||||
@@ -115,10 +89,6 @@ func NewConnectClient(
|
||||
statusRecorder: statusRecorder,
|
||||
engineMutex: sync.Mutex{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
||||
@@ -304,13 +274,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
return nil
|
||||
}
|
||||
|
||||
// suspend connection attempts while the OS reports no usable network
|
||||
if waited, err := c.netState.Wait(c.ctx); err != nil {
|
||||
return nil
|
||||
} else if waited {
|
||||
backOff.Reset()
|
||||
}
|
||||
|
||||
state.Set(StatusConnecting)
|
||||
|
||||
engineCtx, cancel := context.WithCancel(c.ctx)
|
||||
@@ -322,8 +285,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}()
|
||||
|
||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled,
|
||||
mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper))
|
||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
||||
if err != nil {
|
||||
// On daemon shutdown / Down() the parent context is cancelled
|
||||
// and the dial fails with "context canceled". Wrapping that
|
||||
@@ -398,7 +360,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}()
|
||||
|
||||
// with the global Netbird config in hand connect (just a connection, no stream yet) Signal
|
||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netState, c.sweeper)
|
||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return wrapErr(err)
|
||||
@@ -434,8 +396,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
engineConfig.StateDir = filepath.Dir(path)
|
||||
}
|
||||
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU,
|
||||
relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper))
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
|
||||
c.statusRecorder.SetRelayMgr(relayManager)
|
||||
if len(relayURLs) > 0 {
|
||||
if token != nil {
|
||||
@@ -463,7 +424,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
UpdateManager: c.updateManager,
|
||||
ClientMetrics: c.clientMetrics,
|
||||
MetricsCtx: c.ctx,
|
||||
NetState: c.netState,
|
||||
}, mobileDependency)
|
||||
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
||||
c.engine = engine
|
||||
@@ -520,16 +480,6 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
// status stream stuck at Connecting.
|
||||
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
|
||||
if err != nil {
|
||||
// Once the client context is cancelled backoff.WithContext surfaces the
|
||||
// bare context error, and any attempt torn down mid-flight reports the
|
||||
// same. That cancellation is the caller asking us to stop (Stop, Down or
|
||||
// an engine restart), so exit cleanly instead of handing back a failure
|
||||
// the caller would have to distinguish from a real one.
|
||||
if c.ctx.Err() != nil && errors.Is(err, context.Canceled) {
|
||||
log.Info("exiting client retry loop, context cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
||||
state.Set(StatusNeedsLogin)
|
||||
@@ -723,7 +673,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 {
|
||||
}
|
||||
|
||||
// connectToSignal creates Signal Service client and established a connection
|
||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) {
|
||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) {
|
||||
var sigTLSEnabled bool
|
||||
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
|
||||
sigTLSEnabled = true
|
||||
@@ -731,8 +681,7 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
|
||||
sigTLSEnabled = false
|
||||
}
|
||||
|
||||
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled,
|
||||
signal.WithNetworkState(netState), signal.WithSweeper(sweeper))
|
||||
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled)
|
||||
if err != nil {
|
||||
log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err)
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err)
|
||||
|
||||
17
client/internal/daemonaddr/identity.go
Normal file
17
client/internal/daemonaddr/identity.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package daemonaddr
|
||||
|
||||
import "strings"
|
||||
|
||||
// CarriesIdentity reports whether the control channel at addr conveys the
|
||||
// connecting process's identity to the daemon. A Unix socket carries peer
|
||||
// credentials and a named pipe carries the client's token. Nothing else does, TCP
|
||||
// included, and there the daemon can authorize a privileged operation for nobody
|
||||
// at all: see ResolveDaemonAddr, which says as much to anyone still reaching the
|
||||
// Windows daemon on the address it served before it had a pipe.
|
||||
//
|
||||
// A client uses this to tell whether becoming privileged would get it anywhere.
|
||||
// It answers from the scheme and nothing else, so an address it does not
|
||||
// recognise counts as carrying no identity.
|
||||
func CarriesIdentity(addr string) bool {
|
||||
return strings.HasPrefix(addr, "unix://") || strings.HasPrefix(addr, pipeScheme)
|
||||
}
|
||||
29
client/internal/daemonaddr/identity_test.go
Normal file
29
client/internal/daemonaddr/identity_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCarriesIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
addr string
|
||||
want bool
|
||||
}{
|
||||
{"unix:///var/run/netbird.sock", true},
|
||||
{"unix:///var/run/netbird/default.sock", true},
|
||||
{"npipe://netbird", true},
|
||||
{`npipe://\\.\pipe\ProtectedPrefix\Administrators\netbird`, true},
|
||||
{"tcp://127.0.0.1:41731", false},
|
||||
{"tcp://localhost:41731", false},
|
||||
{"", false},
|
||||
{"/var/run/netbird.sock", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.addr, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, CarriesIdentity(tt.addr), "address %q", tt.addr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,9 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
)
|
||||
|
||||
const readmeContent = `This debug bundle contains the following files.
|
||||
If anonymization is enabled (--anonymize / --anonymize-level), the files are anonymized to protect sensitive information.
|
||||
const readmeContent = `Netbird debug bundle
|
||||
This debug bundle contains the following files.
|
||||
If the --anonymize flag is set, the files are anonymized to protect sensitive information.
|
||||
|
||||
status.txt: Anonymized status information 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
|
||||
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
|
||||
|
||||
Default level:
|
||||
- Public IPv4 addresses are replaced with addresses starting from 198.51.100.0
|
||||
- Public IPv6 addresses are replaced with addresses starting from 2001:db8:ffff::
|
||||
- IPv6 unique local addresses (fc00::/7) are anonymized as well: their random global ID uniquely identifies the network.
|
||||
- IP addresses from internal IPv4 ranges and well-known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., 169.254., fe80::).
|
||||
|
||||
Strict level (--anonymize-level strict), in addition to the default level:
|
||||
- Private (RFC 1918), CGNAT (100.64.0.0/10), and link-local (169.254.0.0/16, fe80::/10) addresses are anonymized too.
|
||||
- Internal IPv4 addresses are replaced with addresses starting from 198.18.0.0 and internal IPv6 addresses with addresses starting from 2001:db8:1::, so internal addresses remain distinguishable from public ones.
|
||||
- Addresses are mapped in order of first appearance: subnet structure, allocation scheme, and gateway conventions are not preserved. Prefix lengths of networks are preserved.
|
||||
- Peer names in front of NetBird domains are replaced with numbered placeholders (e.g. peer-1.netbird.cloud), and subdomain labels of other domains with host-N placeholders.
|
||||
- WireGuard public keys are replaced with consistent placeholder keys.
|
||||
IPv4 addresses are replaced with addresses starting from 198.51.100.0
|
||||
IPv6 addresses are replaced with addresses starting from 100::
|
||||
|
||||
IP addresses from non public ranges and well known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., etc.).
|
||||
Reoccuring IP addresses are replaced with the same anonymized address.
|
||||
|
||||
Note: The anonymized IP addresses in the status file do not match those in the log and routes files. However, the anonymized IP addresses are consistent within the status file and across the routes and log files.
|
||||
|
||||
MAC Addresses
|
||||
MAC addresses are replaced at every anonymization level with consistent placeholders counting up from 02:00:00:00:00:01. Broadcast, multicast, and all-zero addresses are kept. At the default level a preserved IPv6 link-local address may still embed a MAC address (EUI-64); the strict level anonymizes those addresses.
|
||||
|
||||
Domains
|
||||
All domain names (except for the netbird domains) are replaced with randomly generated strings ending in ".domain". Anonymized domains are consistent across all files in the bundle.
|
||||
Reoccuring domain names are replaced with the same anonymized domain.
|
||||
At the strict level, the peer name labels in front of netbird domains are anonymized as well.
|
||||
|
||||
Sync Response
|
||||
The network_map.json file contains the following anonymized information:
|
||||
@@ -293,7 +281,6 @@ type BundleGenerator struct {
|
||||
cliVersion string
|
||||
|
||||
anonymize bool
|
||||
anonymizeLevel anonymize.Level
|
||||
includeSystemInfo bool
|
||||
logFileCount uint32
|
||||
|
||||
@@ -301,10 +288,7 @@ type BundleGenerator struct {
|
||||
}
|
||||
|
||||
type BundleConfig struct {
|
||||
Anonymize bool
|
||||
// AnonymizeLevel selects how much the anonymizer redacts.
|
||||
// anonymize.LevelStrict implies Anonymize.
|
||||
AnonymizeLevel anonymize.Level
|
||||
Anonymize bool
|
||||
IncludeSystemInfo bool
|
||||
LogFileCount uint32
|
||||
}
|
||||
@@ -343,11 +327,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
uiLogOpener = openLogFile
|
||||
}
|
||||
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(cfg.AnonymizeLevel)
|
||||
|
||||
return &BundleGenerator{
|
||||
anonymizer: anonymizer,
|
||||
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
|
||||
|
||||
internalConfig: deps.InternalConfig,
|
||||
statusRecorder: deps.StatusRecorder,
|
||||
@@ -364,8 +345,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
daemonVersion: deps.DaemonVersion,
|
||||
cliVersion: deps.CliVersion,
|
||||
|
||||
anonymize: cfg.Anonymize || cfg.AnonymizeLevel >= anonymize.LevelStrict,
|
||||
anonymizeLevel: cfg.AnonymizeLevel,
|
||||
anonymize: cfg.Anonymize,
|
||||
includeSystemInfo: cfg.IncludeSystemInfo,
|
||||
logFileCount: logFileCount,
|
||||
}
|
||||
@@ -505,13 +485,7 @@ func (g *BundleGenerator) addSystemInfo() {
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addReadme() error {
|
||||
level := "none (anonymization disabled)"
|
||||
if g.anonymize {
|
||||
level = g.anonymizeLevel.String()
|
||||
}
|
||||
header := fmt.Sprintf("Netbird debug bundle\nAnonymization level applied to this bundle: %s\n", level)
|
||||
|
||||
readmeReader := strings.NewReader(header + readmeContent)
|
||||
readmeReader := strings.NewReader(readmeContent)
|
||||
if err := g.addFileToZip(readmeReader, "README.txt"); err != nil {
|
||||
return fmt.Errorf("add README file to zip: %w", err)
|
||||
}
|
||||
@@ -533,10 +507,9 @@ func (g *BundleGenerator) addStatus() error {
|
||||
fullStatus := g.statusRecorder.GetFullStatus()
|
||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||
Anonymize: g.anonymize,
|
||||
AnonymizeLevel: g.anonymizeLevel,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
})
|
||||
overview.CliVersion = g.cliVersion
|
||||
statusOutput := overview.FullDetailSummary()
|
||||
@@ -689,7 +662,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
configContent.WriteString("NetBird Client Configuration:\n\n")
|
||||
|
||||
if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil {
|
||||
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", 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("WgPort: %d\n", g.internalConfig.WgPort))
|
||||
@@ -979,11 +952,6 @@ func (g *BundleGenerator) addUpdateLogs() error {
|
||||
}
|
||||
|
||||
baseName := filepath.Base(logFile)
|
||||
data, err = g.anonymizeBytes(data)
|
||||
if err != nil {
|
||||
log.Warnf("skipping update log file %s: %v", baseName, err)
|
||||
continue
|
||||
}
|
||||
if err := g.addFileToZip(bytes.NewReader(data), filepath.Join("update-logs", baseName)); err != nil {
|
||||
return fmt.Errorf("add update log file %s to zip: %w", baseName, err)
|
||||
}
|
||||
@@ -1011,13 +979,6 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
|
||||
}
|
||||
|
||||
fileName := filepath.Base(match)
|
||||
// Corrupted state files usually fail structured JSON anonymization,
|
||||
// so run them through the string anonymizer instead.
|
||||
data, err = g.anonymizeBytes(data)
|
||||
if err != nil {
|
||||
log.Warnf("skipping corrupted state file %s: %v", fileName, err)
|
||||
continue
|
||||
}
|
||||
if err := g.addFileToZip(bytes.NewReader(data), "corrupted_states/"+fileName); err != nil {
|
||||
log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err)
|
||||
continue
|
||||
@@ -1029,27 +990,6 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// anonymizeBytes runs raw file content through the string anonymizer line by
|
||||
// line when anonymization is enabled. It errors instead of returning partial
|
||||
// content, so a caller never adds an unanonymized fallback to the bundle.
|
||||
func (g *BundleGenerator) anonymizeBytes(data []byte) ([]byte, error) {
|
||||
if !g.anonymize {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
buf.WriteString(g.anonymizer.AnonymizeString(scanner.Text()))
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("anonymize content: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addMetrics() error {
|
||||
if g.clientMetrics == nil {
|
||||
log.Debugf("skipping metrics in debug bundle: no metrics collector")
|
||||
@@ -1522,7 +1462,6 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize.
|
||||
}
|
||||
|
||||
peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn)
|
||||
peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey)
|
||||
|
||||
anonymizeSSHConfig(peer.SshConfig)
|
||||
}
|
||||
|
||||
@@ -35,14 +35,14 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName))
|
||||
sb.WriteString(fmt.Sprintf(" public key: %s\n", 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))
|
||||
if s.FWMark != 0 {
|
||||
sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark))
|
||||
}
|
||||
|
||||
for _, peer := range s.Peers {
|
||||
sb.WriteString(fmt.Sprintf("\npeer: %s\n", g.anonymizer.AnonymizeWGKey(peer.PublicKey)))
|
||||
sb.WriteString(fmt.Sprintf("\npeer: %s\n", peer.PublicKey))
|
||||
if peer.Endpoint.IP != nil {
|
||||
if g.anonymize {
|
||||
anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint)
|
||||
@@ -54,11 +54,7 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string {
|
||||
if len(peer.AllowedIPs) > 0 {
|
||||
var ipStrings []string
|
||||
for _, ipnet := range peer.AllowedIPs {
|
||||
ipStr := ipnet.String()
|
||||
if g.anonymize {
|
||||
ipStr = g.anonymizer.AnonymizeIPString(ipStr)
|
||||
}
|
||||
ipStrings = append(ipStrings, ipStr)
|
||||
ipStrings = append(ipStrings, ipnet.String())
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", ")))
|
||||
}
|
||||
|
||||
74
client/internal/elevate/elevate.go
Normal file
74
client/internal/elevate/elevate.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Package elevate re-runs this very executable under the operating system's own
|
||||
// privilege-elevation mechanism and waits for it to finish.
|
||||
//
|
||||
// It exists so that a change the daemon restricts to root/administrator can be
|
||||
// authorized from the GUI, by the user, at the moment they ask for it: Windows
|
||||
// shows the UAC consent dialog, macOS the system authentication dialog, and
|
||||
// Linux/FreeBSD the session's polkit agent. The credentials, where any are
|
||||
// asked for, are collected by the operating system and never pass through
|
||||
// NetBird.
|
||||
//
|
||||
// What the elevated process then does is the caller's business: it is the same
|
||||
// binary, in a one-shot mode, and it is authorized by the daemon exactly like
|
||||
// any other privileged caller, from the identity the kernel reports on the
|
||||
// control channel. Nothing here grants privilege, and the daemon gains no new
|
||||
// way to be talked into something: elevation only changes who is calling it.
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// AppliedMarker is what the elevated process prints on standard output once it has
|
||||
// done what it was run for.
|
||||
//
|
||||
// macOS's AuthorizationExecuteWithPrivileges reports no exit status and does not
|
||||
// say which process it started, so there this line is the only evidence that the
|
||||
// change was applied. The other platforms have an exit code and ignore it.
|
||||
const AppliedMarker = "netbird-elevated: applied"
|
||||
|
||||
var (
|
||||
// ErrDeclined reports that the user dismissed the prompt or did not
|
||||
// authenticate. Nothing happened and nothing is wrong: a caller undoes its
|
||||
// optimistic update and stays quiet.
|
||||
ErrDeclined = errors.New("authorization declined")
|
||||
|
||||
// ErrUnavailable reports that this host has no elevation mechanism we can
|
||||
// drive: no polkit on a Unix desktop, or an executable we decline to run as
|
||||
// root. A caller falls back to telling the user which command to run.
|
||||
ErrUnavailable = errors.New("no privilege elevation mechanism available")
|
||||
)
|
||||
|
||||
// Run runs this executable with args under the platform's elevation mechanism
|
||||
// and waits for it to exit. A non-zero exit is returned as an error, so the
|
||||
// caller can treat a completed Run as the operation having succeeded.
|
||||
//
|
||||
// The args are the caller's own command line, so they cross no privilege
|
||||
// boundary: only a user who has just authenticated as an administrator can get
|
||||
// them run at all.
|
||||
func Run(ctx context.Context, args ...string) error {
|
||||
self, err := trustedSelf()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return run(ctx, self, args)
|
||||
}
|
||||
|
||||
// Available reports whether Run has a mechanism to use on this host, so a caller
|
||||
// can offer the prompt only when there is one and otherwise fall back to
|
||||
// guidance the user can act on. It answers from what is installed, not from what
|
||||
// the user is allowed to do: an administrator's password may still be required
|
||||
// and may still not be given, which is ErrDeclined from Run.
|
||||
func Available() bool {
|
||||
if _, err := trustedSelf(); err != nil {
|
||||
// Worth a line: this is also what a build run from a group-writable
|
||||
// directory hits, and there is nothing in the UI to say why the offer is
|
||||
// missing.
|
||||
log.Debugf("not offering privilege elevation: %v", err)
|
||||
return false
|
||||
}
|
||||
return mechanismAvailable()
|
||||
}
|
||||
18
client/internal/elevate/output.go
Normal file
18
client/internal/elevate/output.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package elevate
|
||||
|
||||
import "strings"
|
||||
|
||||
// noOutput stands in for a process that said nothing, so that a report of what it
|
||||
// said still reads as a sentence.
|
||||
const noOutput = "no output"
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return noOutput
|
||||
}
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
21
client/internal/elevate/output_test.go
Normal file
21
client/internal/elevate/output_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFirstLine(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{in: "", want: noOutput},
|
||||
{in: " \n ", want: noOutput},
|
||||
{in: "one line", want: "one line"},
|
||||
{in: "first\nsecond", want: "first"},
|
||||
{in: "\nsecond\n", want: "second"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, firstLine(tt.in), "input %q", tt.in)
|
||||
}
|
||||
}
|
||||
359
client/internal/elevate/run_darwin.go
Normal file
359
client/internal/elevate/run_darwin.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Authorization Services, reached through purego rather than cgo so the released
|
||||
// binaries keep building with CGO_ENABLED=0.
|
||||
//
|
||||
// The prompt belongs to this process, which is what makes it carry the
|
||||
// application's name and our own explanation. Going through osascript instead puts
|
||||
// the very same trampoline behind a dialog attributed to osascript, and means
|
||||
// handing a shell a command line to re-parse.
|
||||
//
|
||||
// # On AuthorizationExecuteWithPrivileges
|
||||
//
|
||||
// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on
|
||||
// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's
|
||||
// been deprecated for many years. Do not use it in a widely distributed product."
|
||||
// It is used here anyway, knowingly, because the alternatives Apple offers are for
|
||||
// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless —
|
||||
// and NetBird already has what they would install: a launchd daemon running as
|
||||
// root. What is missing is only a way for an unprivileged client to ask it to act.
|
||||
//
|
||||
// The way to that without a deprecated call is to authorize the client instead of
|
||||
// elevating one: the app takes the right with AuthorizationCreate, passes the
|
||||
// AuthorizationExternalForm to the daemon, and the daemon checks it with
|
||||
// AuthorizationCopyRights before acting — none of which is deprecated. It is the
|
||||
// better design and it is where this should end up. It also means the daemon
|
||||
// accepting an authorization over its control socket, which is a new way to be
|
||||
// asked for privileged work and wants reviewing as such, so it is deliberately not
|
||||
// bundled in with the rest of this.
|
||||
//
|
||||
// Until then, three things keep the deprecation from being a trap. Every symbol is
|
||||
// resolved with an error rather than a panic, so a macOS that has dropped this
|
||||
// function leaves the app offering the user a command instead of crashing on the
|
||||
// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the
|
||||
// fallback is the same one an agent-less Linux session gets. And the whole path
|
||||
// runs under guard, which turns a panic out of the FFI layer into that same
|
||||
// fallback.
|
||||
//
|
||||
// The trampoline passes on the environment it was given, so what it starts as root
|
||||
// must be an executable this user's peers cannot influence: that is what
|
||||
// trustedSelf refuses, and what signing the binary settles for the loader.
|
||||
|
||||
const (
|
||||
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
|
||||
libSystem = "/usr/lib/libSystem.B.dylib"
|
||||
|
||||
// trampoline is what the framework hands the tool to. Present on every macOS,
|
||||
// and worth confirming before offering a prompt rather than mid-prompt.
|
||||
trampoline = "/usr/libexec/security_authtrampoline"
|
||||
)
|
||||
|
||||
// rightExecute is the right an administrator holds, and what
|
||||
// AuthorizationExecuteWithPrivileges requires of us.
|
||||
const rightExecute = "system.privilege.admin"
|
||||
|
||||
// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above
|
||||
// the system's in the dialog. It is about the change rather than the mechanism.
|
||||
const (
|
||||
promptKey = "prompt"
|
||||
promptText = "NetBird needs to change a setting that grants SSH access to this computer."
|
||||
)
|
||||
|
||||
// OSStatus values from SecBase.h that mean something to us; anything else is
|
||||
// reported as it comes.
|
||||
const (
|
||||
errAuthorizationSuccess = 0
|
||||
errAuthorizationDenied = -60005
|
||||
errAuthorizationCanceled = -60006
|
||||
errAuthorizationInteractionNotAllowed = -60007
|
||||
errAuthorizationToolExecuteFailure = -60031
|
||||
errAuthorizationToolEnvironmentError = -60032
|
||||
)
|
||||
|
||||
// AuthorizationFlags from Authorization.h.
|
||||
const (
|
||||
flagDefaults = 0
|
||||
flagInteractionAllowed = 1 << 0
|
||||
flagExtendRights = 1 << 1
|
||||
flagDestroyRights = 1 << 3
|
||||
flagPreAuthorize = 1 << 4
|
||||
)
|
||||
|
||||
// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives
|
||||
// meaning to. 32 bytes on both amd64 and arm64.
|
||||
type authorizationItem struct {
|
||||
name *byte
|
||||
valueLength uintptr
|
||||
value unsafe.Pointer
|
||||
// flags is reserved by the API and always zero. Declared because the layout
|
||||
// is the contract: without it the struct is 24 bytes where C reads 32.
|
||||
flags uint32 //nolint:unused // part of the C layout
|
||||
}
|
||||
|
||||
// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an
|
||||
// AuthorizationRights and an AuthorizationEnvironment.
|
||||
type authorizationItemSet struct {
|
||||
count uint32
|
||||
items *authorizationItem
|
||||
}
|
||||
|
||||
var (
|
||||
authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32
|
||||
authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32
|
||||
authorizationFree func(authorization uintptr, flags uint32) int32
|
||||
fileno func(stream uintptr) int32
|
||||
fclose func(stream uintptr) int32
|
||||
|
||||
loadOnce sync.Once
|
||||
loadErr error
|
||||
)
|
||||
|
||||
// load resolves the functions once. A framework that cannot be opened, or a symbol
|
||||
// that is no longer there, leaves the host without a mechanism rather than taking
|
||||
// the process down with it: see the note on deprecation above.
|
||||
func load() error {
|
||||
loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) })
|
||||
return loadErr
|
||||
}
|
||||
|
||||
// guard turns a panic out of the FFI layer into an error, so an API that has
|
||||
// changed under us costs the user a prompt rather than the window they were
|
||||
// clicking in. purego panics on a signature it cannot map, and this is the one
|
||||
// place in the client that calls a deprecated system function.
|
||||
//
|
||||
// It catches Go panics, which is what purego raises. A fault inside the framework
|
||||
// itself is not a panic and not recoverable; the layout the tests pin down is what
|
||||
// stands between us and that.
|
||||
func guard(what string, fn func() error) (err error) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
log.Errorf("%s panicked: %v", what, r)
|
||||
err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r)
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func resolve() error {
|
||||
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", securityFramework, err)
|
||||
}
|
||||
system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", libSystem, err)
|
||||
}
|
||||
|
||||
// purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a
|
||||
// deprecated function's disappearance should reach the user.
|
||||
for _, fn := range []struct {
|
||||
ptr any
|
||||
handle uintptr
|
||||
name string
|
||||
}{
|
||||
{&authorizationCreate, security, "AuthorizationCreate"},
|
||||
{&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"},
|
||||
{&authorizationFree, security, "AuthorizationFree"},
|
||||
{&fileno, system, "fileno"},
|
||||
{&fclose, system, "fclose"},
|
||||
} {
|
||||
symbol, err := purego.Dlsym(fn.handle, fn.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve %s: %w", fn.name, err)
|
||||
}
|
||||
if symbol == 0 {
|
||||
return fmt.Errorf("resolve %s: not present on this system", fn.name)
|
||||
}
|
||||
purego.RegisterFunc(fn.ptr, symbol)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// run asks the system to run self as root: first for the right, which is what puts
|
||||
// up the authentication dialog and collects the password or takes the Touch ID,
|
||||
// then for the tool. The credentials go to the system's authorization trampoline
|
||||
// and never to us.
|
||||
//
|
||||
// The context bounds only our own waiting; the dialog belongs to the system and
|
||||
// closes when the user answers it.
|
||||
func run(ctx context.Context, self string, args []string) error {
|
||||
if err := load(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
|
||||
return guard("asking for privileges", func() error {
|
||||
authorization, err := authorize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer authorizationFree(authorization, flagDestroyRights)
|
||||
|
||||
return execute(ctx, authorization, self, args)
|
||||
})
|
||||
}
|
||||
|
||||
func mechanismAvailable() bool {
|
||||
if err := load(); err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(trampoline)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// authorize obtains the right, prompting for it. A dismissed dialog comes back as
|
||||
// errAuthorizationCanceled and a password given up on as errAuthorizationDenied;
|
||||
// both are the user's answer rather than a failure.
|
||||
func authorize() (uintptr, error) {
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
|
||||
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
||||
environment := itemSet(&pinner, promptItem(&pinner))
|
||||
|
||||
var authorization uintptr
|
||||
status := authorizationCreate(rights, environment,
|
||||
flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization)
|
||||
|
||||
switch status {
|
||||
case errAuthorizationSuccess:
|
||||
return authorization, nil
|
||||
case errAuthorizationCanceled, errAuthorizationDenied:
|
||||
return 0, ErrDeclined
|
||||
case errAuthorizationInteractionNotAllowed:
|
||||
// Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or
|
||||
// a session with no window server.
|
||||
return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable)
|
||||
default:
|
||||
return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status)
|
||||
}
|
||||
}
|
||||
|
||||
// execute runs the tool with the right in hand and waits for it by reading the pipe
|
||||
// it is given until the tool closes it.
|
||||
//
|
||||
// AuthorizationExecuteWithPrivileges reports no exit status and does not say what
|
||||
// process it started, which is why the one-shot says so itself: what it prints is
|
||||
// the only evidence that the change was applied.
|
||||
func execute(ctx context.Context, authorization uintptr, self string, args []string) error {
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
|
||||
argv := make([]uintptr, 0, len(args)+1)
|
||||
for _, arg := range args {
|
||||
argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg))))
|
||||
}
|
||||
argv = append(argv, 0)
|
||||
pinner.Pin(&argv[0])
|
||||
|
||||
var pipe uintptr
|
||||
status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe)
|
||||
switch status {
|
||||
case errAuthorizationSuccess:
|
||||
case errAuthorizationCanceled:
|
||||
return ErrDeclined
|
||||
case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError:
|
||||
// The right was granted and the tool still did not start. Nothing the user
|
||||
// can do about it from here, so point them at the command instead.
|
||||
return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status)
|
||||
default:
|
||||
return fmt.Errorf("run %s elevated: OSStatus %d", self, status)
|
||||
}
|
||||
|
||||
out, err := readPipe(ctx, pipe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkApplied(out)
|
||||
}
|
||||
|
||||
// checkApplied reads the one-shot's report, which stands in for the exit status
|
||||
// there is no way to ask for here. A run that said nothing did not apply the
|
||||
// change, whatever else went on.
|
||||
func checkApplied(out string) error {
|
||||
if !strings.Contains(out, AppliedMarker) {
|
||||
return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPipe drains the tool's output, which ends when the tool exits and is
|
||||
// therefore also how we wait for it.
|
||||
func readPipe(ctx context.Context, pipe uintptr) (string, error) {
|
||||
if pipe == 0 {
|
||||
return "", nil
|
||||
}
|
||||
defer fclose(pipe)
|
||||
|
||||
fd := int(fileno(pipe))
|
||||
if fd < 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return out.String(), err
|
||||
}
|
||||
n, err := syscall.Read(fd, buf)
|
||||
if n > 0 {
|
||||
out.Write(buf[:n])
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, syscall.EINTR):
|
||||
// A signal landed mid-read, which says nothing about the tool.
|
||||
continue
|
||||
case err != nil:
|
||||
log.Debugf("read the elevated process's output: %v", err)
|
||||
return out.String(), nil
|
||||
case n <= 0:
|
||||
// End of file: the tool closed the pipe, which is how it exiting
|
||||
// reaches us.
|
||||
return out.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// itemSet builds an AuthorizationItemSet over items, pinned for the call.
|
||||
func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet {
|
||||
pinner.Pin(&items[0])
|
||||
set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]}
|
||||
pinner.Pin(set)
|
||||
return set
|
||||
}
|
||||
|
||||
// promptItem is the environment entry carrying our sentence for the dialog.
|
||||
func promptItem(pinner *runtime.Pinner) authorizationItem {
|
||||
value := []byte(promptText)
|
||||
pinner.Pin(&value[0])
|
||||
return authorizationItem{
|
||||
name: cString(pinner, promptKey),
|
||||
valueLength: uintptr(len(value)),
|
||||
value: unsafe.Pointer(&value[0]),
|
||||
}
|
||||
}
|
||||
|
||||
// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for
|
||||
// the duration of the call.
|
||||
func cString(pinner *runtime.Pinner, s string) *byte {
|
||||
b := append([]byte(s), 0)
|
||||
pinner.Pin(&b[0])
|
||||
return &b[0]
|
||||
}
|
||||
111
client/internal/elevate/run_darwin_test.go
Normal file
111
client/internal/elevate/run_darwin_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The framework has to load and the symbols have to resolve, or nothing else here
|
||||
// means anything.
|
||||
func TestSecurityFrameworkLoads(t *testing.T) {
|
||||
require.NoError(t, load(), "Security.framework must open")
|
||||
|
||||
for name, fn := range map[string]any{
|
||||
"AuthorizationCreate": authorizationCreate,
|
||||
"AuthorizationExecuteWithPrivileges": authorizationExecuteWithPrivileges,
|
||||
"AuthorizationFree": authorizationFree,
|
||||
"fileno": fileno,
|
||||
"fclose": fclose,
|
||||
} {
|
||||
assert.NotNil(t, fn, "%s must resolve", name)
|
||||
}
|
||||
}
|
||||
|
||||
// A request with no interaction allowed exercises the whole call — the rights and
|
||||
// environment structs, and the OSStatus that comes back — without a dialog anybody
|
||||
// has to answer. What the system decides is its business; that it decides at all is
|
||||
// what this asserts.
|
||||
func TestAuthorizationCreateWithoutInteraction(t *testing.T) {
|
||||
if err := load(); err != nil {
|
||||
t.Skipf("Security.framework did not open: %v", err)
|
||||
}
|
||||
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
|
||||
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
||||
environment := itemSet(&pinner, promptItem(&pinner))
|
||||
require.EqualValues(t, 1, rights.count, "the rights struct layout must match the C one")
|
||||
|
||||
var authorization uintptr
|
||||
status := authorizationCreate(rights, environment, flagDefaults|flagExtendRights, &authorization)
|
||||
|
||||
switch status {
|
||||
case errAuthorizationSuccess:
|
||||
// Credentials were already cached for this session.
|
||||
authorizationFree(authorization, flagDestroyRights)
|
||||
case errAuthorizationDenied, errAuthorizationInteractionNotAllowed:
|
||||
// The expected answers when nobody may be asked.
|
||||
default:
|
||||
require.Failf(t, "unknown OSStatus", "AuthorizationCreate returned %d, want a status we recognise", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Asking with a right nobody has must not be mistaken for a declined prompt: the
|
||||
// caller would report nothing at all.
|
||||
func TestAuthorizeUnknownRightIsNotDeclined(t *testing.T) {
|
||||
if err := load(); err != nil {
|
||||
t.Skipf("Security.framework did not open: %v", err)
|
||||
}
|
||||
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
|
||||
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, "io.netbird.right.that.does.not.exist")})
|
||||
|
||||
var authorization uintptr
|
||||
status := authorizationCreate(rights, nil, flagDefaults|flagExtendRights, &authorization)
|
||||
if status == errAuthorizationSuccess {
|
||||
authorizationFree(authorization, flagDestroyRights)
|
||||
}
|
||||
assert.NotEqual(t, int32(errAuthorizationSuccess), status, "a right that does not exist must not be granted")
|
||||
}
|
||||
|
||||
func TestMechanismAvailable(t *testing.T) {
|
||||
assert.True(t, mechanismAvailable(), "the trampoline exists on every macOS")
|
||||
}
|
||||
|
||||
// The one-shot's report is what stands in for an exit status here, so a run that
|
||||
// says nothing must not read as success.
|
||||
func TestCheckApplied(t *testing.T) {
|
||||
require.NoError(t, checkApplied(AppliedMarker+"\n"), "the report the one-shot prints")
|
||||
require.NoError(t, checkApplied("some warning\n"+AppliedMarker+"\n"), "the report after other output")
|
||||
|
||||
assert.Error(t, checkApplied(""), "a run that printed nothing did not apply the change")
|
||||
assert.Error(t, checkApplied("dyld: library not loaded\n"), "output that is not the report")
|
||||
}
|
||||
|
||||
// A panic out of the FFI layer has to reach the caller as "no mechanism", which is
|
||||
// the outcome that offers the user the command instead of taking the window down.
|
||||
func TestGuardTurnsAPanicIntoUnavailable(t *testing.T) {
|
||||
err := guard("pretending to call something", func() error {
|
||||
panic("purego: signature it cannot map")
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, ErrUnavailable, "a panic must read as a missing mechanism")
|
||||
assert.Contains(t, err.Error(), "pretending to call something", "what panicked")
|
||||
}
|
||||
|
||||
// guard wraps every darwin path, so what a caller switches on has to survive it.
|
||||
func TestGuardPassesErrorsThrough(t *testing.T) {
|
||||
sentinel := errors.New("the call itself failed")
|
||||
assert.ErrorIs(t, guard("calling", func() error { return sentinel }), sentinel,
|
||||
"the error it was given")
|
||||
assert.ErrorIs(t, guard("calling", func() error { return ErrDeclined }), ErrDeclined,
|
||||
"a declined prompt stays declined")
|
||||
assert.NoError(t, guard("calling", func() error { return nil }), "a call that worked")
|
||||
}
|
||||
117
client/internal/elevate/run_unix.go
Normal file
117
client/internal/elevate/run_unix.go
Normal file
@@ -0,0 +1,117 @@
|
||||
//go:build linux
|
||||
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pkexec exit codes that are about the authorization rather than about the program
|
||||
// we asked it to run. The manual page reserves both.
|
||||
const (
|
||||
// exitDismissed is returned when the user dismissed the authentication
|
||||
// dialog.
|
||||
exitDismissed = 126
|
||||
// exitNotAuthorized is returned when the authorization was not obtained. That
|
||||
// covers the user saying no as well as pkexec having had nobody to ask: see
|
||||
// noAgentMarkers.
|
||||
exitNotAuthorized = 127
|
||||
)
|
||||
|
||||
// exitNotAuthorized covers three different endings that only pkexec's own words
|
||||
// tell apart, so they are matched here. Read with LC_ALL=C so the words are the
|
||||
// ones written below.
|
||||
//
|
||||
// refusedMarker is a refusal: the user said no, gave up on the password, or holds
|
||||
// an account that may not elevate at all.
|
||||
const refusedMarker = "Not authorized"
|
||||
|
||||
// noAgentMarkers say pkexec had no way to ask: no agent registered for the
|
||||
// session, and no controlling terminal for the textual agent it falls back to.
|
||||
var noAgentMarkers = []string{"authentication agent", "controlling terminal"}
|
||||
|
||||
// run asks polkit to run self as root. pkexec hands the request to the session's
|
||||
// polkit agent, which is what prompts and what collects any password; we see only
|
||||
// its verdict.
|
||||
//
|
||||
// The environment is otherwise deliberately not passed through: pkexec clears it
|
||||
// bar a small allowlist, and the one-shot needs nothing from it.
|
||||
func run(ctx context.Context, self string, args []string) error {
|
||||
pkexec, err := exec.LookPath("pkexec")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: pkexec is not installed", ErrUnavailable)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, pkexec, append([]string{self}, args...)...)
|
||||
// C locale so pkexec's own diagnostics are the ones noAgentMarkers knows.
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
// The one-shot reports itself on stdout for macOS's sake, where there is no
|
||||
// exit status to read. Here there is one, so that line is noise.
|
||||
cmd.Stdout = io.Discard
|
||||
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
return fmt.Errorf("run pkexec: %w", err)
|
||||
}
|
||||
|
||||
// Matched against everything pkexec said, reported as one line: a complaint
|
||||
// that is not the first thing printed still has to be recognised, and reading
|
||||
// it as a refusal would swallow it.
|
||||
full := stderr.String()
|
||||
out := firstLine(full)
|
||||
|
||||
switch exitErr.ExitCode() {
|
||||
case exitDismissed:
|
||||
return ErrDeclined
|
||||
case exitNotAuthorized:
|
||||
return notAuthorized(full, out)
|
||||
default:
|
||||
return fmt.Errorf("elevated netbird exited with %d: %s", exitErr.ExitCode(), out)
|
||||
}
|
||||
}
|
||||
|
||||
// notAuthorized sorts out the three endings pkexec reports as exitNotAuthorized.
|
||||
//
|
||||
// It also returns that code when the authorization succeeded and it then could
|
||||
// not run the program, so a refusal has to be recognised rather than assumed:
|
||||
// reading every one of these as "the user said no" would revert the control in
|
||||
// silence on a host where elevation is broken.
|
||||
func notAuthorized(full, out string) error {
|
||||
switch {
|
||||
case hasAny(full, noAgentMarkers):
|
||||
return fmt.Errorf("%w: polkit had no way to ask: %s", ErrUnavailable, out)
|
||||
case out == noOutput, strings.Contains(full, refusedMarker):
|
||||
// The user said no, which needs no message; that an account barred from
|
||||
// elevating altogether lands here too is why the reason is kept.
|
||||
return fmt.Errorf("%w: %s", ErrDeclined, out)
|
||||
default:
|
||||
return fmt.Errorf("pkexec could not run elevated netbird: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func hasAny(s string, markers []string) bool {
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(s, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mechanismAvailable() bool {
|
||||
_, err := exec.LookPath("pkexec")
|
||||
return err == nil
|
||||
}
|
||||
110
client/internal/elevate/run_unix_test.go
Normal file
110
client/internal/elevate/run_unix_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
//go:build linux
|
||||
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakePkexec puts a pkexec on PATH that exits with the given code, so the
|
||||
// mapping from polkit's exit codes onto our errors can be exercised without a
|
||||
// polkit agent.
|
||||
func fakePkexec(t *testing.T, exitCode int, stderr string) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
script := fmt.Sprintf("#!/bin/sh\necho %s >&2\nexit %d\n", shellQuote(stderr), exitCode)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "pkexec"), []byte(script), 0o700), "write the fake pkexec")
|
||||
t.Setenv("PATH", dir)
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
func TestRunMapsPkexecExitCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
exitCode int
|
||||
stderr string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "applied", exitCode: 0},
|
||||
{
|
||||
name: "dialog dismissed",
|
||||
exitCode: exitDismissed,
|
||||
stderr: "Error executing command as another user: Request dismissed",
|
||||
wantErr: ErrDeclined,
|
||||
},
|
||||
{
|
||||
// What a graphical agent reports for a cancelled prompt. Not a
|
||||
// failure: the user was asked and answered.
|
||||
name: "prompt cancelled",
|
||||
exitCode: exitNotAuthorized,
|
||||
stderr: "Error executing command as another user: Not authorized",
|
||||
wantErr: ErrDeclined,
|
||||
},
|
||||
{
|
||||
// The same status, but pkexec never got to ask anybody.
|
||||
name: "no agent and no terminal to fall back on",
|
||||
exitCode: exitNotAuthorized,
|
||||
stderr: "Error creating textual authentication agent: Error opening current controlling terminal for the process (`/dev/tty'): No such device or address",
|
||||
wantErr: ErrUnavailable,
|
||||
},
|
||||
{
|
||||
// And the same status again once the authorization succeeded and
|
||||
// pkexec could not run what it had been authorized to run. Reading
|
||||
// that as a refusal would revert the control in silence on a host
|
||||
// where elevation is broken.
|
||||
name: "authorized but not runnable",
|
||||
exitCode: exitNotAuthorized,
|
||||
stderr: "Error executing command as another user: No such file or directory",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakePkexec(t, tt.exitCode, tt.stderr)
|
||||
|
||||
err := run(context.Background(), "/nonexistent/netbird-ui", []string{"--flag"})
|
||||
switch {
|
||||
case tt.wantErr != nil:
|
||||
require.ErrorIs(t, err, tt.wantErr, "exit %d said %q", tt.exitCode, tt.stderr)
|
||||
case tt.exitCode == 0:
|
||||
require.NoError(t, err, "a pkexec that exited cleanly applied the change")
|
||||
default:
|
||||
require.Error(t, err, "exit %d said %q", tt.exitCode, tt.stderr)
|
||||
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
|
||||
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An exit code that is not polkit's is the one-shot's own failure, and has to
|
||||
// stay distinguishable from a declined prompt: the caller reports it.
|
||||
func TestRunReportsOneShotFailure(t *testing.T) {
|
||||
fakePkexec(t, 3, "the one-shot said no")
|
||||
|
||||
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
|
||||
|
||||
require.Error(t, err, "a one-shot that failed is not a prompt that was answered")
|
||||
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
|
||||
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
|
||||
}
|
||||
|
||||
func TestRunWithoutPkexecIsUnavailable(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
|
||||
require.ErrorIs(t, err, ErrUnavailable, "no pkexec means no mechanism")
|
||||
assert.False(t, mechanismAvailable(), "mechanismAvailable without pkexec on PATH")
|
||||
}
|
||||
19
client/internal/elevate/run_unsupported.go
Normal file
19
client/internal/elevate/run_unsupported.go
Normal file
@@ -0,0 +1,19 @@
|
||||
//go:build !windows && !darwin && !linux
|
||||
|
||||
package elevate
|
||||
|
||||
import "context"
|
||||
|
||||
// run reports that this platform has no elevation prompt to drive.
|
||||
//
|
||||
// The desktop app is the only caller and is not built for any of these: mobile
|
||||
// and WASM have no local user to ask, and the FreeBSD client ships without a UI.
|
||||
// pkexec would be the mechanism there, and run_unix.go is what to widen if that
|
||||
// changes.
|
||||
func run(context.Context, string, []string) error {
|
||||
return ErrUnavailable
|
||||
}
|
||||
|
||||
func mechanismAvailable() bool {
|
||||
return false
|
||||
}
|
||||
193
client/internal/elevate/run_windows.go
Normal file
193
client/internal/elevate/run_windows.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
// seeMaskNoCloseProcess keeps the started process's handle open in
|
||||
// hProcess so we can wait for it.
|
||||
seeMaskNoCloseProcess = 0x00000040
|
||||
// seeMaskNoAsync makes ShellExecuteExW finish its work before returning,
|
||||
// which it must when the calling thread does not pump messages.
|
||||
seeMaskNoAsync = 0x00000100
|
||||
// seeMaskFlagNoUI suppresses the shell's own error dialogs; the UAC consent
|
||||
// dialog is not one of them and still appears.
|
||||
seeMaskFlagNoUI = 0x00000400
|
||||
|
||||
// swHide: the one-shot has no window to show.
|
||||
swHide = 0
|
||||
|
||||
// sFalse (S_FALSE) answers CoInitializeEx when COM is already up on this
|
||||
// thread in the mode we asked for; rpcChangedMode (RPC_E_CHANGED_MODE) when
|
||||
// it is up in the other one.
|
||||
sFalse = 1
|
||||
rpcChangedMode = 0x80010106
|
||||
)
|
||||
|
||||
// shellExecuteInfoW mirrors SHELLEXECUTEINFOW. The field order and Go's own
|
||||
// padding match the C layout on both 386 and amd64.
|
||||
type shellExecuteInfoW struct {
|
||||
cbSize uint32
|
||||
fMask uint32
|
||||
hwnd windows.HWND
|
||||
lpVerb *uint16
|
||||
lpFile *uint16
|
||||
lpParameters *uint16
|
||||
lpDirectory *uint16
|
||||
nShow int32
|
||||
hInstApp windows.Handle
|
||||
lpIDList uintptr
|
||||
lpClass *uint16
|
||||
hkeyClass windows.Handle
|
||||
dwHotKey uint32
|
||||
hIconOrMonitor windows.Handle
|
||||
hProcess windows.Handle
|
||||
}
|
||||
|
||||
var (
|
||||
shell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||
procShellExecuteEx = shell32.NewProc("ShellExecuteExW")
|
||||
)
|
||||
|
||||
// run starts self elevated with the "runas" verb, which is what raises the UAC
|
||||
// consent dialog, and waits for it to finish. Windows decides whether consent is
|
||||
// enough or an administrator's credentials are needed, and collects them itself.
|
||||
func run(ctx context.Context, self string, args []string) error {
|
||||
verb, err := windows.UTF16PtrFromString("runas")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode verb: %w", err)
|
||||
}
|
||||
file, err := windows.UTF16PtrFromString(self)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", self, err)
|
||||
}
|
||||
params, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args))
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode arguments: %w", err)
|
||||
}
|
||||
|
||||
info := shellExecuteInfoW{
|
||||
fMask: seeMaskNoCloseProcess | seeMaskNoAsync | seeMaskFlagNoUI,
|
||||
hwnd: ownerWindow(),
|
||||
lpVerb: verb,
|
||||
lpFile: file,
|
||||
lpParameters: params,
|
||||
nShow: swHide,
|
||||
}
|
||||
info.cbSize = uint32(unsafe.Sizeof(info))
|
||||
|
||||
process, err := shellExecute(&info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(process); err != nil {
|
||||
log.Debugf("close elevated process handle: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return waitForProcess(ctx, process)
|
||||
}
|
||||
|
||||
// shellExecute performs the call itself. ShellExecuteExW wants COM initialised on
|
||||
// the calling thread, so the goroutine is pinned to one for the duration and COM
|
||||
// is set up on it; an "already initialised, different mode" answer is fine,
|
||||
// because then somebody else has done it for us.
|
||||
func shellExecute(info *shellExecuteInfoW) (windows.Handle, error) {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
switch err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); {
|
||||
case err == nil, isHResult(err, sFalse):
|
||||
// Ours, or already initialised in the same mode: either way this call
|
||||
// counts and has to be balanced.
|
||||
defer windows.CoUninitialize()
|
||||
case isHResult(err, rpcChangedMode):
|
||||
// The thread is already in the other apartment model. ShellExecuteExW
|
||||
// works there too, and there is nothing of ours to balance.
|
||||
default:
|
||||
return 0, fmt.Errorf("initialise COM: %w", err)
|
||||
}
|
||||
|
||||
ret, _, lastErr := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info)))
|
||||
if ret != 0 {
|
||||
return info.hProcess, nil
|
||||
}
|
||||
|
||||
if errors.Is(lastErr, windows.ERROR_CANCELLED) {
|
||||
return 0, ErrDeclined
|
||||
}
|
||||
return 0, fmt.Errorf("run elevated: %w", lastErr)
|
||||
}
|
||||
|
||||
// ownerWindow returns this process's foreground window, and 0 when the window in
|
||||
// front belongs to somebody else or cannot be attributed. ShellExecuteExW takes it
|
||||
// as the parent for the UI it raises, which is what keeps the consent dialog in
|
||||
// front of the window the user was just clicking in instead of behind it. It is
|
||||
// also what a remote-desktop session needs to place the dialog at all when the
|
||||
// secure desktop is switched off.
|
||||
func ownerWindow() windows.HWND {
|
||||
hwnd := windows.GetForegroundWindow()
|
||||
if hwnd == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var pid uint32
|
||||
if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil {
|
||||
log.Debugf("cannot attribute the foreground window, raising the prompt without an owner: %v", err)
|
||||
return 0
|
||||
}
|
||||
if pid != windows.GetCurrentProcessId() {
|
||||
return 0
|
||||
}
|
||||
return hwnd
|
||||
}
|
||||
|
||||
// isHResult reports whether err carries the given HRESULT. CoInitializeEx
|
||||
// returns its HRESULT as an Errno, so the comparison is on the raw value.
|
||||
func isHResult(err error, hresult uintptr) bool {
|
||||
var errno windows.Errno
|
||||
return errors.As(err, &errno) && uintptr(errno) == hresult
|
||||
}
|
||||
|
||||
func waitForProcess(ctx context.Context, process windows.Handle) error {
|
||||
// The wait is interruptible so a cancelled context stops us waiting on a
|
||||
// consent dialog nobody is going to answer. The elevated process is not
|
||||
// ours to kill, and it either applies the change or does not.
|
||||
for {
|
||||
event, err := windows.WaitForSingleObject(process, 250)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for the elevated process: %w", err)
|
||||
}
|
||||
if event == uint32(windows.WAIT_OBJECT_0) {
|
||||
break
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(process, &code); err != nil {
|
||||
return fmt.Errorf("read the elevated process's exit code: %w", err)
|
||||
}
|
||||
if code != 0 {
|
||||
return fmt.Errorf("elevated netbird exited with %d", code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mechanismAvailable is true on Windows: UAC prompts for consent when the user
|
||||
// is an administrator and for an administrator's credentials when they are not,
|
||||
// so there is always something to ask.
|
||||
func mechanismAvailable() bool {
|
||||
return true
|
||||
}
|
||||
40
client/internal/elevate/trusted.go
Normal file
40
client/internal/elevate/trusted.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// trustedSelf returns the path of this executable, provided it is one we are
|
||||
// willing to have run as root.
|
||||
//
|
||||
// The check is what keeps elevation from becoming a way to launder someone
|
||||
// else's code into a root process: the user consents to NetBird being elevated,
|
||||
// having been shown NetBird's name, so what runs must be the file NetBird was
|
||||
// installed as and not something a third party could have swapped for it. An
|
||||
// executable only its owner can write is that; anything wider is refused, and
|
||||
// the caller falls back to showing the command instead.
|
||||
//
|
||||
// The owner writing to their own executable is not part of that threat: code
|
||||
// running as the user can already prompt them for anything, and could just as
|
||||
// well ask them to run the command by hand. What matters is that no *other*
|
||||
// unprivileged account can reach it.
|
||||
func trustedSelf() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locate this executable: %w", err)
|
||||
}
|
||||
|
||||
// Resolve symlinks so the checks below apply to the file that would actually
|
||||
// be executed, not to a link somebody else may control.
|
||||
resolved, err := filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve %s: %w", exe, err)
|
||||
}
|
||||
|
||||
if err := checkOnlyOwnerWritable(resolved); err != nil {
|
||||
return "", fmt.Errorf("%w: %s cannot be trusted to run as root: %w", ErrUnavailable, resolved, err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
10
client/internal/elevate/trusted_group_darwin.go
Normal file
10
client/internal/elevate/trusted_group_darwin.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package elevate
|
||||
|
||||
// adminWriteGIDs are the groups whose write access to an executable does not
|
||||
// widen who could authorize elevating it.
|
||||
//
|
||||
// macOS installs applications as root:admin, mode 0775, /Applications included,
|
||||
// so requiring owner-only write would reject every normal install. Group admin
|
||||
// (gid 80) is exactly the set of accounts that can answer the authentication
|
||||
// dialog, so its write access grants nothing the prompt would not.
|
||||
var adminWriteGIDs = []uint32{0, 80}
|
||||
9
client/internal/elevate/trusted_group_unix.go
Normal file
9
client/internal/elevate/trusted_group_unix.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package elevate
|
||||
|
||||
// adminWriteGIDs are the groups whose write access to an executable does not
|
||||
// widen who could authorize elevating it. Only root's own group qualifies here:
|
||||
// a distribution installs into root-owned directories, and there is no
|
||||
// system-wide administrators group that both writes them and answers polkit.
|
||||
var adminWriteGIDs = []uint32{0}
|
||||
146
client/internal/elevate/trusted_unix.go
Normal file
146
client/internal/elevate/trusted_unix.go
Normal file
@@ -0,0 +1,146 @@
|
||||
//go:build !windows
|
||||
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// groupFile lists which accounts are in which group, for the membership a user
|
||||
// private group's name does not state: see groupHasOtherMembers.
|
||||
const groupFile = "/etc/group"
|
||||
|
||||
// checkOnlyOwnerWritable reports an error unless path, and every directory leading
|
||||
// to it, is owned by either root or this user and writable by nobody who could not
|
||||
// already act as its owner. A writable directory is as good as a writable file,
|
||||
// since anything in it can be replaced, so the whole chain is checked.
|
||||
func checkOnlyOwnerWritable(path string) error {
|
||||
self := uint32(os.Getuid())
|
||||
|
||||
for dir := path; ; dir = filepath.Dir(dir) {
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %s: %w", dir, err)
|
||||
}
|
||||
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return errors.New("file ownership is unavailable on this platform")
|
||||
}
|
||||
if stat.Uid != 0 && stat.Uid != self {
|
||||
return fmt.Errorf("%s is owned by uid %d, neither root nor this user", dir, stat.Uid)
|
||||
}
|
||||
|
||||
if err := checkWriteBits(dir, info, stat.Uid, stat.Gid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if parent := filepath.Dir(dir); parent == dir {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkWriteBits(path string, info os.FileInfo, uid, gid uint32) error {
|
||||
// On a directory the sticky bit stands in for the write bits: whoever may
|
||||
// write there still cannot replace an entry they do not own, which is the
|
||||
// only thing that would matter to us. /tmp is the usual example.
|
||||
sticky := info.IsDir() && info.Mode()&os.ModeSticky != 0
|
||||
|
||||
return writeBitsAllow(path, info.Mode().Perm(), sticky, groupWriteAllowed(uid, gid))
|
||||
}
|
||||
|
||||
// writeBitsAllow decides on the permission bits alone, given whether the group's
|
||||
// write access has been vouched for.
|
||||
func writeBitsAllow(path string, perm os.FileMode, sticky, groupAllowed bool) error {
|
||||
if sticky {
|
||||
return nil
|
||||
}
|
||||
if perm&0o020 != 0 && !groupAllowed {
|
||||
return fmt.Errorf("%s is writable by a group with members other than its owner (%v)", path, perm)
|
||||
}
|
||||
if perm&0o002 != 0 {
|
||||
return fmt.Errorf("%s is world-writable (%v)", path, perm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupWriteAllowed reports whether a group's write access to a file owned by uid
|
||||
// puts it in reach of anyone who could not already act as that owner.
|
||||
//
|
||||
// Two ways it does not. A group in adminWriteGIDs holds the accounts that can
|
||||
// answer the elevation prompt anyway. And a user private group is how Debian,
|
||||
// Ubuntu and Fedora ship: their umask of 002 makes a home directory and
|
||||
// everything built in it group-writable, so refusing that would refuse every
|
||||
// build not installed from a package.
|
||||
func groupWriteAllowed(uid, gid uint32) bool {
|
||||
if slices.Contains(adminWriteGIDs, gid) {
|
||||
return true
|
||||
}
|
||||
|
||||
group, err := user.LookupGroupId(strconv.FormatUint(uint64(gid), 10))
|
||||
if err != nil {
|
||||
log.Debugf("cannot look up group %d, treating it as shared: %v", gid, err)
|
||||
return false
|
||||
}
|
||||
owner, err := user.LookupId(strconv.FormatUint(uint64(uid), 10))
|
||||
if err != nil {
|
||||
log.Debugf("cannot look up uid %d, treating its group as shared: %v", uid, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if group.Name != owner.Username {
|
||||
return false
|
||||
}
|
||||
return !groupHasOtherMembers(groupFile, group.Name, owner.Username)
|
||||
}
|
||||
|
||||
// groupHasOtherMembers reports whether the group lists a member besides owner.
|
||||
//
|
||||
// Sharing the owner's name is what a user private group is recognised by, and it
|
||||
// says nothing about who is in it: a group that has since gained a member is
|
||||
// still named that way, and that member can write whatever the group can. So the
|
||||
// membership is read rather than assumed. A group this file does not describe,
|
||||
// because it comes from LDAP or another NSS source, cannot be answered here and
|
||||
// leaves the name as the only thing to go on.
|
||||
func groupHasOtherMembers(path, name, owner string) bool {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Debugf("cannot read %s for the members of group %q: %v", path, name, err)
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
log.Debugf("close %s: %v", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
// name:password:gid:member,member
|
||||
fields := strings.Split(scanner.Text(), ":")
|
||||
if len(fields) < 4 || fields[0] != name {
|
||||
continue
|
||||
}
|
||||
for member := range strings.SplitSeq(fields[3], ",") {
|
||||
if member != "" && member != owner {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Debugf("read %s: %v", path, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
177
client/internal/elevate/trusted_unix_test.go
Normal file
177
client/internal/elevate/trusted_unix_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
//go:build !windows
|
||||
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its
|
||||
// numbered directory with 0777 minus the umask, so under the common 002 umask it
|
||||
// is group-writable and would fail the check under test on its own.
|
||||
func ownerOnlyDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory")
|
||||
return dir
|
||||
}
|
||||
|
||||
// writeExecutable creates a plain executable file, the shape trustedSelf checks.
|
||||
func writeExecutable(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "netbird-ui")
|
||||
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable")
|
||||
require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode")
|
||||
return path
|
||||
}
|
||||
|
||||
func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) {
|
||||
err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t)))
|
||||
assert.NoError(t, err, "an owner-only writable executable is trustworthy")
|
||||
}
|
||||
|
||||
func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) {
|
||||
path := writeExecutable(t, ownerOnlyDir(t))
|
||||
require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable")
|
||||
|
||||
assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused")
|
||||
}
|
||||
|
||||
// The permission policy on its own, without a filesystem to arrange: whether the
|
||||
// group has been vouched for is the only thing that makes group write acceptable.
|
||||
func TestWriteBitsAllow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
perm os.FileMode
|
||||
sticky bool
|
||||
groupAllowed bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "owner only", perm: 0o755},
|
||||
{name: "group write in a private group", perm: 0o775, groupAllowed: true},
|
||||
{name: "group write in a shared group", perm: 0o775, wantErr: true},
|
||||
{name: "world write", perm: 0o777, groupAllowed: true, wantErr: true},
|
||||
{name: "world write on a sticky directory", perm: 0o777, sticky: true},
|
||||
{name: "group write on a sticky directory", perm: 0o775, sticky: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A build under a home directory on a distribution with a 002 umask, which is what
|
||||
// a locally built or tarball-installed binary looks like. Its group has no members
|
||||
// but its owner, so it is as good as owner-only.
|
||||
//
|
||||
// Whether this host is such a distribution is read from the environment rather than
|
||||
// from groupWriteAllowed: asking the function under test whether to run would let
|
||||
// it skip its own coverage away if it regressed to refusing everything.
|
||||
func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) {
|
||||
requirePrivatePrimaryGroup(t)
|
||||
|
||||
dir := ownerOnlyDir(t)
|
||||
path := writeExecutable(t, dir)
|
||||
require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable")
|
||||
require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable")
|
||||
|
||||
err := checkOnlyOwnerWritable(path)
|
||||
assert.NoError(t, err, "group write in the owner's own private group reaches nobody else")
|
||||
}
|
||||
|
||||
// A group that shares its owner's name but has gained another member is no longer
|
||||
// private, and its write access reaches an account that could not elevate.
|
||||
func TestGroupHasOtherMembers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry string
|
||||
want bool
|
||||
}{
|
||||
{name: "no members", entry: "vma:x:1000:"},
|
||||
{name: "only the owner", entry: "vma:x:1000:vma"},
|
||||
{name: "another member", entry: "vma:x:1000:bob", want: true},
|
||||
{name: "the owner and another", entry: "vma:x:1000:vma,bob", want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "group")
|
||||
body := "root:x:0:\n" + tt.entry + "\nsudo:x:27:vma\n"
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0o644), "write the group file")
|
||||
|
||||
assert.Equal(t, tt.want, groupHasOtherMembers(path, "vma", "vma"), "entry %q", tt.entry)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A group file that says nothing about the group leaves the name as the only thing
|
||||
// to go on, so the private-group allowance stands rather than collapsing on every
|
||||
// host whose groups come from LDAP.
|
||||
func TestGroupHasOtherMembersTolerantOfAnUnknownGroup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "group")
|
||||
require.NoError(t, os.WriteFile(path, []byte("root:x:0:\n"), 0o644), "write the group file")
|
||||
|
||||
assert.False(t, groupHasOtherMembers(path, "vma", "vma"), "a group the file does not describe")
|
||||
assert.False(t, groupHasOtherMembers(filepath.Join(t.TempDir(), "absent"), "vma", "vma"),
|
||||
"no group file at all")
|
||||
}
|
||||
|
||||
// A writable directory is as good as a writable file: whoever can write the
|
||||
// directory can put a different binary at the same path.
|
||||
func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) {
|
||||
dir := filepath.Join(ownerOnlyDir(t), "bin")
|
||||
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
|
||||
path := writeExecutable(t, dir)
|
||||
require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable")
|
||||
|
||||
assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused")
|
||||
}
|
||||
|
||||
// A sticky world-writable directory is exempt: the sticky bit is what stops one
|
||||
// user replacing another's entries. /tmp is why this matters.
|
||||
func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) {
|
||||
dir := filepath.Join(ownerOnlyDir(t), "sticky")
|
||||
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
|
||||
path := writeExecutable(t, dir)
|
||||
require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable")
|
||||
|
||||
err := checkOnlyOwnerWritable(path)
|
||||
assert.NoError(t, err, "the sticky bit stops another user replacing the executable")
|
||||
}
|
||||
|
||||
func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) {
|
||||
err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent"))
|
||||
assert.Error(t, err, "an executable that is not there must be refused")
|
||||
}
|
||||
|
||||
// requirePrivatePrimaryGroup skips unless this user's primary group is their own,
|
||||
// which is what the user-private-group allowance is about.
|
||||
func requirePrivatePrimaryGroup(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
self, err := user.Current()
|
||||
require.NoError(t, err, "look up the test user")
|
||||
group, err := user.LookupGroupId(strconv.Itoa(os.Getgid()))
|
||||
require.NoError(t, err, "look up the test user's primary group")
|
||||
|
||||
if group.Name != self.Username {
|
||||
t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name)
|
||||
}
|
||||
if groupHasOtherMembers(groupFile, group.Name, self.Username) {
|
||||
t.Skipf("group %q has other members, so it is not a private group", group.Name)
|
||||
}
|
||||
}
|
||||
215
client/internal/elevate/trusted_windows.go
Normal file
215
client/internal/elevate/trusted_windows.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
// fileDeleteChild is FILE_DELETE_CHILD, which x/sys does not define: the
|
||||
// right to delete an entry of a directory without holding DELETE on it.
|
||||
fileDeleteChild = 0x00000040
|
||||
|
||||
// accessAllowedCallbackACEType is an allow ACE with a condition appended to
|
||||
// the ACCESS_ALLOWED_ACE layout, so its trustee is still at SidStart.
|
||||
accessAllowedCallbackACEType = 0x9
|
||||
|
||||
// The allow ACE types that carry object GUIDs ahead of the trustee, so the
|
||||
// SID is not at SidStart. They occur on directory-service objects rather
|
||||
// than files, and are refused rather than skipped: see aceTrustee.
|
||||
accessAllowedObjectACEType = 0x5
|
||||
accessAllowedCallbackObjectACEType = 0xB
|
||||
)
|
||||
|
||||
// fileWriteAccess are the rights that let a trustee rewrite or replace a file,
|
||||
// or take it over and then do so.
|
||||
const fileWriteAccess = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA |
|
||||
windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER |
|
||||
windows.GENERIC_WRITE | windows.GENERIC_ALL
|
||||
|
||||
// dirWriteAccess are the rights over a directory that let a trustee replace an
|
||||
// entry somebody else owns. Creating a new entry is not one of them, which is
|
||||
// what the Unix sticky bit says in one bit: the root of every volume grants
|
||||
// BUILTIN\Users the right to add directories under it, and that reaches nothing
|
||||
// already there.
|
||||
const dirWriteAccess = fileDeleteChild | windows.DELETE |
|
||||
windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_ALL
|
||||
|
||||
// trustedInstallerSID owns much of what Windows itself installs. x/sys has no
|
||||
// well-known constant for it.
|
||||
const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
|
||||
|
||||
// checkOnlyOwnerWritable reports an error unless path, and every directory
|
||||
// leading to it, is owned by an account that can elevate (or by this user) and
|
||||
// grants write access to nobody else. A writable directory is as good as a
|
||||
// writable file, since an entry in it can be replaced, so the whole chain is
|
||||
// checked.
|
||||
func checkOnlyOwnerWritable(path string) error {
|
||||
owners, err := trustedOwners()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writers, err := trustedWriters(owners)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writeAccess := windows.ACCESS_MASK(fileWriteAccess)
|
||||
for target := path; ; target = filepath.Dir(target) {
|
||||
if err := checkSecurity(target, writeAccess, owners, writers); err != nil {
|
||||
return err
|
||||
}
|
||||
if parent := filepath.Dir(target); parent == target {
|
||||
return nil
|
||||
}
|
||||
writeAccess = dirWriteAccess
|
||||
}
|
||||
}
|
||||
|
||||
// trustedOwners are the accounts we accept as the owner of the executable and of
|
||||
// the directories above it: the ones that can already answer the UAC prompt,
|
||||
// plus this user, whose own executable is theirs to write. Code running as the
|
||||
// user could prompt them for anything anyway; what matters is that no *other*
|
||||
// unprivileged account can reach it.
|
||||
func trustedOwners() ([]*windows.SID, error) {
|
||||
self, err := currentUserSID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
owners := []*windows.SID{self}
|
||||
for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{
|
||||
windows.WinLocalSystemSid,
|
||||
windows.WinBuiltinAdministratorsSid,
|
||||
} {
|
||||
sid, err := windows.CreateWellKnownSid(wellKnown)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build well-known SID %d: %w", wellKnown, err)
|
||||
}
|
||||
owners = append(owners, sid)
|
||||
}
|
||||
|
||||
installer, err := windows.StringToSid(trustedInstallerSID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse TrustedInstaller SID: %w", err)
|
||||
}
|
||||
return append(owners, installer), nil
|
||||
}
|
||||
|
||||
// trustedWriters are the trustees whose write access does not widen who could
|
||||
// decide what runs behind the prompt. The owners, and CREATOR OWNER, which
|
||||
// resolves to the object's owner and is therefore already vetted.
|
||||
func trustedWriters(owners []*windows.SID) ([]*windows.SID, error) {
|
||||
creatorOwner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build the CREATOR OWNER SID: %w", err)
|
||||
}
|
||||
return append(slices.Clone(owners), creatorOwner), nil
|
||||
}
|
||||
|
||||
func checkSecurity(path string, writeAccess windows.ACCESS_MASK, owners, writers []*windows.SID) error {
|
||||
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read security descriptor of %s: %w", path, err)
|
||||
}
|
||||
|
||||
owner, _, err := sd.Owner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read owner of %s: %w", path, err)
|
||||
}
|
||||
if !containsSID(owners, owner) {
|
||||
return fmt.Errorf("%s is owned by %s, which is neither this user nor an account that can elevate", path, owner)
|
||||
}
|
||||
|
||||
dacl, _, err := sd.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read DACL of %s: %w", path, err)
|
||||
}
|
||||
// A NULL DACL grants everyone everything; only an absent security
|
||||
// descriptor would have got us here without one, and neither is trustworthy.
|
||||
if dacl == nil {
|
||||
return fmt.Errorf("%s has no DACL, so it grants write access to everyone", path)
|
||||
}
|
||||
|
||||
return checkDACL(path, dacl, writeAccess, writers)
|
||||
}
|
||||
|
||||
// checkDACL refuses an ACL that grants write access to a trustee outside
|
||||
// writers.
|
||||
//
|
||||
// An allowlist, because the trustees that must not have it cannot be listed: an
|
||||
// ACE naming an ordinary user account hands that account the same power as one
|
||||
// naming Everyone, and only the accounts that may hold it are knowable.
|
||||
func checkDACL(path string, dacl *windows.ACL, writeAccess windows.ACCESS_MASK, writers []*windows.SID) error {
|
||||
for i := uint32(0); i < uint32(dacl.AceCount); i++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, i, &ace); err != nil {
|
||||
return fmt.Errorf("read ACE %d of %s: %w", i, path, err)
|
||||
}
|
||||
// An inherit-only ACE says what children of this object get, not what
|
||||
// this object grants.
|
||||
if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
|
||||
continue
|
||||
}
|
||||
if ace.Mask&writeAccess == 0 {
|
||||
continue
|
||||
}
|
||||
// Only an allow ACE grants anything; a deny ACE narrows what one gave.
|
||||
if !isAllowACE(ace.Header.AceType) {
|
||||
continue
|
||||
}
|
||||
|
||||
trustee, err := aceTrustee(ace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read the trustee of ACE %d of %s: %w", i, path, err)
|
||||
}
|
||||
if !containsSID(writers, trustee) {
|
||||
return fmt.Errorf("%s grants write access to %s", path, trustee)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAllowACE reports whether an ACE type grants rights, rather than denying,
|
||||
// auditing or labelling them.
|
||||
func isAllowACE(aceType uint8) bool {
|
||||
switch aceType {
|
||||
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType,
|
||||
accessAllowedObjectACEType, accessAllowedCallbackObjectACEType:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// aceTrustee returns who an allow ACE grants its rights to. An ACE whose trustee
|
||||
// cannot be located is an error rather than something to skip past: being unable
|
||||
// to read who is being given write access is a refusal.
|
||||
func aceTrustee(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) {
|
||||
switch ace.Header.AceType {
|
||||
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType:
|
||||
//nolint:gosec // SidStart is the first uint32 of the variable-length SID that follows the ACE header.
|
||||
return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), nil
|
||||
default:
|
||||
return nil, errors.New("an object-type allow ACE does not carry its trustee where we can read it")
|
||||
}
|
||||
}
|
||||
|
||||
func containsSID(sids []*windows.SID, sid *windows.SID) bool {
|
||||
return slices.ContainsFunc(sids, sid.Equals)
|
||||
}
|
||||
|
||||
func currentUserSID() (*windows.SID, error) {
|
||||
token := windows.GetCurrentProcessToken()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read this process's user: %w", err)
|
||||
}
|
||||
return user.User.Sid, nil
|
||||
}
|
||||
126
client/internal/elevate/trusted_windows_test.go
Normal file
126
client/internal/elevate/trusted_windows_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// A file the test user created under their own profile, which is what a per-user
|
||||
// install looks like. The whole chain up to the volume root is walked, so this is
|
||||
// also what says the walk does not refuse an ordinary Windows installation: the
|
||||
// root of every volume grants BUILTIN\Users rights that are not ours to worry
|
||||
// about.
|
||||
func TestCheckOnlyOwnerWritableAcceptsOwnFile(t *testing.T) {
|
||||
err := checkOnlyOwnerWritable(writeExecutable(t))
|
||||
assert.NoError(t, err, "a file the test user owns, under directories only administrators can write")
|
||||
}
|
||||
|
||||
// Write access held by an account that cannot answer the UAC prompt means that
|
||||
// account decides what runs behind it, whoever the ACE names. The trustees that
|
||||
// must not have it cannot be listed, so the check names the ones that may.
|
||||
func TestCheckOnlyOwnerWritableRejectsUntrustedWriters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wellKnown windows.WELL_KNOWN_SID_TYPE
|
||||
}{
|
||||
{name: "everyone", wellKnown: windows.WinWorldSid},
|
||||
{name: "authenticated users", wellKnown: windows.WinAuthenticatedUserSid},
|
||||
{name: "builtin users", wellKnown: windows.WinBuiltinUsersSid},
|
||||
// A service account, which no denylist of the obvious groups would name
|
||||
// and which cannot elevate any more than Everyone can.
|
||||
{name: "local service", wellKnown: windows.WinLocalServiceSid},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeExecutable(t)
|
||||
grantWrite(t, path, tt.wellKnown)
|
||||
|
||||
assert.Error(t, checkOnlyOwnerWritable(path),
|
||||
"write access for %s must be refused", tt.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The masks are the policy: on a file any write reaches its contents, while on a
|
||||
// directory only deleting or taking over an entry reaches something already
|
||||
// there. Adding an entry does not, which is why the walk survives a volume root.
|
||||
func TestWriteAccessMasks(t *testing.T) {
|
||||
assert.NotZero(t, fileWriteAccess&windows.FILE_WRITE_DATA, "writing a file's data reaches its contents")
|
||||
assert.NotZero(t, fileWriteAccess&windows.FILE_APPEND_DATA, "appending to a file reaches its contents")
|
||||
|
||||
assert.Zero(t, dirWriteAccess&windows.FILE_WRITE_DATA, "adding a file to a directory replaces nothing")
|
||||
assert.Zero(t, dirWriteAccess&windows.FILE_APPEND_DATA, "adding a subdirectory replaces nothing")
|
||||
assert.NotZero(t, dirWriteAccess&fileDeleteChild, "deleting an entry replaces it")
|
||||
assert.NotZero(t, dirWriteAccess&windows.DELETE, "deleting the directory takes its entries with it")
|
||||
}
|
||||
|
||||
func TestIsAllowACE(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
aceType uint8
|
||||
want bool
|
||||
}{
|
||||
{name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, want: true},
|
||||
{name: "allowed callback", aceType: accessAllowedCallbackACEType, want: true},
|
||||
{name: "allowed object", aceType: accessAllowedObjectACEType, want: true},
|
||||
{name: "allowed callback object", aceType: accessAllowedCallbackObjectACEType, want: true},
|
||||
{name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE},
|
||||
// SYSTEM_AUDIT_ACE_TYPE, which x/sys does not define: an ACE that records
|
||||
// access rather than granting it.
|
||||
{name: "audit", aceType: 0x2},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, isAllowACE(tt.aceType), "ACE type %#x", tt.aceType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeExecutable creates a plain file under the test's own directory, the shape
|
||||
// trustedSelf checks.
|
||||
func writeExecutable(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "netbird-ui.exe")
|
||||
require.NoError(t, os.WriteFile(path, []byte("MZ"), 0o755), "write the executable")
|
||||
return path
|
||||
}
|
||||
|
||||
// grantWrite replaces the file's DACL with one that grants a well-known trustee
|
||||
// everything, keeping the test user's own access so the file stays deletable.
|
||||
func grantWrite(t *testing.T, path string, wellKnown windows.WELL_KNOWN_SID_TYPE) {
|
||||
t.Helper()
|
||||
|
||||
trustee, err := windows.CreateWellKnownSid(wellKnown)
|
||||
require.NoError(t, err, "build the trustee SID")
|
||||
self, err := currentUserSID()
|
||||
require.NoError(t, err, "read the test user's SID")
|
||||
|
||||
acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{
|
||||
fullControl(self, windows.TRUSTEE_IS_USER),
|
||||
fullControl(trustee, windows.TRUSTEE_IS_WELL_KNOWN_GROUP),
|
||||
}, nil)
|
||||
require.NoError(t, err, "build the ACL")
|
||||
|
||||
require.NoError(t, windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil, nil, acl, nil), "set the DACL")
|
||||
}
|
||||
|
||||
func fullControl(sid *windows.SID, trusteeType uint32) windows.EXPLICIT_ACCESS {
|
||||
return windows.EXPLICIT_ACCESS{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_TYPE(trusteeType),
|
||||
TrusteeValue: windows.TrusteeValueFromSID(sid),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/firewall"
|
||||
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||
@@ -59,7 +58,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/syncstore"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/jobexec"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
@@ -182,9 +180,6 @@ type EngineServices struct {
|
||||
UpdateManager *updater.Manager
|
||||
ClientMetrics *metrics.ClientMetrics
|
||||
MetricsCtx context.Context
|
||||
// NetState gates the reconnection loops on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
NetState *netstate.State
|
||||
}
|
||||
|
||||
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
||||
@@ -208,10 +203,6 @@ type Engine struct {
|
||||
config *EngineConfig
|
||||
mobileDep MobileDependency
|
||||
|
||||
// netState gates the peer reconnection guards on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
netState *netstate.State
|
||||
|
||||
// STUNs is a list of STUN servers used by ICE
|
||||
STUNs []*stun.URI
|
||||
// TURNs is a list of STUN servers used by ICE
|
||||
@@ -345,7 +336,6 @@ func NewEngine(
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
config: config,
|
||||
mobileDep: mobileDep,
|
||||
netState: services.NetState,
|
||||
STUNs: []*stun.URI{},
|
||||
TURNs: []*stun.URI{},
|
||||
networkSerial: 0,
|
||||
@@ -1395,7 +1385,6 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
|
||||
bundleJobParams := debug.BundleConfig{
|
||||
Anonymize: params.Anonymize,
|
||||
AnonymizeLevel: anonymize.ParseLevel(params.AnonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
LogFileCount: uint32(params.LogFileCount),
|
||||
}
|
||||
@@ -1902,8 +1891,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
||||
Addr: e.getRosenpassAddr(),
|
||||
PermissiveMode: e.config.RosenpassPermissive,
|
||||
},
|
||||
ICEConfig: e.createICEConfig(),
|
||||
NetworkState: e.netState,
|
||||
ICEConfig: e.createICEConfig(),
|
||||
}
|
||||
|
||||
serviceDependencies := peer.ServiceDependencies{
|
||||
|
||||
@@ -91,6 +91,12 @@ func SelfDelegatesTo() (Identity, bool) {
|
||||
return selfIdentity, true
|
||||
}
|
||||
|
||||
// The values PrivilegedActorKey returns.
|
||||
const (
|
||||
ActorKeyAdministrator = "administrator"
|
||||
ActorKeyRoot = "root"
|
||||
)
|
||||
|
||||
// PrivilegedActor names the principal a privileged operation requires, for use
|
||||
// in messages shown to the user.
|
||||
func PrivilegedActor() string {
|
||||
@@ -100,6 +106,16 @@ func PrivilegedActor() string {
|
||||
return "root"
|
||||
}
|
||||
|
||||
// PrivilegedActorKey identifies that principal without wording it, for a client
|
||||
// that writes its own message in the user's language. The words PrivilegedActor
|
||||
// returns are English, and a translated sentence cannot borrow them.
|
||||
func PrivilegedActorKey() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return ActorKeyAdministrator
|
||||
}
|
||||
return ActorKeyRoot
|
||||
}
|
||||
|
||||
// ElevatedCommand renders a command so that running it grants the privileges the
|
||||
// operation needs. Windows has no in-line equivalent of sudo, so the command is
|
||||
// returned unchanged and the user is expected to run it from an elevated
|
||||
|
||||
@@ -4,17 +4,11 @@ package metrics
|
||||
type ConnectionType string
|
||||
|
||||
const (
|
||||
// ConnectionTypeICEP2P represents a direct peer-to-peer connection using ICE
|
||||
ConnectionTypeICEP2P ConnectionType = "ice_p2p"
|
||||
|
||||
// ConnectionTypeICETurn represents an ICE connection through a TURN server
|
||||
ConnectionTypeICETurn ConnectionType = "ice_turn"
|
||||
// ConnectionTypeICE represents a direct peer-to-peer connection using ICE
|
||||
ConnectionTypeICE ConnectionType = "ice"
|
||||
|
||||
// ConnectionTypeRelay represents a relayed connection
|
||||
ConnectionTypeRelay ConnectionType = "relay"
|
||||
|
||||
// ConnectionTypeUnknown represents a connection with no active transport. It is not pushed.
|
||||
ConnectionTypeUnknown ConnectionType = "unknown"
|
||||
)
|
||||
|
||||
// String returns the string representation of the connection type
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestInfluxDBMetrics_RecordAndExport(t *testing.T) {
|
||||
WgHandshakeSuccess: time.Now().Add(-1 * time.Second),
|
||||
}
|
||||
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts)
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := m.Export(&buf)
|
||||
@@ -60,7 +60,7 @@ func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) {
|
||||
|
||||
// Record multiple times and verify consistent field order
|
||||
for i := 0; i < 10; i++ {
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts)
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -56,33 +56,14 @@ Measurement: `netbird_peer_connection`
|
||||
|
||||
Tags:
|
||||
- `deployment_type`: "cloud" | "selfhosted" | "unknown"
|
||||
- `connection_type`: "ice_p2p" | "ice_turn" | "relay" (see below)
|
||||
- `connection_type`: "ice" | "relay"
|
||||
- `attempt_type`: "initial" | "reconnection"
|
||||
- `version`: NetBird version string
|
||||
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
|
||||
- `arch`: CPU architecture (amd64, arm64, etc.)
|
||||
- `peer_id`: anonymised peer identifier (truncated SHA-256 of the WireGuard public key)
|
||||
- `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides
|
||||
|
||||
**Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online.
|
||||
|
||||
#### `connection_type` values
|
||||
|
||||
Derived from the connection priority (`conntype.ConnPriority`) by `metricsConnType` in `client/internal/peer/conn.go`:
|
||||
|
||||
| Value | Priority | Traffic is |
|
||||
|-------|----------|------------|
|
||||
| `ice_p2p` | `ICEP2P` | direct peer-to-peer |
|
||||
| `ice_turn` | `ICETurn` | relayed, through a TURN server |
|
||||
| `relay` | `Relay` | relayed, through a NetBird relay |
|
||||
| `unknown` | `None` or unrecognised | no active transport — **the sample is not pushed** |
|
||||
|
||||
**Direct traffic is `ice_p2p` only.** `ice_turn` is relayed despite being negotiated by ICE, matching `Conn.isRelayed`.
|
||||
|
||||
`None` means no transport is active: not established yet, or reset after a relay drop or a peer-state reset. Such a sample cannot be attributed to a transport, so `recordConnectionMetrics` drops it instead of pushing it — `unknown` therefore never appears in the bucket. Connection counts are counts of connections whose transport was known at sampling time.
|
||||
|
||||
**Samples recorded before 0.77 used a single `ice` value** which covered `ICEP2P`, `ICETurn` *and* `None`, so historical `ice` samples overstate direct connections by an unknown amount and must not be compared with `ice_p2p`.
|
||||
|
||||
### Sync Duration
|
||||
|
||||
Measurement: `netbird_sync`
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
)
|
||||
@@ -94,10 +93,6 @@ type ConnConfig struct {
|
||||
|
||||
// ICEConfig ICE protocol configuration
|
||||
ICEConfig icemaker.Config
|
||||
|
||||
// NetworkState gates the reconnection guard on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
NetworkState *netstate.State
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
@@ -259,7 +254,7 @@ func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
|
||||
conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer)
|
||||
}
|
||||
|
||||
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetworkState)
|
||||
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher)
|
||||
|
||||
conn.wg.Add(1)
|
||||
go func() {
|
||||
@@ -312,8 +307,6 @@ func (conn *Conn) Close(signalToRemote bool) {
|
||||
|
||||
if conn.wgWatcherCancel != nil {
|
||||
conn.wgWatcherCancel()
|
||||
conn.wgWatcher = nil
|
||||
conn.wgWatcherCancel = nil
|
||||
}
|
||||
conn.workerRelay.CloseConn()
|
||||
if conn.workerICE != nil {
|
||||
@@ -966,9 +959,12 @@ func (conn *Conn) recordConnectionMetrics() {
|
||||
priority := conn.currentConnPriority
|
||||
conn.mu.Unlock()
|
||||
|
||||
connType := metricsConnType(priority)
|
||||
if connType == metrics.ConnectionTypeUnknown {
|
||||
return
|
||||
var connType metrics.ConnectionType
|
||||
switch priority {
|
||||
case conntype.Relay:
|
||||
connType = metrics.ConnectionTypeRelay
|
||||
default:
|
||||
connType = metrics.ConnectionTypeICE
|
||||
}
|
||||
|
||||
// Record metrics with timestamps - duration calculation happens in metrics package
|
||||
@@ -1069,16 +1065,3 @@ func boolToConnStatus(connected bool) guard.ConnStatus {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/conntype"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/dispatcher"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/guard"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
@@ -388,33 +386,3 @@ func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
// ConnStatus represents the connection state as seen by the guard.
|
||||
@@ -33,26 +31,20 @@ type connStatusFunc func() ConnStatus
|
||||
// - Relayed connection disconnected
|
||||
// - ICE candidate changes
|
||||
type Guard struct {
|
||||
log *log.Entry
|
||||
isConnectedOnAllWay connStatusFunc
|
||||
timeout time.Duration
|
||||
srWatcher *SRWatcher
|
||||
// netState gates reconnect attempts on OS-reported network availability;
|
||||
// nil disables gating.
|
||||
netState *netstate.State
|
||||
log *log.Entry
|
||||
isConnectedOnAllWay connStatusFunc
|
||||
timeout time.Duration
|
||||
srWatcher *SRWatcher
|
||||
relayedConnDisconnected chan struct{}
|
||||
iCEConnDisconnected chan struct{}
|
||||
}
|
||||
|
||||
// NewGuard creates a reconnection guard for a peer connection. A nil netState
|
||||
// disables network availability gating.
|
||||
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netState *netstate.State) *Guard {
|
||||
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard {
|
||||
return &Guard{
|
||||
log: log,
|
||||
isConnectedOnAllWay: isConnectedFn,
|
||||
timeout: timeout,
|
||||
srWatcher: srWatcher,
|
||||
netState: netState,
|
||||
relayedConnDisconnected: make(chan struct{}, 1),
|
||||
iCEConnDisconnected: make(chan struct{}, 1),
|
||||
}
|
||||
@@ -104,16 +96,9 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
|
||||
iceState := &iceRetryState{log: g.log}
|
||||
defer iceState.reset()
|
||||
|
||||
netChanged := g.netState.Changed()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tickerChannel:
|
||||
// skip attempts while the OS reports no usable network; the
|
||||
// netChanged case below resumes the loop once it returns
|
||||
if !g.netState.IsOnline() {
|
||||
continue
|
||||
}
|
||||
switch g.isConnectedOnAllWay() {
|
||||
case ConnStatusConnected:
|
||||
// all good, nothing to do
|
||||
@@ -150,23 +135,6 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
|
||||
tickerChannel = ticker.C
|
||||
iceState.reset()
|
||||
|
||||
case <-netChanged:
|
||||
// Re-arm for the next transition before acting on this one.
|
||||
netChanged = g.netState.Changed()
|
||||
if !g.netState.IsOnline() {
|
||||
continue
|
||||
}
|
||||
// Ticks skipped while offline drove the backoff towards its
|
||||
// maximum without ever attempting, and left the ICE budget
|
||||
// frozen — possibly in hourly mode. Recover on our own so the
|
||||
// peer does not depend on a signal or relay event that never
|
||||
// comes when both stayed up across the outage.
|
||||
g.log.Debugf("network is back, reset reconnection ticker")
|
||||
ticker.Stop()
|
||||
ticker = g.newReconnectTicker(ctx)
|
||||
tickerChannel = ticker.C
|
||||
iceState.reset()
|
||||
|
||||
case <-ctx.Done():
|
||||
g.log.Debugf("context is done, stop reconnect loop")
|
||||
return
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
func newTestGuard(status connStatusFunc) *Guard {
|
||||
srw := NewSRWatcher(nil, nil, nil, ice.Config{})
|
||||
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw, nil)
|
||||
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw)
|
||||
}
|
||||
|
||||
// countBackoffTickerGoroutines returns how many goroutines are currently sitting
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package guard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
// newTestGuardWithNetState builds a guard with a realistic MaxInterval: the
|
||||
// backoff must be able to grow well past the outage, as it does in production
|
||||
// where the timeout is seconds to minutes.
|
||||
func newTestGuardWithNetState(status connStatusFunc, netState *netstate.State) *Guard {
|
||||
srw := NewSRWatcher(nil, nil, nil, ice.Config{})
|
||||
return NewGuard(log.WithField("test", "guard"), status, 30*time.Second, srw, netState)
|
||||
}
|
||||
|
||||
// TestGuard_RecoversAfterOfflineToOnline covers a peer that stays disconnected
|
||||
// across a network outage while neither signal nor relay reports an event —
|
||||
// both stayed up, as on a short airplane mode toggle over Wi-Fi.
|
||||
//
|
||||
// Every tick taken while offline is skipped, but it still advances the
|
||||
// exponential backoff, so by the time the network returns the next tick can be
|
||||
// tens of seconds away. Without an explicit reaction to the transition the
|
||||
// peer waits out that interval for a recovery that could start immediately.
|
||||
func TestGuard_RecoversAfterOfflineToOnline(t *testing.T) {
|
||||
netState := netstate.New()
|
||||
|
||||
var attempts atomic.Int32
|
||||
g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// Start from the reconnect ticker (800ms initial interval), the state a
|
||||
// peer is in after it loses its connection.
|
||||
go g.Start(ctx, func() { attempts.Add(1) })
|
||||
g.SetRelayedConnDisconnected()
|
||||
|
||||
// Let the backoff climb: 0.8s, 1.6s, 3.2s, 6.4s ... every tick is skipped
|
||||
// while offline, but each one doubles the wait for the next.
|
||||
netState.Set(false)
|
||||
time.Sleep(8 * time.Second)
|
||||
|
||||
offlineAttempts := attempts.Load()
|
||||
if offlineAttempts != 0 {
|
||||
t.Fatalf("callback ran %d times while offline, want 0", offlineAttempts)
|
||||
}
|
||||
|
||||
netState.Set(true)
|
||||
|
||||
// The next organic tick is now several seconds out, so anything within
|
||||
// this window can only come from reacting to the transition itself.
|
||||
pollCtx, stopPolling := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer stopPolling()
|
||||
|
||||
select {
|
||||
case <-pollCtx.Done():
|
||||
t.Fatal("peer was not retried within 2s of the network coming back, " +
|
||||
"with neither a signal nor a relay event to fall back on")
|
||||
case <-pollUntil(pollCtx, func() bool { return attempts.Load() > 0 }):
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuard_OfflineTransitionDoesNotRetry checks the other direction: going
|
||||
// offline must not itself trigger an attempt.
|
||||
func TestGuard_OfflineTransitionDoesNotRetry(t *testing.T) {
|
||||
netState := netstate.New()
|
||||
|
||||
var attempts atomic.Int32
|
||||
g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go g.Start(ctx, func() { attempts.Add(1) })
|
||||
|
||||
netState.Set(false)
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
if got := attempts.Load(); got != 0 {
|
||||
t.Fatalf("callback ran %d times after going offline, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// pollUntil closes the returned channel once cond holds. It gives up when ctx
|
||||
// is done, so the polling goroutine never outlives the test that started it.
|
||||
func pollUntil(ctx context.Context, cond func() bool) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
if cond() {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
@@ -1,40 +1,11 @@
|
||||
package peer
|
||||
|
||||
// ClientState identifies the client connection state delivered via
|
||||
// Listener.OnStateChanged.
|
||||
type ClientState int
|
||||
|
||||
// Client states. The numeric values cross the gomobile boundary (the mobile
|
||||
// bindings re-export them as integer constants), so they are a wire format:
|
||||
// append new states at the end, never reorder or insert.
|
||||
const (
|
||||
ClientStateDisconnected ClientState = iota
|
||||
ClientStateConnected
|
||||
ClientStateConnecting
|
||||
ClientStateDisconnecting
|
||||
// ClientStateNoNetwork is an overlay state: it is never stored as the
|
||||
// last notification, only derived from ClientStateConnecting while the
|
||||
// OS reports no usable network (see notifier.effectiveState).
|
||||
ClientStateNoNetwork
|
||||
)
|
||||
|
||||
// Listener is a callback type about the NetBird network connection state
|
||||
type Listener interface {
|
||||
// OnStateChanged reports every client state transition. New states are
|
||||
// delivered only through this callback; the per-state callbacks below
|
||||
// are kept for compatibility and will be removed once all consumers
|
||||
// have migrated.
|
||||
OnStateChanged(state ClientState)
|
||||
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnConnected()
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnDisconnected()
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnConnecting()
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnDisconnecting()
|
||||
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
@@ -4,64 +4,31 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
stateDisconnected = iota
|
||||
stateConnected
|
||||
stateConnecting
|
||||
stateDisconnecting
|
||||
)
|
||||
|
||||
type notifier struct {
|
||||
// publishLock orders state publication: it is held across computing the
|
||||
// effective state and handing it to the listener, so a transition cannot
|
||||
// overtake a newer one and leave the listener on a stale state.
|
||||
publishLock sync.Mutex
|
||||
serverStateLock sync.Mutex
|
||||
listenersLock sync.Mutex
|
||||
listener Listener
|
||||
currentClientState bool
|
||||
lastNotification ClientState
|
||||
lastNotification int
|
||||
lastNumberOfPeers int
|
||||
lastFqdnAddress string
|
||||
lastIPAddress string
|
||||
networkAvailable bool
|
||||
}
|
||||
|
||||
func newNotifier() *notifier {
|
||||
return ¬ifier{
|
||||
networkAvailable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveState maps the computed state to what listeners should see:
|
||||
// while the OS reports no usable network, "Connecting" would be a lie —
|
||||
// connection attempts are suspended — so it is reported as NoNetwork.
|
||||
// Caller must hold serverStateLock.
|
||||
func (n *notifier) effectiveState(state ClientState) ClientState {
|
||||
if !n.networkAvailable && state == ClientStateConnecting {
|
||||
return ClientStateNoNetwork
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// setNetworkAvailable records the OS network availability and re-notifies
|
||||
// the listener when the flag flips the effective state (Connecting <->
|
||||
// NoNetwork).
|
||||
func (n *notifier) setNetworkAvailable(available bool) {
|
||||
n.publishLock.Lock()
|
||||
defer n.publishLock.Unlock()
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
if n.networkAvailable == available {
|
||||
n.serverStateLock.Unlock()
|
||||
return
|
||||
}
|
||||
previous := n.effectiveState(n.lastNotification)
|
||||
n.networkAvailable = available
|
||||
current := n.effectiveState(n.lastNotification)
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
if previous != current {
|
||||
n.notify(current)
|
||||
}
|
||||
return ¬ifier{}
|
||||
}
|
||||
|
||||
func (n *notifier) setListener(listener Listener) {
|
||||
n.serverStateLock.Lock()
|
||||
lastNotification := n.effectiveState(n.lastNotification)
|
||||
lastNotification := n.lastNotification
|
||||
numOfPeers := n.lastNumberOfPeers
|
||||
fqdnAddress := n.lastFqdnAddress
|
||||
address := n.lastIPAddress
|
||||
@@ -85,9 +52,6 @@ func (n *notifier) removeListener() {
|
||||
}
|
||||
|
||||
func (n *notifier) updateServerStates(mgmState bool, signalState bool) {
|
||||
n.publishLock.Lock()
|
||||
defer n.publishLock.Unlock()
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
calculatedState := n.calculateState(mgmState, signalState)
|
||||
|
||||
@@ -97,54 +61,43 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) {
|
||||
}
|
||||
|
||||
n.lastNotification = calculatedState
|
||||
effective := n.effectiveState(calculatedState)
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(effective)
|
||||
n.notify(calculatedState)
|
||||
}
|
||||
|
||||
func (n *notifier) clientStart() {
|
||||
n.publishLock.Lock()
|
||||
defer n.publishLock.Unlock()
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
n.currentClientState = true
|
||||
n.lastNotification = ClientStateConnecting
|
||||
effective := n.effectiveState(ClientStateConnecting)
|
||||
n.lastNotification = stateConnecting
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(effective)
|
||||
n.notify(stateConnecting)
|
||||
}
|
||||
|
||||
func (n *notifier) clientStop() {
|
||||
n.publishLock.Lock()
|
||||
defer n.publishLock.Unlock()
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
n.currentClientState = false
|
||||
n.lastNotification = ClientStateDisconnected
|
||||
n.lastNotification = stateDisconnected
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(ClientStateDisconnected)
|
||||
n.notify(stateDisconnected)
|
||||
}
|
||||
|
||||
func (n *notifier) clientTearDown() {
|
||||
n.publishLock.Lock()
|
||||
defer n.publishLock.Unlock()
|
||||
|
||||
n.serverStateLock.Lock()
|
||||
n.currentClientState = false
|
||||
n.lastNotification = ClientStateDisconnecting
|
||||
n.lastNotification = stateDisconnecting
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(ClientStateDisconnecting)
|
||||
n.notify(stateDisconnecting)
|
||||
}
|
||||
|
||||
func (n *notifier) isServerStateChanged(newState ClientState) bool {
|
||||
func (n *notifier) isServerStateChanged(newState int) bool {
|
||||
return n.lastNotification != newState
|
||||
}
|
||||
|
||||
func (n *notifier) notify(state ClientState) {
|
||||
func (n *notifier) notify(state int) {
|
||||
n.listenersLock.Lock()
|
||||
listener := n.listener
|
||||
n.listenersLock.Unlock()
|
||||
@@ -156,20 +109,20 @@ func (n *notifier) notify(state ClientState) {
|
||||
notifyListener(listener, state)
|
||||
}
|
||||
|
||||
func (n *notifier) calculateState(managementConn, signalConn bool) ClientState {
|
||||
func (n *notifier) calculateState(managementConn, signalConn bool) int {
|
||||
if managementConn && signalConn {
|
||||
return ClientStateConnected
|
||||
return stateConnected
|
||||
}
|
||||
|
||||
if !managementConn && !signalConn && !n.currentClientState {
|
||||
return ClientStateDisconnected
|
||||
return stateDisconnected
|
||||
}
|
||||
|
||||
if n.lastNotification == ClientStateDisconnecting {
|
||||
return ClientStateDisconnecting
|
||||
if n.lastNotification == stateDisconnecting {
|
||||
return stateDisconnecting
|
||||
}
|
||||
|
||||
return ClientStateConnecting
|
||||
return stateConnecting
|
||||
}
|
||||
|
||||
func (n *notifier) peerListChanged(numOfPeers int) {
|
||||
@@ -206,19 +159,15 @@ func (n *notifier) localAddressChanged(fqdn, address string) {
|
||||
listener.OnAddressChanged(fqdn, address)
|
||||
}
|
||||
|
||||
func notifyListener(l Listener, state ClientState) {
|
||||
// legacy per-state callbacks; NoNetwork is delivered only via
|
||||
// OnStateChanged below
|
||||
func notifyListener(l Listener, state int) {
|
||||
switch state {
|
||||
case ClientStateDisconnected:
|
||||
case stateDisconnected:
|
||||
l.OnDisconnected()
|
||||
case ClientStateConnected:
|
||||
case stateConnected:
|
||||
l.OnConnected()
|
||||
case ClientStateConnecting:
|
||||
case stateConnecting:
|
||||
l.OnConnecting()
|
||||
case ClientStateDisconnecting:
|
||||
case stateDisconnecting:
|
||||
l.OnDisconnecting()
|
||||
}
|
||||
|
||||
l.OnStateChanged(state)
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type recordingListener struct {
|
||||
mu sync.Mutex
|
||||
states []ClientState
|
||||
onState func(ClientState)
|
||||
}
|
||||
|
||||
func (l *recordingListener) OnStateChanged(state ClientState) {
|
||||
l.mu.Lock()
|
||||
l.states = append(l.states, state)
|
||||
hook := l.onState
|
||||
l.mu.Unlock()
|
||||
|
||||
if hook != nil {
|
||||
hook(state)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *recordingListener) last() (ClientState, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if len(l.states) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return l.states[len(l.states)-1], true
|
||||
}
|
||||
|
||||
func (l *recordingListener) snapshot() []ClientState {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return append([]ClientState(nil), l.states...)
|
||||
}
|
||||
|
||||
func (l *recordingListener) OnConnected() {}
|
||||
func (l *recordingListener) OnDisconnected() {}
|
||||
func (l *recordingListener) OnConnecting() {}
|
||||
func (l *recordingListener) OnDisconnecting() {}
|
||||
func (l *recordingListener) OnAddressChanged(string, string) {}
|
||||
func (l *recordingListener) OnPeersListChanged(int) {}
|
||||
|
||||
// TestNotifier_ConcurrentAvailabilityFlipOrdersPublication holds the first
|
||||
// transition inside the listener callback and flips availability again from
|
||||
// another goroutine while it is parked. The second flip must not publish
|
||||
// ahead of the one in flight, otherwise the listener ends up on a state the
|
||||
// notifier already superseded.
|
||||
func TestNotifier_ConcurrentAvailabilityFlipOrdersPublication(t *testing.T) {
|
||||
n := newNotifier()
|
||||
n.currentClientState = true
|
||||
n.lastNotification = ClientStateConnecting
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
l := &recordingListener{}
|
||||
l.onState = func(state ClientState) {
|
||||
if state != ClientStateNoNetwork {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.onState = nil
|
||||
l.mu.Unlock()
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
n.listener = l
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
n.setNetworkAvailable(false)
|
||||
}()
|
||||
|
||||
<-entered
|
||||
|
||||
flipped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(flipped)
|
||||
n.setNetworkAvailable(true)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-flipped:
|
||||
t.Fatal("the online transition published while the offline one was " +
|
||||
"still in flight; publication is not serialized")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(release)
|
||||
<-flipped
|
||||
wg.Wait()
|
||||
|
||||
got, ok := l.last()
|
||||
if !ok {
|
||||
t.Fatal("listener never observed a state")
|
||||
}
|
||||
if got != ClientStateConnecting {
|
||||
t.Fatalf("listener holds %v after the network came back, want Connecting; sequence: %v",
|
||||
got, l.snapshot())
|
||||
}
|
||||
}
|
||||
@@ -6,32 +6,29 @@ import (
|
||||
)
|
||||
|
||||
type mocListener struct {
|
||||
lastState ClientState
|
||||
lastState int
|
||||
wg sync.WaitGroup
|
||||
peersWg sync.WaitGroup
|
||||
peers int
|
||||
}
|
||||
|
||||
func (l *mocListener) OnConnected() {
|
||||
l.lastState = ClientStateConnected
|
||||
l.lastState = stateConnected
|
||||
l.wg.Done()
|
||||
}
|
||||
func (l *mocListener) OnDisconnected() {
|
||||
l.lastState = ClientStateDisconnected
|
||||
l.lastState = stateDisconnected
|
||||
l.wg.Done()
|
||||
}
|
||||
func (l *mocListener) OnConnecting() {
|
||||
l.lastState = ClientStateConnecting
|
||||
l.lastState = stateConnecting
|
||||
l.wg.Done()
|
||||
}
|
||||
func (l *mocListener) OnDisconnecting() {
|
||||
l.lastState = ClientStateDisconnecting
|
||||
l.lastState = stateDisconnecting
|
||||
l.wg.Done()
|
||||
}
|
||||
|
||||
func (l *mocListener) OnStateChanged(state ClientState) {
|
||||
|
||||
}
|
||||
func (l *mocListener) OnAddressChanged(host, addr string) {
|
||||
|
||||
}
|
||||
@@ -60,15 +57,15 @@ func Test_notifier_serverState(t *testing.T) {
|
||||
|
||||
type scenario struct {
|
||||
name string
|
||||
expected ClientState
|
||||
expected int
|
||||
mgmState bool
|
||||
signalState bool
|
||||
}
|
||||
scenarios := []scenario{
|
||||
{"connected", ClientStateConnected, true, true},
|
||||
{"mgm down", ClientStateConnecting, false, true},
|
||||
{"signal down", ClientStateConnecting, true, false},
|
||||
{"disconnected", ClientStateDisconnected, false, false},
|
||||
{"connected", stateConnected, true, true},
|
||||
{"mgm down", stateConnecting, false, true},
|
||||
{"signal down", stateConnecting, true, false},
|
||||
{"disconnected", stateDisconnected, false, false},
|
||||
}
|
||||
|
||||
for _, tt := range scenarios {
|
||||
@@ -88,7 +85,7 @@ func Test_notifier_SetListener(t *testing.T) {
|
||||
listener.setPeersWaiter()
|
||||
|
||||
n := newNotifier()
|
||||
n.lastNotification = ClientStateConnecting
|
||||
n.lastNotification = stateConnecting
|
||||
n.setListener(listener)
|
||||
listener.wait()
|
||||
listener.waitPeers()
|
||||
@@ -102,7 +99,7 @@ func Test_notifier_RemoveListener(t *testing.T) {
|
||||
listener.setWaiter()
|
||||
listener.setPeersWaiter()
|
||||
n := newNotifier()
|
||||
n.lastNotification = ClientStateConnecting
|
||||
n.lastNotification = stateConnecting
|
||||
n.setListener(listener)
|
||||
// setListener replays cached state on a goroutine; wait for both the state
|
||||
// and peers callbacks to finish so we don't race on listener.peers.
|
||||
|
||||
@@ -1211,12 +1211,6 @@ func (d *Status) ClientTeardown() {
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// SetNetworkAvailable records the OS-reported network availability; while
|
||||
// unavailable, listeners see NoNetwork instead of Connecting.
|
||||
func (d *Status) SetNetworkAvailable(available bool) {
|
||||
d.notifier.setNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// SetConnectionListener set a listener to the notifier
|
||||
func (d *Status) SetConnectionListener(listener Listener) {
|
||||
d.notifier.setListener(listener)
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
@@ -22,8 +21,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -31,12 +28,10 @@ import (
|
||||
types "github.com/netbirdio/netbird/upload-server/types"
|
||||
)
|
||||
|
||||
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
|
||||
// anonymizeLevel values for DebugBundle.
|
||||
const (
|
||||
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
|
||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||
)
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
}
|
||||
|
||||
// RouteListener export internal RouteListener for mobile
|
||||
type NetworkChangeListener interface {
|
||||
@@ -84,12 +79,6 @@ type Client struct {
|
||||
onHostDnsFn func([]string)
|
||||
dnsManager dns.IosDnsManager
|
||||
loginComplete bool
|
||||
// netState outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run injects it into each new ConnectClient, which
|
||||
// distributes it to every reconnection loop.
|
||||
netState *netstate.State
|
||||
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
|
||||
sweeper *netsweep.Sweeper
|
||||
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
|
||||
preloadedConfig *profilemanager.Config
|
||||
|
||||
@@ -112,8 +101,6 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
dnsManager: dnsManager,
|
||||
netState: netstate.New(),
|
||||
sweeper: netsweep.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,8 +176,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
c.onHostDnsFn = func([]string) {}
|
||||
cfg.WgIface = interfaceName
|
||||
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, connectClient)
|
||||
// Persist the latest sync response so DebugBundle can include the network
|
||||
// map. On iOS this is backed by disk to keep it out of the constrained
|
||||
@@ -199,24 +185,6 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath)
|
||||
}
|
||||
|
||||
// SetNetworkAvailable feeds OS-reported network availability into the client
|
||||
// (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops
|
||||
// suspend their attempts and the connection listener reports NoNetwork
|
||||
// instead of Connecting; when availability returns, the loops resume
|
||||
// immediately with a fresh backoff.
|
||||
func (c *Client) SetNetworkAvailable(available bool) {
|
||||
c.netState.Set(available)
|
||||
c.recorder.SetNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// NotifyNetworkChange cuts the management, signal and relay connections
|
||||
// after the OS switched networks, so the reconnect loops redial immediately
|
||||
// on the new one. The engine and the TUN device stay untouched.
|
||||
func (c *Client) NotifyNetworkChange() {
|
||||
n := c.sweeper.Sweep()
|
||||
log.Infof("network change: swept %d connections", n)
|
||||
}
|
||||
|
||||
// Stop the internal client and free the resources
|
||||
func (c *Client) Stop() {
|
||||
c.ctxCancelLock.Lock()
|
||||
@@ -232,10 +200,8 @@ func (c *Client) Stop() {
|
||||
// DebugBundle generates a debug bundle, uploads it and returns the upload key.
|
||||
// It works with or without a running engine: when the engine is up it reuses
|
||||
// the live config, sync response and client metrics; otherwise it loads the
|
||||
// config from disk (or the preloaded tvOS config). anonymizeLevel is "default"
|
||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||
// WireGuard public keys, and implies anonymize.
|
||||
func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, error) {
|
||||
// config from disk (or the preloaded tvOS config).
|
||||
func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
||||
cfg, cc := c.stateSnapshot()
|
||||
|
||||
// If the engine hasn't been started, load config so we can reach management.
|
||||
@@ -285,7 +251,6 @@ func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, err
|
||||
deps,
|
||||
debug.BundleConfig{
|
||||
Anonymize: anonymize,
|
||||
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
|
||||
IncludeSystemInfo: true,
|
||||
},
|
||||
)
|
||||
@@ -355,11 +320,7 @@ func (c *Client) GetStatusDetails() *StatusDetails {
|
||||
|
||||
// SetConnectionListener set the network connection listener
|
||||
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
||||
if listener == nil {
|
||||
c.recorder.RemoveConnectionListener()
|
||||
return
|
||||
}
|
||||
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
|
||||
c.recorder.SetConnectionListener(listener)
|
||||
}
|
||||
|
||||
// RemoveConnectionListener remove connection listener
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
//go:build ios
|
||||
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// Client state values, re-exported as basic constants so gomobile emits them
|
||||
// into the generated bindings. They mirror peer.ClientState*: append-only,
|
||||
// never reorder.
|
||||
const (
|
||||
ClientStateDisconnected = int(peer.ClientStateDisconnected)
|
||||
ClientStateConnected = int(peer.ClientStateConnected)
|
||||
ClientStateConnecting = int(peer.ClientStateConnecting)
|
||||
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
|
||||
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile.
|
||||
//
|
||||
// It intentionally lacks OnStateChanged for now: adding a method to a gomobile
|
||||
// interface breaks every Swift implementation, so the iOS app keeps building
|
||||
// against the legacy per-state callbacks. A follow-up will extend it together
|
||||
// with the app.
|
||||
type ConnectionListener interface {
|
||||
OnConnected()
|
||||
OnDisconnected()
|
||||
OnConnecting()
|
||||
OnDisconnecting()
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
|
||||
// peer.Listener.
|
||||
type connectionListenerAdapter struct {
|
||||
ConnectionListener
|
||||
}
|
||||
|
||||
// OnStateChanged is dropped on iOS until the app adopts the state callback;
|
||||
// the legacy per-state callbacks continue to fire.
|
||||
func (a connectionListenerAdapter) OnStateChanged(peer.ClientState) {}
|
||||
@@ -1,107 +0,0 @@
|
||||
// Package netstate tracks OS-reported network availability for the client.
|
||||
//
|
||||
// A State instance is owned by the platform integration (e.g. the Android or
|
||||
// iOS bindings, fed from ConnectivityManager callbacks or NWPathMonitor) and
|
||||
// is injected into the connection retry loops (management, signal, relay,
|
||||
// peer guards and the top-level connect loop), which consult it to avoid
|
||||
// burning CPU and battery on reconnect attempts while the device has no
|
||||
// network at all (e.g. airplane mode), and to reset their backoff as soon as
|
||||
// the network returns.
|
||||
//
|
||||
// Consumers hold a *State that may be nil — every non-mobile platform leaves
|
||||
// it unset. The read methods are safe on a nil receiver: they report online
|
||||
// and never block, so consumers behave as if this package did not exist.
|
||||
package netstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// State holds the OS-reported network availability. The zero value is not
|
||||
// usable; create instances with New.
|
||||
type State struct {
|
||||
mu sync.Mutex
|
||||
online bool
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
// New creates a State that starts online.
|
||||
func New() *State {
|
||||
return &State{
|
||||
online: true,
|
||||
changed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Set records whether the OS reports any usable network. Transitions wake up
|
||||
// all Wait callers immediately.
|
||||
func (s *State) Set(online bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.online == online {
|
||||
return
|
||||
}
|
||||
s.online = online
|
||||
close(s.changed)
|
||||
s.changed = make(chan struct{})
|
||||
log.Infof("OS network availability changed: online=%t", online)
|
||||
}
|
||||
|
||||
// IsOnline reports whether the OS reports at least one usable network. On a
|
||||
// nil receiver — no State injected — it reports online.
|
||||
func (s *State) IsOnline() bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.online
|
||||
}
|
||||
|
||||
// Changed returns a channel closed on the next availability transition, for
|
||||
// callers that already own a select loop and cannot block in Wait. Re-read it
|
||||
// after every fire: each transition installs a fresh channel. On a nil
|
||||
// receiver — no State injected — it returns nil, which blocks forever in a
|
||||
// select, so the caller simply never observes a transition.
|
||||
func (s *State) Changed() <-chan struct{} {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.changed
|
||||
}
|
||||
|
||||
// Wait blocks while the network is offline. It reports whether it had to
|
||||
// wait, so callers can reset their backoff after an outage. It returns early
|
||||
// with the context error when ctx is done. On a nil receiver — no State
|
||||
// injected — it returns immediately.
|
||||
func (s *State) Wait(ctx context.Context) (bool, error) {
|
||||
if s == nil {
|
||||
return false, nil
|
||||
}
|
||||
waited := false
|
||||
for {
|
||||
s.mu.Lock()
|
||||
if s.online {
|
||||
s.mu.Unlock()
|
||||
return waited, nil
|
||||
}
|
||||
ch := s.changed
|
||||
s.mu.Unlock()
|
||||
|
||||
if !waited {
|
||||
waited = true
|
||||
log.Debugf("network is offline, pausing connection attempts")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return waited, ctx.Err()
|
||||
case <-ch:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package netstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewStateIsOnline(t *testing.T) {
|
||||
assert.True(t, New().IsOnline(), "a fresh State should start online")
|
||||
}
|
||||
|
||||
func TestSetTogglesOnlineState(t *testing.T) {
|
||||
s := New()
|
||||
|
||||
s.Set(false)
|
||||
assert.False(t, s.IsOnline(), "state should be offline after Set(false)")
|
||||
|
||||
s.Set(true)
|
||||
assert.True(t, s.IsOnline(), "state should be online after Set(true)")
|
||||
}
|
||||
|
||||
func TestWaitReturnsImmediatelyWhenOnline(t *testing.T) {
|
||||
s := New()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
waited, err := s.Wait(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, waited, "Wait should not block when the network is online")
|
||||
}
|
||||
|
||||
func TestWaitBlocksUntilOnline(t *testing.T) {
|
||||
s := New()
|
||||
s.Set(false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result := make(chan bool, 1)
|
||||
go func() {
|
||||
waited, err := s.Wait(ctx)
|
||||
if err != nil {
|
||||
result <- false
|
||||
return
|
||||
}
|
||||
result <- waited
|
||||
}()
|
||||
|
||||
// Verify Wait is actually blocking while offline
|
||||
select {
|
||||
case <-result:
|
||||
t.Fatal("Wait should block while the network is offline")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
s.Set(true)
|
||||
|
||||
select {
|
||||
case waited := <-result:
|
||||
assert.True(t, waited, "Wait should report that it had to wait for the network")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Wait should return promptly after the network becomes available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitReturnsOnContextCancel(t *testing.T) {
|
||||
s := New()
|
||||
s.Set(false)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.Wait(ctx)
|
||||
result <- err
|
||||
}()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Wait should return promptly after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitWakesAllWaiters(t *testing.T) {
|
||||
s := New()
|
||||
s.Set(false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const waiters = 10
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan bool, waiters)
|
||||
for i := 0; i < waiters; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
waited, err := s.Wait(ctx)
|
||||
if err != nil {
|
||||
results <- false
|
||||
return
|
||||
}
|
||||
results <- waited
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
s.Set(true)
|
||||
wg.Wait()
|
||||
|
||||
close(results)
|
||||
count := 0
|
||||
for waited := range results {
|
||||
assert.True(t, waited, "every waiter should report that it waited")
|
||||
count++
|
||||
}
|
||||
assert.Equal(t, waiters, count, "all waiters should have returned")
|
||||
}
|
||||
|
||||
func TestNilStateReadsAreNoops(t *testing.T) {
|
||||
var s *State
|
||||
|
||||
assert.True(t, s.IsOnline(), "nil State should report online")
|
||||
|
||||
waited, err := s.Wait(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, waited, "nil State's Wait should not block")
|
||||
}
|
||||
|
||||
func TestConcurrentSetAndWait(t *testing.T) {
|
||||
s := New()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
s.Set(j%2 == 0)
|
||||
s.IsOnline()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
if _, err := s.Wait(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
// Package netsweep cuts network-bound activity when the OS switches networks:
|
||||
// a sweep closes the registered connections and aborts the in-flight dials, so
|
||||
// their owners redial immediately instead of waiting for the old sockets to
|
||||
// time out.
|
||||
//
|
||||
// A nil *Sweeper disables everything: all methods are nil-safe no-ops.
|
||||
package netsweep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ErrSwept reports that a dial finished after a network change swept its
|
||||
// registration. The connection is already closed; the caller must treat it
|
||||
// as a failed dial and redial on the new network.
|
||||
var ErrSwept = errors.New("netsweep: connection swept by network change")
|
||||
|
||||
// sweepID identifies one registration in a sweeper. Connections and dials
|
||||
// draw from the same counter, so an id is unique across both registries.
|
||||
type sweepID uint64
|
||||
|
||||
// Dial tracks one dial from start to connection registration. It hands the
|
||||
// dialed connection to the sweeper atomically, so a sweep can never fall
|
||||
// between the dial finishing and the connection being registered.
|
||||
type Dial struct {
|
||||
sweeper *Sweeper
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
id sweepID
|
||||
done bool // set by Sweep, WrapConn or Release; guarded by sweeper.mu
|
||||
}
|
||||
|
||||
// Ctx returns the dial's context. Sweep cancels it, so a dial started on the
|
||||
// old network aborts instead of waiting out its handshake timeout.
|
||||
func (d *Dial) Ctx() context.Context {
|
||||
return d.ctx
|
||||
}
|
||||
|
||||
// Release ends the dial's registration and cancels its context. It is
|
||||
// idempotent and safe after WrapConn, so callers can defer it.
|
||||
func (d *Dial) Release() {
|
||||
s := d.sweeper
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
d.done = true
|
||||
delete(s.dials, d.id)
|
||||
s.mu.Unlock()
|
||||
|
||||
d.cancel()
|
||||
}
|
||||
|
||||
// sweptConn deregisters itself from the sweeper when closed.
|
||||
type sweptConn struct {
|
||||
net.Conn
|
||||
sweeper *Sweeper
|
||||
id sweepID
|
||||
}
|
||||
|
||||
func (c *sweptConn) Close() error {
|
||||
c.sweeper.deregister(c.id)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// Sweeper registers live connections and in-flight dials so Sweep can cut
|
||||
// everything that started before the network changed.
|
||||
type Sweeper struct {
|
||||
mu sync.Mutex
|
||||
conns map[sweepID]net.Conn
|
||||
dials map[sweepID]*Dial
|
||||
nextID sweepID
|
||||
}
|
||||
|
||||
// New creates an empty sweeper.
|
||||
func New() *Sweeper {
|
||||
return &Sweeper{
|
||||
conns: make(map[sweepID]net.Conn),
|
||||
dials: make(map[sweepID]*Dial),
|
||||
}
|
||||
}
|
||||
|
||||
// StartDial registers an in-flight dial. Dial with Ctx, hand the result to
|
||||
// WrapConn, and Release the dial when the attempt is over, typically deferred.
|
||||
func (s *Sweeper) StartDial(ctx context.Context) *Dial {
|
||||
if s == nil {
|
||||
return &Dial{ctx: ctx}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
d := &Dial{sweeper: s, ctx: ctx, cancel: cancel}
|
||||
|
||||
s.mu.Lock()
|
||||
d.id = s.nextID
|
||||
s.nextID++
|
||||
s.dials[d.id] = d
|
||||
s.mu.Unlock()
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// WrapConn hands conn over to the sweeper. If a sweep ran since StartDial,
|
||||
// the connection belongs to the old network: it is closed and ErrSwept is
|
||||
// returned. Otherwise conn is registered against the next sweep and returned
|
||||
// wrapped, deregistering itself on Close. Call it once, before Release.
|
||||
func (d *Dial) WrapConn(conn net.Conn) (net.Conn, error) {
|
||||
s := d.sweeper
|
||||
if s == nil {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if d.done {
|
||||
s.mu.Unlock()
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("swept dial close error: %v", err)
|
||||
}
|
||||
return nil, ErrSwept
|
||||
}
|
||||
d.done = true
|
||||
delete(s.dials, d.id)
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.conns[id] = conn
|
||||
s.mu.Unlock()
|
||||
|
||||
return &sweptConn{Conn: conn, sweeper: s, id: id}, nil
|
||||
}
|
||||
|
||||
// Sweep closes every registered connection, aborts every in-flight dial, and
|
||||
// returns how many connections it closed. A dial whose connection was not
|
||||
// yet handed to WrapConn is marked, so the late WrapConn closes it instead
|
||||
// of registering it.
|
||||
func (s *Sweeper) Sweep() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
conns := s.conns
|
||||
dials := s.dials
|
||||
s.conns = make(map[sweepID]net.Conn)
|
||||
s.dials = make(map[sweepID]*Dial)
|
||||
for _, d := range dials {
|
||||
d.done = true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(dials) > 0 {
|
||||
log.Debugf("aborting %d in-flight dials", len(dials))
|
||||
for _, d := range dials {
|
||||
d.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
for _, conn := range conns {
|
||||
log.Debugf("sweeping connection %s -> %s", conn.LocalAddr(), conn.RemoteAddr())
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("swept connection close error: %v", err)
|
||||
}
|
||||
}
|
||||
return len(conns)
|
||||
}
|
||||
|
||||
func (s *Sweeper) deregister(id sweepID) {
|
||||
s.mu.Lock()
|
||||
delete(s.conns, id)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package netsweep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSweepClosesRegisteredConns(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
c1 := wrap(t, sweeper, connPair(t))
|
||||
c2 := wrap(t, sweeper, connPair(t))
|
||||
|
||||
assert.Equal(t, 2, sweeper.Sweep(), "both live connections should be closed")
|
||||
|
||||
// The wrappers must report closed now.
|
||||
buf := make([]byte, 1)
|
||||
_, err := c1.Read(buf)
|
||||
assert.Error(t, err, "first connection should be unusable after the sweep")
|
||||
_, err = c2.Read(buf)
|
||||
assert.Error(t, err, "second connection should be unusable after the sweep")
|
||||
|
||||
assert.Equal(t, 0, sweeper.Sweep(), "second sweep should find nothing")
|
||||
}
|
||||
|
||||
func TestCloseDeregisters(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
conn := wrap(t, sweeper, connPair(t))
|
||||
require.NoError(t, conn.Close())
|
||||
|
||||
assert.Equal(t, 0, sweeper.Sweep(), "closed connection must leave the registry")
|
||||
}
|
||||
|
||||
func TestCloseIsIdempotent(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
conn := wrap(t, sweeper, connPair(t))
|
||||
require.NoError(t, conn.Close())
|
||||
assert.Error(t, conn.Close(), "double close surfaces the underlying error but must not panic")
|
||||
}
|
||||
|
||||
func TestSweepOnlyAffectsOlderConns(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
_ = wrap(t, sweeper, connPair(t))
|
||||
assert.Equal(t, 1, sweeper.Sweep())
|
||||
|
||||
// A connection dialed after the sweep must survive until the next one.
|
||||
_ = wrap(t, sweeper, connPair(t))
|
||||
assert.Equal(t, 1, sweeper.Sweep(), "post-sweep connection belongs to the next sweep")
|
||||
}
|
||||
|
||||
func TestSweepAbortsInFlightDials(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
sweeper.Sweep()
|
||||
|
||||
assert.ErrorIs(t, dial.Ctx().Err(), context.Canceled, "sweep must cancel the in-flight dial context")
|
||||
}
|
||||
|
||||
func TestReleasedDialIsNotAborted(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
// Simulate a dial that finished before the sweep.
|
||||
released := sweeper.StartDial(context.Background())
|
||||
released.Release()
|
||||
|
||||
// A dial still in flight during the sweep.
|
||||
pending := sweeper.StartDial(context.Background())
|
||||
defer pending.Release()
|
||||
|
||||
sweeper.Sweep()
|
||||
assert.ErrorIs(t, pending.Ctx().Err(), context.Canceled, "pending dial must be aborted")
|
||||
}
|
||||
|
||||
func TestSweepBetweenDialAndHandoffClosesConn(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
// The dial succeeds on the old network, then the sweep lands before the
|
||||
// connection is handed over.
|
||||
conn := connPair(t)
|
||||
assert.Equal(t, 0, sweeper.Sweep(), "the connection is not registered yet")
|
||||
|
||||
wrapped, err := dial.WrapConn(conn)
|
||||
require.ErrorIs(t, err, ErrSwept)
|
||||
require.Nil(t, wrapped)
|
||||
|
||||
buf := make([]byte, 1)
|
||||
_, err = conn.Read(buf)
|
||||
assert.Error(t, err, "the old-network connection must be closed, not leaked")
|
||||
|
||||
assert.Equal(t, 0, sweeper.Sweep(), "nothing may leak into the next sweep")
|
||||
}
|
||||
|
||||
func TestNilSweeperIsNoop(t *testing.T) {
|
||||
var sweeper *Sweeper
|
||||
|
||||
conn := connPair(t)
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
wrapped, err := dial.WrapConn(conn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, conn, wrapped, "nil sweeper must return the conn unchanged")
|
||||
assert.NoError(t, dial.Ctx().Err(), "nil sweeper must not cancel the dial context")
|
||||
assert.Equal(t, 0, sweeper.Sweep(), "nil sweeper closes nothing")
|
||||
}
|
||||
|
||||
// wrap registers conn with the sweeper through a completed dial.
|
||||
func wrap(t *testing.T, sweeper *Sweeper, conn net.Conn) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
wrapped, err := dial.WrapConn(conn)
|
||||
require.NoError(t, err)
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// connPair dials a loopback TCP connection and keeps the accepted peer open
|
||||
// until the test ends: a peer that closed early would make the connection
|
||||
// unreadable on its own, so a read error after the sweep would prove nothing.
|
||||
func connPair(t *testing.T) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := l.Close(); err != nil {
|
||||
t.Logf("listener close error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
accepted := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
close(accepted)
|
||||
return
|
||||
}
|
||||
accepted <- conn
|
||||
}()
|
||||
|
||||
conn, err := net.Dial("tcp", l.Addr().String())
|
||||
require.NoError(t, err)
|
||||
|
||||
peer, ok := <-accepted
|
||||
require.True(t, ok, "listener must accept the dialed connection")
|
||||
t.Cleanup(func() {
|
||||
if err := peer.Close(); err != nil {
|
||||
t.Logf("peer close error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return conn
|
||||
}
|
||||
@@ -2781,11 +2781,6 @@ type DebugBundleRequest struct {
|
||||
// untrusted TLS certificate. Restricted to privileged callers; for
|
||||
// self-hosted upload servers.
|
||||
UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"`
|
||||
// anonymizeLevel selects how much the anonymizer redacts: "default"
|
||||
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
// Unknown values are treated as "strict". Only meaningful with anonymize;
|
||||
// "strict" implies it.
|
||||
AnonymizeLevel string `protobuf:"bytes,8,opt,name=anonymizeLevel,proto3" json:"anonymizeLevel,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2862,13 +2857,6 @@ func (x *DebugBundleRequest) GetUploadInsecure() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *DebugBundleRequest) GetAnonymizeLevel() string {
|
||||
if x != nil {
|
||||
return x.AnonymizeLevel
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DebugBundleResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||
@@ -7265,7 +7253,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
|
||||
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
|
||||
"\x17ForwardingRulesResponse\x12,\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xdc\x01\n" +
|
||||
"\x12DebugBundleRequest\x12\x1c\n" +
|
||||
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
|
||||
"\n" +
|
||||
@@ -7276,8 +7264,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"cliVersion\x18\x06 \x01(\tR\n" +
|
||||
"cliVersion\x12&\n" +
|
||||
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\x12&\n" +
|
||||
"\x0eanonymizeLevel\x18\b \x01(\tR\x0eanonymizeLevel\"}\n" +
|
||||
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\"}\n" +
|
||||
"\x13DebugBundleResponse\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
|
||||
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
|
||||
|
||||
@@ -540,11 +540,6 @@ message DebugBundleRequest {
|
||||
// untrusted TLS certificate. Restricted to privileged callers; for
|
||||
// self-hosted upload servers.
|
||||
bool uploadInsecure = 7;
|
||||
// anonymizeLevel selects how much the anonymizer redacts: "default"
|
||||
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
// Unknown values are treated as "strict". Only meaningful with anonymize;
|
||||
// "strict" implies it.
|
||||
string anonymizeLevel = 8;
|
||||
}
|
||||
|
||||
message DebugBundleResponse {
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
@@ -123,7 +122,6 @@ func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener deb
|
||||
},
|
||||
debug.BundleConfig{
|
||||
Anonymize: req.GetAnonymize(),
|
||||
AnonymizeLevel: anonymize.ParseLevel(req.GetAnonymizeLevel()),
|
||||
IncludeSystemInfo: req.GetSystemInfo(),
|
||||
LogFileCount: req.GetLogFileCount(),
|
||||
},
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi
|
||||
|
||||
// prepareCommandEnv prepares environment variables for command execution on Windows
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
username, domain := parseUsername(localUser.Username)
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
shell := getUserShell(localUser.Uid)
|
||||
|
||||
req := PtyExecutionRequest{
|
||||
|
||||
@@ -133,12 +133,7 @@ func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, resu
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only uid 0 may bind below the threshold, which is the kernel's own rule and
|
||||
// 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" {
|
||||
if result.User != nil && isPrivilegedUsername(result.User.Username) {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -239,7 +239,6 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType string
|
||||
port uint32
|
||||
username string
|
||||
uid string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
skipOnWindows bool
|
||||
@@ -249,7 +248,6 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 80,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: true,
|
||||
errorMsg: "cannot bind to privileged port",
|
||||
skipOnWindows: true,
|
||||
@@ -259,7 +257,6 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "tcpip-forward",
|
||||
port: 443,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: true,
|
||||
errorMsg: "cannot bind to privileged port",
|
||||
skipOnWindows: true,
|
||||
@@ -269,7 +266,6 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 8080,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
@@ -277,7 +273,6 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 0,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
@@ -285,35 +280,13 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 22,
|
||||
username: "root",
|
||||
uid: "0",
|
||||
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",
|
||||
forwardType: "local",
|
||||
port: 80,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
@@ -326,7 +299,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
|
||||
result := PrivilegeCheckResult{
|
||||
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)
|
||||
@@ -447,13 +420,6 @@ func TestServer_PortConflictHandling(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 {
|
||||
username string
|
||||
expected bool
|
||||
@@ -474,16 +440,44 @@ func TestServer_IsPrivilegedUser(t *testing.T) {
|
||||
expected: false,
|
||||
description: "empty username should not be privileged",
|
||||
},
|
||||
{
|
||||
username: "Administrator",
|
||||
expected: false,
|
||||
description: "Administrator should not be privileged on non-Windows systems",
|
||||
},
|
||||
}
|
||||
|
||||
// Add Windows-specific tests
|
||||
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 {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
result := isPrivilegedOrUnknown(tt.username)
|
||||
result := isPrivilegedUsername(tt.username)
|
||||
assert.Equal(t, tt.expected, result, tt.description)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// createSftpCommand creates a Windows SFTP command with user switching.
|
||||
// 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) {
|
||||
username, domain := parseUsername(targetUser.Username)
|
||||
username, domain := s.parseUsername(targetUser.Username)
|
||||
|
||||
netbirdPath, err := os.Executable()
|
||||
if err != nil {
|
||||
|
||||
@@ -16,6 +16,11 @@ var (
|
||||
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
|
||||
var (
|
||||
getCurrentUser = currentUserWithGetent
|
||||
@@ -24,9 +29,6 @@ var (
|
||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||
|
||||
getEuid = os.Geteuid
|
||||
|
||||
getProcessElevated = isProcessElevated
|
||||
getWindowsAccountPrivilegedOrUnknown = isWindowsAccountPrivilegedOrUnknown
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -63,13 +65,6 @@ type PrivilegeCheckResult struct {
|
||||
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.
|
||||
// This is the single source of truth for privilege decisions across the SSH server.
|
||||
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
|
||||
if req.RequestedUsername == "" {
|
||||
if isPrivilegedOrUnknown(context.currentUser.Username) && !context.allowRoot {
|
||||
if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot {
|
||||
return PrivilegeCheckResult{
|
||||
Allowed: false,
|
||||
Error: &PrivilegedUserError{Username: context.currentUser.Username},
|
||||
@@ -140,7 +135,7 @@ func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheck
|
||||
|
||||
needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser)
|
||||
|
||||
if isPrivilegedOrUnknown(resolvedUser.Username) && !ctx.allowRoot {
|
||||
if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot {
|
||||
return PrivilegeCheckResult{
|
||||
Allowed: false,
|
||||
Error: &PrivilegedUserError{Username: resolvedUser.Username},
|
||||
@@ -180,42 +175,6 @@ func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, err
|
||||
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
|
||||
func isSameResolvedUser(user1, user2 *user.User) bool {
|
||||
if user1 == nil || user2 == nil {
|
||||
@@ -224,6 +183,13 @@ func isSameResolvedUser(user1, user2 *user.User) bool {
|
||||
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
|
||||
// SECURITY: This function must be conservative - it should only return true
|
||||
// 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)
|
||||
}
|
||||
|
||||
// isPrivilegedOrUnknown reports whether the given username represents a
|
||||
// privileged user, or on Windows an account whose privilege could not be
|
||||
// determined.
|
||||
// On Unix: root.
|
||||
// On Windows: well-known service accounts, built-in Administrator accounts,
|
||||
// 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.
|
||||
//
|
||||
// Use this to refuse privileged accounts, never to grant them anything: the
|
||||
// undetermined case is safe for a refusal and unsafe for a grant.
|
||||
func isPrivilegedOrUnknown(username string) bool {
|
||||
// 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 := s.CheckPrivileges(PrivilegeCheckRequest{
|
||||
RequestedUsername: username,
|
||||
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" {
|
||||
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.
|
||||
// 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 {
|
||||
if getCurrentOS() == "windows" {
|
||||
return getProcessElevated()
|
||||
return isWindowsElevated()
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -28,8 +27,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
|
||||
originalLookupUser := lookupUser
|
||||
originalGetCurrentOS := getCurrentOS
|
||||
originalGetEuid := getEuid
|
||||
originalGetProcessElevated := getProcessElevated
|
||||
originalGetWindowsAccountPrivilegedOrUnknown := getWindowsAccountPrivilegedOrUnknown
|
||||
|
||||
// Reset caches to ensure clean test state
|
||||
|
||||
// Set test values - inject platform dependencies
|
||||
getCurrentUser = func() (*user.User, error) {
|
||||
@@ -54,31 +53,16 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
|
||||
return euid
|
||||
}
|
||||
|
||||
// Simulate the Windows token elevation check based on the fixture user:
|
||||
// the built-in Administrator (RID 500) and SYSTEM run elevated.
|
||||
getProcessElevated = func() bool {
|
||||
// Mock privilege detection based on the test user
|
||||
getIsProcessPrivileged = func() bool {
|
||||
if currentUser == nil {
|
||||
return false
|
||||
}
|
||||
return currentUser.Uid == "S-1-5-18" || strings.HasSuffix(currentUser.Uid, "-500")
|
||||
}
|
||||
|
||||
// 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":
|
||||
// Check both username and SID for Windows systems
|
||||
if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return isPrivilegedUsername(currentUser.Username)
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
@@ -87,8 +71,10 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
|
||||
lookupUser = originalLookupUser
|
||||
getCurrentOS = originalGetCurrentOS
|
||||
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) {
|
||||
// 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 {
|
||||
name string
|
||||
username string
|
||||
@@ -449,9 +432,25 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
|
||||
{"unix_regular_user", "alice", "linux", false},
|
||||
{"unix_root_capital", "Root", "linux", false}, // Case-sensitive
|
||||
|
||||
// Windows dispatch to the (mocked) account classifier
|
||||
// Windows tests
|
||||
{"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_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 {
|
||||
@@ -460,8 +459,50 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
|
||||
cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
result := isPrivilegedOrUnknown(tt.username)
|
||||
assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform)
|
||||
result := isPrivilegedUsername(tt.username)
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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 {
|
||||
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.
|
||||
// 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) {
|
||||
username, domain := parseUsername(localUser.Username)
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
|
||||
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
|
||||
func parseUsername(fullUsername string) (username, domain string) {
|
||||
func (s *Server) parseUsername(fullUsername string) (username, domain string) {
|
||||
// Handle DOMAIN\username format
|
||||
if idx := strings.LastIndex(fullUsername, `\`); idx != -1 {
|
||||
domain = fullUsername[:idx]
|
||||
|
||||
@@ -46,10 +46,7 @@ func ParseDaemonStatus(s string) DaemonStatus {
|
||||
|
||||
// ConvertOptions holds parameters for ConvertToStatusOutputOverview.
|
||||
type ConvertOptions struct {
|
||||
Anonymize bool
|
||||
// AnonymizeLevel selects how much the anonymizer redacts. Only
|
||||
// meaningful when Anonymize is set.
|
||||
AnonymizeLevel anonymize.Level
|
||||
Anonymize bool
|
||||
DaemonVersion string
|
||||
DaemonStatus DaemonStatus
|
||||
StatusFilter string
|
||||
@@ -220,7 +217,6 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
|
||||
if opts.Anonymize {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(opts.AnonymizeLevel)
|
||||
anonymizeOverview(anonymizer, &overview)
|
||||
}
|
||||
|
||||
@@ -980,7 +976,6 @@ func timeAgo(t time.Time) string {
|
||||
|
||||
func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) {
|
||||
peer.FQDN = a.AnonymizeDomain(peer.FQDN)
|
||||
peer.PubKey = a.AnonymizeWGKey(peer.PubKey)
|
||||
if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil {
|
||||
peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port)
|
||||
}
|
||||
@@ -1012,7 +1007,6 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL)
|
||||
overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error)
|
||||
|
||||
overview.PubKey = a.AnonymizeWGKey(overview.PubKey)
|
||||
overview.IP = a.AnonymizeIPString(overview.IP)
|
||||
overview.IPv6 = a.AnonymizeIPString(overview.IPv6)
|
||||
for i, detail := range overview.Relays.Details {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Name=Netbird
|
||||
Name=NetBird
|
||||
Comment=NetBird desktop client
|
||||
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
|
||||
Icon=netbird
|
||||
Type=Application
|
||||
|
||||
47
client/ui/build/linux/polkit/io.netbird.settings.policy
Normal file
47
client/ui/build/linux/polkit/io.netbird.settings.policy
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
|
||||
<!--
|
||||
Names the action behind the elevation prompt the desktop app raises for an SSH
|
||||
setting the daemon restricts to root; without it pkexec's generic dialog offers
|
||||
the raw command line instead. The argv1 annotation keeps this wording to the
|
||||
one-shot mode that applies those settings.
|
||||
|
||||
auth_admin rather than auth_admin_keep: each of these settings is its own grant
|
||||
of shell access, so a credential cache would let a second, unasked-for change
|
||||
ride along on the authorization given the first.
|
||||
|
||||
exec.path takes no wildcard and the binary's location depends on the package,
|
||||
hence one action per path.
|
||||
-->
|
||||
<policyconfig>
|
||||
<vendor>NetBird</vendor>
|
||||
<vendor_url>https://netbird.io</vendor_url>
|
||||
|
||||
<action id="io.netbird.settings.apply-privileged">
|
||||
<description>Change privileged NetBird settings</description>
|
||||
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
|
||||
<icon_name>netbird</icon_name>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/netbird-ui</annotate>
|
||||
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
|
||||
</action>
|
||||
|
||||
<action id="io.netbird.settings.apply-privileged-local">
|
||||
<description>Change privileged NetBird settings</description>
|
||||
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
|
||||
<icon_name>netbird</icon_name>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/local/bin/netbird-ui</annotate>
|
||||
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -71,12 +71,10 @@ type BundleOptions = {
|
||||
hasWindow: boolean;
|
||||
totalSec: number;
|
||||
uploadUrl: string;
|
||||
anonymizeLevel: AnonymizeLevel;
|
||||
anonymize: boolean;
|
||||
systemInfo: boolean;
|
||||
};
|
||||
|
||||
export type AnonymizeLevel = "none" | "default" | "strict";
|
||||
|
||||
const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
|
||||
try {
|
||||
// Mirror the CLI's safety margin: window + 30s, server caps at 10m.
|
||||
@@ -189,10 +187,7 @@ const runBundleFlow = async (
|
||||
|
||||
if (opts.uploadUrl) setStage({ kind: "uploading" });
|
||||
const result = await DebugSvc.Bundle({
|
||||
anonymize: opts.anonymizeLevel !== "none",
|
||||
// The daemon only knows "default" and "strict"; "none" is expressed
|
||||
// through the anonymize flag being off.
|
||||
anonymizeLevel: opts.anonymizeLevel === "strict" ? "strict" : "default",
|
||||
anonymize: opts.anonymize,
|
||||
systemInfo: opts.systemInfo,
|
||||
uploadUrl: opts.uploadUrl,
|
||||
logFileCount,
|
||||
@@ -203,7 +198,7 @@ const runBundleFlow = async (
|
||||
};
|
||||
|
||||
const useDebugBundle = () => {
|
||||
const [anonymizeLevel, setAnonymizeLevel] = useState<AnonymizeLevel>("none");
|
||||
const [anonymize, setAnonymize] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
const [upload, setUpload] = useState(true);
|
||||
const [trace, setTrace] = useState(true);
|
||||
@@ -245,7 +240,7 @@ const useDebugBundle = () => {
|
||||
hasWindow: capture && totalSec > 0,
|
||||
totalSec,
|
||||
uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
|
||||
anonymizeLevel,
|
||||
anonymize,
|
||||
systemInfo,
|
||||
};
|
||||
|
||||
@@ -277,8 +272,8 @@ const useDebugBundle = () => {
|
||||
};
|
||||
|
||||
return {
|
||||
anonymizeLevel,
|
||||
setAnonymizeLevel,
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
systemInfo,
|
||||
setSystemInfo,
|
||||
upload,
|
||||
|
||||
@@ -22,12 +22,18 @@ const logSaveError = (err: unknown) => console.error("[SettingsContext] save fai
|
||||
|
||||
export type AutostartState = { supported: boolean; enabled: boolean };
|
||||
|
||||
// GuardedField is a setting the daemon only accepts from root/administrator.
|
||||
// Turning one on goes through saveGuardedField, which asks the operating system
|
||||
// for the privileges rather than sending a request that would be refused.
|
||||
export type GuardedField = "serverSshAllowed" | "enableSshRoot" | "disableSshAuth";
|
||||
|
||||
type SettingsContextValue = {
|
||||
config: Config;
|
||||
guiVersion: string;
|
||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
||||
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
|
||||
saveGuardedField: (k: GuardedField, v: boolean) => Promise<void>;
|
||||
saveNow: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -63,6 +69,12 @@ const useSettingsState = () => {
|
||||
const [guiVersion, setGuiVersion] = useState<string>("—");
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loadedRef = useRef<LoadedConfig | null>(null);
|
||||
// Set when the daemon's config changed while a save was pending, so the read
|
||||
// that was skipped to protect the pending edit happens once it is through.
|
||||
// Without it the form keeps values the daemon no longer has and the next save
|
||||
// submits them, which for a guarded setting means asking the user to authorize
|
||||
// a change they never made.
|
||||
const reloadOwed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadedRef.current = loaded;
|
||||
@@ -73,6 +85,7 @@ const useSettingsState = () => {
|
||||
// update the daemon then rejected.
|
||||
const reload = useCallback(
|
||||
async (profileName: string) => {
|
||||
reloadOwed.current = false;
|
||||
try {
|
||||
const data = await SettingsSvc.GetConfig({ profileName, username });
|
||||
setLoaded({ profileName, data });
|
||||
@@ -94,7 +107,12 @@ const useSettingsState = () => {
|
||||
username,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (saveTimer.current) return;
|
||||
// A pending edit outranks the daemon's copy until it is saved, so
|
||||
// the read is owed rather than dropped: see reloadOwed.
|
||||
if (saveTimer.current) {
|
||||
reloadOwed.current = true;
|
||||
return;
|
||||
}
|
||||
setLoaded({ profileName: activeProfileId, data });
|
||||
} catch (e) {
|
||||
if (cancelled || !showError) return;
|
||||
@@ -141,12 +159,17 @@ const useSettingsState = () => {
|
||||
async (profileName: string, next: Config, preSharedKey?: string) => {
|
||||
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
|
||||
try {
|
||||
await SettingsSvc.SetConfig({
|
||||
const { declined } = await SettingsSvc.SetConfig({
|
||||
...next,
|
||||
...preSharedKeyWrite,
|
||||
profileName,
|
||||
username,
|
||||
});
|
||||
// The change needed authorization and the user said no, so the
|
||||
// optimistic update is wrong. Nothing to report: they know.
|
||||
if (declined || reloadOwed.current) {
|
||||
await reload(profileName);
|
||||
}
|
||||
} catch (e) {
|
||||
// The optimistic update is wrong now: the daemon refused it
|
||||
// (a change that needs elevated privileges, an MDM-managed
|
||||
@@ -206,6 +229,59 @@ const useSettingsState = () => {
|
||||
[loaded, save],
|
||||
);
|
||||
|
||||
// saveGuardedField applies a setting the daemon restricts to
|
||||
// root/administrator by having the Go side run the app again under the
|
||||
// platform's elevation prompt (UAC, the macOS authentication dialog, polkit).
|
||||
// The prompt is the user's, so the call is made straight from their gesture
|
||||
// and never from the debounce.
|
||||
const saveGuardedField = useCallback(
|
||||
async (k: GuardedField, v: boolean) => {
|
||||
const cur = loadedRef.current;
|
||||
if (!cur) return;
|
||||
|
||||
// Flush what the debounce still owes, before the optimistic update
|
||||
// below joins it: a later save carrying the guarded value would be
|
||||
// refused, and its error dialog would be the second one for a change
|
||||
// the user already authorized.
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
await save(cur.profileName, cur.data);
|
||||
}
|
||||
|
||||
const next: LoadedConfig = {
|
||||
profileName: cur.profileName,
|
||||
data: { ...cur.data, [k]: v },
|
||||
};
|
||||
loadedRef.current = next;
|
||||
setLoaded(next);
|
||||
|
||||
try {
|
||||
await SettingsSvc.SetGuardedSettings({
|
||||
profileName: cur.profileName,
|
||||
username,
|
||||
[k]: v,
|
||||
});
|
||||
} catch (e) {
|
||||
// The daemon is authoritative either way, so re-read before
|
||||
// reporting. A declined prompt is not an error and does not come
|
||||
// through here at all; this is a prompt that could not be raised,
|
||||
// which carries the command that would have done it.
|
||||
await reload(cur.profileName);
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: errorMessage(e),
|
||||
Command: errorCommand(e),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Either the change went through or the user declined it. The daemon
|
||||
// says which.
|
||||
await reload(cur.profileName);
|
||||
},
|
||||
[username, save, reload],
|
||||
);
|
||||
|
||||
const saveFields = useCallback(
|
||||
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
|
||||
if (!loaded) return;
|
||||
@@ -225,15 +301,27 @@ const useSettingsState = () => {
|
||||
[loaded, save],
|
||||
);
|
||||
|
||||
return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
|
||||
return {
|
||||
config: loaded?.data ?? null,
|
||||
guiVersion,
|
||||
setField,
|
||||
saveField,
|
||||
saveFields,
|
||||
saveGuardedField,
|
||||
saveNow,
|
||||
};
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } =
|
||||
useSettingsState();
|
||||
|
||||
const value = useMemo<SettingsContextValue | null>(
|
||||
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
|
||||
[config, guiVersion, setField, saveField, saveFields, saveNow],
|
||||
() =>
|
||||
config
|
||||
? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow }
|
||||
: null,
|
||||
[config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow],
|
||||
);
|
||||
|
||||
if (!value) {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import { Privilege } from "@bindings/services/models.js";
|
||||
import { type Privilege } from "@bindings/services/models.js";
|
||||
|
||||
// usePrivilege reports whether this UI process may perform the changes the daemon
|
||||
// restricts to root/administrator. It is answered in-process from our own token
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"
|
||||
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
@@ -20,7 +19,6 @@ export function SettingsGeneral() {
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
|
||||
useManagementUrl();
|
||||
const { mdm, features } = useRestrictions();
|
||||
const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const managementUrlId = useId();
|
||||
@@ -59,15 +57,6 @@ export function SettingsGeneral() {
|
||||
helpText={t("settings.general.autostart.help")}
|
||||
/>
|
||||
)}
|
||||
<FancyToggleSwitch
|
||||
value={keepConnected ?? false}
|
||||
onChange={(v) => {
|
||||
void setKeepConnectedOnQuit(v);
|
||||
}}
|
||||
loading={keepConnected === null}
|
||||
label={t("settings.general.keepConnectedOnQuit.label")}
|
||||
helpText={t("settings.general.keepConnectedOnQuit.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
{!mdm.managementURL && !features.disableUpdateSettings && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
@@ -6,51 +7,91 @@ import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
||||
import { Privilege } from "@bindings/services/models.js";
|
||||
import type { Privilege } from "@bindings/services/models.js";
|
||||
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const { config, setField, saveGuardedField } = useSettings();
|
||||
const privilege = usePrivilege();
|
||||
// The field whose elevation prompt is currently up, if any. The prompt is
|
||||
// modal to the operating system, not to us, so the guarded controls are held
|
||||
// still meanwhile rather than allowed to stack a second one behind it.
|
||||
const [authorizing, setAuthorizing] = useState<GuardedField | null>(null);
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
|
||||
const authorize = async (field: GuardedField, value: boolean) => {
|
||||
setAuthorizing(field);
|
||||
try {
|
||||
await saveGuardedField(field, value);
|
||||
} finally {
|
||||
setAuthorizing(null);
|
||||
}
|
||||
};
|
||||
|
||||
// The daemon restricts only the direction that hands out shells from a process
|
||||
// running as root. So for an unprivileged user a guarded control is either
|
||||
// unavailable (it is off and only they could turn it on) or a one-way switch
|
||||
// (it is on, they may turn it off, but not back on) — say which, either way.
|
||||
// running as root: for all three settings that is switching the field on.
|
||||
//
|
||||
// An unprivileged user gets that direction routed through the platform's
|
||||
// elevation prompt where there is one to raise, and otherwise the old
|
||||
// arrangement, where the control is either unavailable (it is off and only a
|
||||
// privileged caller could turn it on) or a one-way switch (it is on, they may
|
||||
// turn it off but not back on) with the command that does it.
|
||||
//
|
||||
// A null privilege means we could not determine it: leave the control alone
|
||||
// rather than greying it out with nothing to explain why. The daemon enforces
|
||||
// this regardless, and a rejected save reports its own guidance.
|
||||
const guarded = (
|
||||
guardedDirectionActive: boolean,
|
||||
field: GuardedField,
|
||||
command: (p: Privilege) => string,
|
||||
// inverted marks a control whose guarded direction is switching it off, so
|
||||
// the one-way warning has to read the other way round.
|
||||
inverted = false,
|
||||
) => {
|
||||
const plain = (value: boolean) => setField(field, value);
|
||||
if (!privilege || privilege.privileged) {
|
||||
return { disabled: false, hint: undefined };
|
||||
return { apply: plain, disabled: false, hint: undefined };
|
||||
}
|
||||
const hint = (
|
||||
<PrivilegeHint
|
||||
actor={privilege.actor}
|
||||
command={command(privilege)}
|
||||
|
||||
const guardedDirectionActive = config[field];
|
||||
const hint = (pending: boolean, command?: string) => (
|
||||
<GuardedHint
|
||||
actor={actorLabel(privilege, t)}
|
||||
oneWay={guardedDirectionActive}
|
||||
inverted={inverted}
|
||||
pending={pending}
|
||||
command={command}
|
||||
/>
|
||||
);
|
||||
return { disabled: !guardedDirectionActive, hint };
|
||||
|
||||
if (privilege.canElevate) {
|
||||
return {
|
||||
// Switching off is ours to do; only switching on is authorized.
|
||||
apply: (value: boolean) => {
|
||||
if (!value) {
|
||||
plain(value);
|
||||
return;
|
||||
}
|
||||
void authorize(field, value);
|
||||
},
|
||||
disabled: authorizing !== null,
|
||||
hint: hint(authorizing === field),
|
||||
};
|
||||
}
|
||||
return {
|
||||
apply: plain,
|
||||
disabled: !guardedDirectionActive,
|
||||
hint: hint(false, command(privilege)),
|
||||
};
|
||||
};
|
||||
|
||||
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
|
||||
const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot);
|
||||
const sshServer = guarded("serverSshAllowed", (p) => p.allowSshServer);
|
||||
const sshRoot = guarded("enableSshRoot", (p) => p.enableSshRoot);
|
||||
// Inverted control: the guarded direction is switching authentication off, so
|
||||
// it is the already-disabled state that is the one-way one.
|
||||
const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true);
|
||||
const sshAuth = guarded("disableSshAuth", (p) => p.disableSshAuth, true);
|
||||
const jwtTtlId = useId();
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
@@ -84,7 +125,7 @@ export function SettingsSSH() {
|
||||
<SectionGroup title={t("settings.ssh.section.server")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
onChange={sshServer.apply}
|
||||
disabled={sshServer.disabled}
|
||||
label={t("settings.ssh.server.label")}
|
||||
helpText={t("settings.ssh.server.help")}
|
||||
@@ -98,7 +139,7 @@ export function SettingsSSH() {
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
onChange={sshRoot.apply}
|
||||
disabled={sshRoot.disabled}
|
||||
label={t("settings.ssh.root.label")}
|
||||
helpText={t("settings.ssh.root.help")}
|
||||
@@ -130,7 +171,7 @@ export function SettingsSSH() {
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", !v)}
|
||||
onChange={(v) => sshAuth.apply(!v)}
|
||||
disabled={sshAuth.disabled}
|
||||
label={t("settings.ssh.jwt.label")}
|
||||
helpText={t("settings.ssh.jwt.help")}
|
||||
@@ -163,41 +204,81 @@ export function SettingsSSH() {
|
||||
);
|
||||
}
|
||||
|
||||
// PrivilegeHint explains what an unprivileged user can and cannot do with a
|
||||
// guarded control, and offers the command that does it with the privileges the
|
||||
// daemon requires. oneWay covers the control being in the guarded state already:
|
||||
// switching it back is the part that needs privileges.
|
||||
function PrivilegeHint({
|
||||
// actorLabel names the principal the daemon requires, in the user's language. The
|
||||
// Go side reports which one it is rather than wording it, because "administrator
|
||||
// privileges" is English and a translated sentence cannot borrow it.
|
||||
function actorLabel(privilege: Privilege, t: TFunction): string {
|
||||
return privilege.actorKey === "administrator"
|
||||
? t("settings.ssh.privilege.actorAdministrator")
|
||||
: t("settings.ssh.privilege.actorRoot");
|
||||
}
|
||||
|
||||
// GuardedHint is what a control the daemon guards says to an unprivileged user.
|
||||
// There are three things worth saying, and it says at most one:
|
||||
//
|
||||
// - A prompt is open. Worth a line because it can take a few seconds to appear,
|
||||
// long enough that a control which merely went inert would read as a hang.
|
||||
// - The setting is in its guarded state already (oneWay), so the user may switch
|
||||
// it back as they please and it is switching it away again that will ask. No
|
||||
// command either way: the direction they can take is theirs to take.
|
||||
// - Only a privileged caller can move it at all, and there is no prompt to
|
||||
// raise: the command that does it belongs here, and nothing else will do.
|
||||
//
|
||||
// Which leaves the case of a control whose guarded direction is still ahead of the
|
||||
// user and a prompt that can be raised for it: nothing to say, because clicking it
|
||||
// raises the prompt and the prompt explains itself.
|
||||
function GuardedHint({
|
||||
actor,
|
||||
command,
|
||||
oneWay,
|
||||
inverted,
|
||||
pending,
|
||||
command,
|
||||
}: {
|
||||
actor: string;
|
||||
command: string;
|
||||
oneWay: boolean;
|
||||
inverted: boolean;
|
||||
pending: boolean;
|
||||
command?: string;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (pending) {
|
||||
return <HintBox>{t("settings.ssh.privilege.authorizePending")}</HintBox>;
|
||||
}
|
||||
if (oneWay) {
|
||||
return (
|
||||
<HintBox>
|
||||
<span>
|
||||
{inverted
|
||||
? t("settings.ssh.privilege.oneWayInverted", { actor })
|
||||
: t("settings.ssh.privilege.oneWay", { actor })}
|
||||
</span>
|
||||
</HintBox>
|
||||
);
|
||||
}
|
||||
if (!command) return null;
|
||||
return (
|
||||
<HintBox>
|
||||
<span>{t("settings.ssh.privilege.hint", { actor })}</span>
|
||||
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
|
||||
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
</HintBox>
|
||||
);
|
||||
}
|
||||
|
||||
// HintBox is the box a guarded control puts its explanation in, directly under the
|
||||
// control it belongs to.
|
||||
function HintBox({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{!oneWay
|
||||
? t("settings.ssh.privilege.hint", { actor })
|
||||
: inverted
|
||||
? t("settings.ssh.privilege.oneWayInverted", { actor })
|
||||
: t("settings.ssh.privilege.oneWay", { actor })}
|
||||
</span>
|
||||
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
|
||||
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useId, type ReactNode } from "react";
|
||||
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 { Debug as DebugSvc } from "@bindings/services";
|
||||
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 { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import HelpText from "@/components/typography/HelpText.tsx";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext";
|
||||
import type { DebugStage } from "@/contexts/DebugBundleContext";
|
||||
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
|
||||
@@ -33,8 +24,8 @@ export function SettingsTroubleshooting() {
|
||||
const { t } = useTranslation();
|
||||
const durationId = useId();
|
||||
const {
|
||||
anonymizeLevel,
|
||||
setAnonymizeLevel,
|
||||
anonymize,
|
||||
setAnonymize,
|
||||
systemInfo,
|
||||
setSystemInfo,
|
||||
upload,
|
||||
@@ -64,71 +55,12 @@ export function SettingsTroubleshooting() {
|
||||
|
||||
return (
|
||||
<SectionGroup title={t("settings.troubleshooting.section.title")}>
|
||||
<div className={"flex items-center justify-between gap-6"}>
|
||||
<div className={"max-w-md flex-1"}>
|
||||
<Label as={"div"}>
|
||||
<span className={"inline-flex items-center gap-1.5"}>
|
||||
{t("settings.troubleshooting.anonymize.label")}
|
||||
<Tooltip
|
||||
content={
|
||||
<div className={"max-w-xs whitespace-normal leading-relaxed"}>
|
||||
{t("settings.troubleshooting.anonymize.info")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Info
|
||||
size={14}
|
||||
aria-label={t("settings.troubleshooting.anonymize.label")}
|
||||
className={"shrink-0 cursor-default text-nb-gray-400"}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
{t("settings.troubleshooting.anonymize.help")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"shrink-0"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("settings.troubleshooting.anonymize.label")}
|
||||
className={cn(
|
||||
"inline-flex h-[40px] min-w-[160px] items-center justify-between gap-2 px-3",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
|
||||
)}
|
||||
>
|
||||
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} className={"min-w-[160px]"}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={anonymizeLevel}
|
||||
onValueChange={(v) => setAnonymizeLevel(v as AnonymizeLevel)}
|
||||
>
|
||||
<DropdownMenuRadioItem value={"none"}>
|
||||
{t("settings.troubleshooting.anonymize.none")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value={"default"}>
|
||||
{t("settings.troubleshooting.anonymize.default")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value={"strict"}>
|
||||
{t("settings.troubleshooting.anonymize.strict")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<FancyToggleSwitch
|
||||
value={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label={t("settings.troubleshooting.anonymize.label")}
|
||||
helpText={t("settings.troubleshooting.anonymize.help")}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={systemInfo}
|
||||
onChange={setSystemInfo}
|
||||
|
||||
@@ -2,24 +2,9 @@
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
> 💡 **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
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) |
|
||||
| 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) |
|
||||
| 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
- **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.
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
- **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
|
||||
|
||||
- [ ] Valid JSON · **every `en` key** present, same order · **no `description`** fields
|
||||
- [ ] 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
|
||||
- [ ] Crowdin QA flags resolved (variables, glossary terms, punctuation)
|
||||
- [ ] New language added to `_index.json`
|
||||
- [ ] **Tested in the running 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.
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Alle sichtbaren Ressourcen umschalten"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Einstellungsbereiche"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Zu Profil \"{name}\" wechseln?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Debug-Paket fehlgeschlagen"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Einstellungsbereiche"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Allgemein"
|
||||
},
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Ändern des Autostarts fehlgeschlagen"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Nach dem Beenden verbunden bleiben",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Anzeigesprache"
|
||||
},
|
||||
@@ -1338,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Vorgang fehlgeschlagen."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird konnte auf diesem System nicht die nötigen Rechte anfordern. Führen Sie stattdessen dies aus:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "Die Änderung konnte mit erhöhten Rechten nicht angewendet werden. Führen Sie stattdessen dies aus:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "root-Rechte"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "Administratorrechte"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Erfordert {actor}. Führen Sie stattdessen dies aus:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Warten auf Autorisierung…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,14 +735,6 @@
|
||||
"message": "Autostart Change Failed",
|
||||
"description": "Error-dialog title when changing the autostart setting fails."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Stay Connected After Quitting",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "The connection stays up in the background after you close NetBird. It only stops when you disconnect it yourself.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Display Language",
|
||||
"description": "Label for the display-language picker."
|
||||
@@ -1013,27 +1005,11 @@
|
||||
},
|
||||
"settings.troubleshooting.anonymize.label": {
|
||||
"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": {
|
||||
"message": "Hides IP addresses, domains, and other sensitive values.",
|
||||
"description": "Helper text under the anonymization dropdown. The level details live in the info tooltip."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "Default keeps internal IPv4 addresses and peer names readable for support. Strict additionally anonymizes private (RFC 1918), CGNAT, and link-local IP addresses, peer names, and WireGuard public keys. Recurring values map to the same placeholder, so peers stay distinguishable. Use Strict when sharing the bundle outside your organization.",
|
||||
"description": "Info tooltip explaining the anonymization levels. 'RFC 1918', 'CGNAT', 'link-local', and 'WireGuard' are technical terms — keep them."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
"message": "None",
|
||||
"description": "Dropdown option: no anonymization."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.default": {
|
||||
"message": "Default",
|
||||
"description": "Dropdown option: default anonymization level."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.strict": {
|
||||
"message": "Strict",
|
||||
"description": "Dropdown option: strict anonymization level."
|
||||
"message": "Hides public IP addresses and non-NetBird domains from logs.",
|
||||
"description": "Helper text for anonymizing logs (hides public IPs and non-NetBird domains)."
|
||||
},
|
||||
"settings.troubleshooting.systemInfo.label": {
|
||||
"message": "Include System Information",
|
||||
@@ -1799,16 +1775,36 @@
|
||||
"message": "Operation failed.",
|
||||
"description": "Generic fallback error message used when no specific error applies."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird could not ask this system for the privileges the change needs. Run this instead:",
|
||||
"description": "Error: this computer has no way to prompt for elevated privileges. Followed by a copyable command that applies the setting from a terminal."
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "The change could not be applied with elevated privileges. Run this instead:",
|
||||
"description": "Error: the authorization succeeded but applying the setting afterwards failed. Followed by a copyable command that applies the setting from a terminal."
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "root",
|
||||
"description": "Fills {actor} in the settings.ssh.privilege.* messages on Linux, macOS and BSD, where the daemon requires the root account. 'root' is an account name and stays as it is; add the word for privileges or rights around it if the sentence needs one to read naturally."
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "administrator privileges",
|
||||
"description": "Fills {actor} in the settings.ssh.privilege.* messages on Windows, where the daemon requires an elevated administrator. The Windows term for the rights an account is asked to elevate to."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Requires {actor}. Run this instead:",
|
||||
"description": "Help text under an SSH setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "You can switch this off, but switching it back on needs {actor}:",
|
||||
"description": "Warning under an SSH setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
"message": "You can switch this off, but switching it back on needs {actor}.",
|
||||
"description": "Help text under an SSH setting that is already on: an unprivileged user may switch it off freely, and switching it on again is what needs the privileges. No command follows, since the direction they can take is theirs to take. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "You can switch this on, but switching it back off needs {actor}:",
|
||||
"description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
"message": "You can switch this on, but switching it back off needs {actor}.",
|
||||
"description": "Same as settings.ssh.privilege.oneWay, for the SSH authentication setting once it has been switched off: switching it off again is what needs the privileges."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Waiting for authorization…",
|
||||
"description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Conmutar todos los recursos visibles"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Secciones de configuración"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "¿Cambiar el perfil a «{name}»?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Error en el paquete de diagnóstico"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Secciones de configuración"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "General"
|
||||
},
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Error al cambiar el inicio automático"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Permanecer conectado al salir",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Idioma de la interfaz"
|
||||
},
|
||||
@@ -1338,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "La operación falló."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird no pudo solicitar a este sistema los privilegios necesarios. Ejecute esto en su lugar:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "No se pudo aplicar el cambio con privilegios elevados. Ejecute esto en su lugar:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "privilegios de root"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "privilegios de administrador"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Requiere {actor}. Ejecute esto en su lugar:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Puede desactivarlo, pero volver a activarlo requiere {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Esperando la autorización…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Activer/désactiver toutes les ressources visibles"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Sections des paramètres"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Basculer vers le profil « {name} » ?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Échec du lot de diagnostic"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Sections des paramètres"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Général"
|
||||
},
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Échec de la modification du démarrage automatique"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Rester connecté après la fermeture",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Langue d’affichage"
|
||||
},
|
||||
@@ -1338,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "L’opération a échoué."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird n’a pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "La modification n’a pas pu être appliquée avec des privilèges élevés. Exécutez plutôt ceci :"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "les privilèges root"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "les privilèges administrateur"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Nécessite {actor}. Exécutez plutôt ceci :"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "En attente de l’autorisation…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Összes látható erőforrás be/ki"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Beállítások szakaszai"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Váltás a(z) \"{name}\" profilra?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Hibakeresési csomag sikertelen"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Beállítások szakaszai"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Általános"
|
||||
},
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Az automatikus indítás módosítása sikertelen"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Kapcsolat megtartása kilépéskor",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Megjelenítési nyelv"
|
||||
},
|
||||
@@ -1338,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "A művelet meghiúsult."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "A NetBird nem tudta bekérni a rendszertől a szükséges jogosultságokat. Futtassa inkább ezt:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "A módosítást emelt szintű jogosultságokkal sem sikerült alkalmazni. Futtassa inkább ezt:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "root jogosultság"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "rendszergazdai jogosultság"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "{actor} szükséges hozzá. Futtassa inkább ezt:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Várakozás az engedélyezésre…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Attiva/disattiva tutte le risorse visibili"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Sezioni delle impostazioni"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Passare al profilo «{name}»?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Pacchetto di debug non riuscito"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Sezioni delle impostazioni"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Generale"
|
||||
},
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Modifica avvio automatico non riuscita"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Resta connesso dopo la chiusura",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Lingua dell'interfaccia"
|
||||
},
|
||||
@@ -1338,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Operazione non riuscita."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird non ha potuto richiedere a questo sistema i privilegi necessari. Esegua invece questo:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "Non è stato possibile applicare la modifica con privilegi elevati. Esegua invece questo:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "i privilegi di root"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "i privilegi di amministratore"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Richiede {actor}. Esegua invece questo:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Può disabilitarlo, ma riabilitarlo richiede {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "In attesa dell'autorizzazione…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "自動起動の変更に失敗しました"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "終了後も接続を維持",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "表示言語"
|
||||
},
|
||||
@@ -1312,6 +1304,9 @@
|
||||
"daemon.outdated.description": {
|
||||
"message": "このアプリを使用するには NetBird サービスを更新してください。"
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "最新版をダウンロード"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。"
|
||||
},
|
||||
@@ -1335,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作に失敗しました。"
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird はこのシステムに必要な権限を要求できませんでした。代わりに次のコマンドを実行してください:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "昇格した権限でも変更を適用できませんでした。代わりに次のコマンドを実行してください:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "root 権限"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "管理者権限"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "{actor}が必要です。代わりに次のコマンドを実行してください:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "無効にはできますが、再度有効にするには{actor}が必要です。"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "有効にはできますが、再度無効にするには{actor}が必要です。"
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "承認を待っています…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Alternar todos os recursos visíveis"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Seções das configurações"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Alternar perfil para \"{name}\"?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Falha no pacote de depuração"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Seções das configurações"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Geral"
|
||||
},
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Falha ao alterar o início automático"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Permanecer conectado ao sair",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Idioma de exibição"
|
||||
},
|
||||
@@ -1338,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "A operação falhou."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "O NetBird não conseguiu solicitar a este sistema os privilégios necessários. Execute isto em vez disso:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "Não foi possível aplicar a alteração com privilégios elevados. Execute isto em vez disso:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "privilégios de root"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "privilégios de administrador"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Requer {actor}. Execute isto em vez disso:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Você pode desativar isto, mas ativar novamente requer {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Você pode ativar isto, mas desativar novamente requer {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Aguardando a autorização…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Переключить все видимые ресурсы"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Разделы настроек"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Переключиться на профиль «{name}»?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Не удалось создать отладочный пакет"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Разделы настроек"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Общие"
|
||||
},
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Не удалось изменить автозапуск"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Оставаться подключённым после выхода",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Язык интерфейса"
|
||||
},
|
||||
@@ -1338,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Не удалось выполнить операцию."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird не смог запросить у этой системы нужные права. Выполните вместо этого:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "Не удалось применить изменение с повышенными правами. Выполните вместо этого:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "права root"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "права администратора"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Требуются {actor}. Выполните вместо этого:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Отключить можно, но чтобы включить снова, нужны {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Включить можно, но чтобы отключить снова, нужны {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Ожидание авторизации…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "切换所有可见资源"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "设置部分"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "切换到配置文件“{name}”?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "创建调试包失败"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "设置部分"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "常规"
|
||||
},
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "更改自启动设置失败"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "退出后保持连接",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "显示语言"
|
||||
},
|
||||
@@ -1338,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作失败。"
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird 无法向此系统请求所需的权限。请改为运行:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "即使使用提升的权限也无法应用此更改。请改为运行:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "root 权限"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "管理员权限"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "需要{actor}。请改为运行:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "您可以关闭此项,但重新开启需要{actor}。"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "您可以开启此项,但再次关闭需要{actor}。"
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "正在等待授权…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"flag"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
@@ -79,6 +80,14 @@ func init() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// The one-shot that applies the settings the daemon restricts to
|
||||
// root/administrator, which this binary runs itself as under the platform's
|
||||
// elevation prompt. Handled before anything GUI so no window, tray or
|
||||
// single-instance lock is involved.
|
||||
if services.IsPrivilegedSettingsRun(os.Args[1:]) {
|
||||
os.Exit(runPrivilegedSettings(os.Args[1:]))
|
||||
}
|
||||
|
||||
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
|
||||
conn := NewConn(daemonAddr)
|
||||
|
||||
@@ -180,7 +189,6 @@ func main() {
|
||||
WindowManager: windowManager,
|
||||
Session: authSession,
|
||||
Localizer: localizer,
|
||||
Preferences: prefStore,
|
||||
})
|
||||
listenForShowSignal(context.Background(), tray)
|
||||
|
||||
|
||||
@@ -58,10 +58,6 @@ type UIPreferences struct {
|
||||
// decision has run for this OS user. It only ever transitions to true
|
||||
// and is never reset, so the default-on flow runs at most once, ever.
|
||||
AutostartInitialized bool `json:"autostartInitialized"`
|
||||
// KeepConnectedOnQuit leaves the daemon connected when the GUI quits.
|
||||
// Its false zero value preserves the historical disconnect-on-quit
|
||||
// behaviour for preference files written before the field existed.
|
||||
KeepConnectedOnQuit bool `json:"keepConnectedOnQuit"`
|
||||
}
|
||||
|
||||
// LanguageValidator rejects SetLanguage inputs with no shipped bundle.
|
||||
@@ -187,26 +183,6 @@ func (s *Store) SetAutostartInitialized(done bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetKeepConnectedOnQuit persists the disconnect-on-quit opt-out. No-op if unchanged.
|
||||
func (s *Store) SetKeepConnectedOnQuit(keep bool) error {
|
||||
s.mu.Lock()
|
||||
if s.current.KeepConnectedOnQuit == keep {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.KeepConnectedOnQuit = keep
|
||||
if err := s.persistLocked(next); err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("persist preferences: %w", err)
|
||||
}
|
||||
s.current = next
|
||||
s.mu.Unlock()
|
||||
|
||||
s.broadcast(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLanguage validates, persists, and broadcasts. No-op if unchanged.
|
||||
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
|
||||
if lang == "" {
|
||||
|
||||
@@ -238,42 +238,6 @@ func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) {
|
||||
assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk")
|
||||
}
|
||||
|
||||
func TestStore_SetKeepConnectedOnQuitPersistsAcrossReload(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
emitter := &recordingEmitter{}
|
||||
s, err := NewStore(nil, emitter)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, s.Get().KeepConnectedOnQuit, "quitting must disconnect by default")
|
||||
|
||||
require.NoError(t, s.SetKeepConnectedOnQuit(true))
|
||||
assert.True(t, s.Get().KeepConnectedOnQuit, "Get should reflect the persisted opt-out")
|
||||
require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first write should broadcast")
|
||||
|
||||
require.NoError(t, s.SetKeepConnectedOnQuit(true))
|
||||
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent write should not broadcast again")
|
||||
|
||||
reloaded, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reloaded.Get().KeepConnectedOnQuit, "opt-out must survive a reload from disk")
|
||||
}
|
||||
|
||||
func TestStore_KeepConnectedOnQuitDefaultsFalseForPreExistingFile(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
|
||||
// A preferences file written before the field existed must keep the
|
||||
// historical disconnect-on-quit behaviour rather than silently opting out.
|
||||
path, err := preferencesPath()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"language":"en","viewMode":"default"}`), 0o600))
|
||||
|
||||
s, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, s.Get().KeepConnectedOnQuit, "a file predating the field must not opt out of disconnect-on-quit")
|
||||
assert.True(t, s.ExistedAtLoad(), "the pre-existing file must be seen on disk")
|
||||
}
|
||||
|
||||
func TestStore_ExistedAtLoad(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
|
||||
|
||||
27
client/ui/privileged_settings.go
Normal file
27
client/ui/privileged_settings.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
)
|
||||
|
||||
// The one-shot mode this binary runs itself in, elevated, to apply the settings the
|
||||
// daemon restricts to root/administrator. It is handled before anything GUI, so no
|
||||
// window, tray or single-instance lock is involved.
|
||||
//
|
||||
// Only the wiring is here: what the mode accepts and does lives beside the code
|
||||
// that asks for it, in services.RunPrivilegedSettings, so the settings it will
|
||||
// apply are declared once. There is nothing privileged about the mode itself; it
|
||||
// sends the same request the frontend would have sent, and the daemon authorizes it
|
||||
// from the identity the kernel reports on the control channel exactly as it does
|
||||
// for `sudo netbird up`.
|
||||
func runPrivilegedSettings(args []string) int {
|
||||
return services.RunPrivilegedSettings(args, func(addr string) (proto.DaemonServiceClient, error) {
|
||||
if addr == "" {
|
||||
addr = DaemonAddr()
|
||||
}
|
||||
return NewConn(addr).Client()
|
||||
})
|
||||
}
|
||||
@@ -49,11 +49,5 @@ func getCursorPosition(app *application.App) (application.Point, bool) {
|
||||
if app == nil || app.Screen == nil {
|
||||
return p, true
|
||||
}
|
||||
// The wails GTK3 backend caches screens from the active window; a tray app
|
||||
// has none at startup, so the cache is empty and PhysicalToDipPoint would
|
||||
// dereference a nil nearest screen. Raw pixels are correct there anyway.
|
||||
if app.Screen.ScreenNearestPhysicalPoint(p) == nil {
|
||||
return p, true
|
||||
}
|
||||
return app.Screen.PhysicalToDipPoint(p), true
|
||||
}
|
||||
|
||||
@@ -15,13 +15,10 @@ import (
|
||||
)
|
||||
|
||||
type DebugBundleParams struct {
|
||||
Anonymize bool `json:"anonymize"`
|
||||
// AnonymizeLevel is "default" or "strict"; strict also anonymizes
|
||||
// private IP ranges, peer names, and WireGuard public keys.
|
||||
AnonymizeLevel string `json:"anonymizeLevel"`
|
||||
SystemInfo bool `json:"systemInfo"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
LogFileCount uint32 `json:"logFileCount"`
|
||||
Anonymize bool `json:"anonymize"`
|
||||
SystemInfo bool `json:"systemInfo"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
LogFileCount uint32 `json:"logFileCount"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
|
||||
Anonymize: p.Anonymize,
|
||||
AnonymizeLevel: p.AnonymizeLevel,
|
||||
SystemInfo: p.SystemInfo,
|
||||
UploadURL: p.UploadURL,
|
||||
LogFileCount: p.LogFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
Anonymize: p.Anonymize,
|
||||
SystemInfo: p.SystemInfo,
|
||||
UploadURL: p.UploadURL,
|
||||
LogFileCount: p.LogFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
})
|
||||
if err != nil {
|
||||
return DebugBundleResult{}, err
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user